Changeset View
Changeset View
Standalone View
Standalone View
looper/admin.py
| import copy | import copy | ||||
| import typing | from typing import ( | ||||
| Any, | |||||
| Callable, | |||||
| Dict, | |||||
| Optional, | |||||
| Sequence, | |||||
| Set, | |||||
| Tuple, | |||||
| Union, | |||||
| ) | |||||
| import django.db.models | |||||
| from django.contrib import admin | from django.contrib import admin | ||||
| from django.db.models.query import QuerySet | |||||
| from django.http import HttpRequest | |||||
| from django.urls import reverse | |||||
| from django.utils.html import format_html | from django.utils.html import format_html | ||||
| from django.utils.safestring import SafeText | from django.utils.safestring import SafeText | ||||
| from django.urls import reverse | import django.db.models | ||||
| from looper import forms, models, decorators | |||||
| from looper.utils import make_absolute_url | from looper.utils import make_absolute_url | ||||
| from . import forms, models, decorators | |||||
| USER_SEARCH_FIELDS = ('user__email', 'user__customer__billing_email', | USER_SEARCH_FIELDS = ( | ||||
| 'user__customer__full_name', 'user__username') | 'user__email', | ||||
| 'user__customer__billing_email', | |||||
| 'user__customer__full_name', | |||||
| 'user__username', | |||||
| ) | |||||
| def interval(instance) -> str: | def interval(instance: models.Subscription) -> str: | ||||
| """Combines interval_unit and interval_length into one string. | """Combines interval_unit and interval_length into one string. | ||||
| This helps making the admin's list_display a little bit tidier. | This helps making the admin's list_display a little bit tidier. | ||||
| """ | """ | ||||
| if instance.interval_length == 1: | if instance.interval_length == 1: | ||||
| return instance.interval_unit | return instance.interval_unit | ||||
| return f'{instance.interval_length} {instance.interval_unit}' | return f'{instance.interval_length} {instance.interval_unit}' | ||||
| @decorators.short_description('ID') | @decorators.short_description('ID') | ||||
| def id_column(instance) -> str: | def id_column(instance: django.db.models.Model) -> str: | ||||
| pk = instance.pk | pk = instance.pk | ||||
| if pk is None: | if pk is None: | ||||
| return '-' | return '-' | ||||
| if isinstance(pk, int): | if isinstance(pk, int): | ||||
| return f'#{pk}' | return f'#{pk}' | ||||
| return str(pk) | return str(pk) | ||||
| @decorators.short_description('') | @decorators.short_description('') | ||||
| def create_subscription_button_link(planvar: models.PlanVariation) -> str: | def create_subscription_button_link(planvar: models.PlanVariation) -> str: | ||||
| assert isinstance(planvar, models.PlanVariation), \ | assert isinstance(planvar, models.PlanVariation), f'Expected PlanVariation, not {type(planvar)}' | ||||
| f'Expected PlanVariation, not {type(planvar)}' | |||||
| if planvar.pk is None: | if planvar.pk is None: | ||||
| return '' | return '' | ||||
| from urllib.parse import urlencode | from urllib.parse import urlencode | ||||
| button_url = reverse('admin:looper_subscription_add') | button_url = reverse('admin:looper_subscription_add') | ||||
| query = urlencode({ | query = urlencode( | ||||
| { | |||||
| 'plan': planvar.plan_id, | 'plan': planvar.plan_id, | ||||
| 'collection_method': 'manual', | 'collection_method': 'manual', | ||||
| 'currency': planvar.currency, | 'currency': planvar.currency, | ||||
| 'price': planvar.price.decimals_string, | 'price': planvar.price.decimals_string, | ||||
| 'interval_unit': planvar.interval_unit, | 'interval_unit': planvar.interval_unit, | ||||
| 'interval_length': planvar.interval_length, | 'interval_length': planvar.interval_length, | ||||
| }) | } | ||||
| ) | |||||
| return format_html( | return format_html( | ||||
| '<span class="object-tools"><a class="historylink" href="{}">Create Subscription</a></span>', | '<span class="object-tools"><a class="historylink" href="{}">Create Subscription</a></span>', | ||||
| f'{button_url}?{query}') | f'{button_url}?{query}', | ||||
| ) | |||||
| class PlanVariationInline(admin.TabularInline): | class PlanVariationInline(admin.TabularInline): | ||||
| model = models.PlanVariation | model = models.PlanVariation | ||||
| min_num = 1 | min_num = 1 | ||||
| extra = 0 | extra = 0 | ||||
| fields = [id_column, 'currency', | fields = [ | ||||
| 'price', 'interval_unit', 'interval_length', 'is_default_for_currency', | id_column, | ||||
| 'collection_method', 'is_active', create_subscription_button_link] | 'currency', | ||||
| 'price', | |||||
| 'interval_unit', | |||||
| 'interval_length', | |||||
| 'is_default_for_currency', | |||||
| 'collection_method', | |||||
| 'is_active', | |||||
| create_subscription_button_link, | |||||
| ] | |||||
| readonly_fields = [id_column, create_subscription_button_link] | readonly_fields = [id_column, create_subscription_button_link] | ||||
| @admin.register(models.Gateway) | @admin.register(models.Gateway) | ||||
| class GatewayAdmin(admin.ModelAdmin): | class GatewayAdmin(admin.ModelAdmin): | ||||
| list_display = ['name', 'is_default', 'frontend_name'] | list_display = ['name', 'is_default', 'frontend_name'] | ||||
| list_display_links = list_display | list_display_links = list_display | ||||
| admin.site.register(models.Product) | admin.site.register(models.Product) | ||||
| LinkFunc = typing.Callable[[django.db.models.Model], str] | LinkFunc = Callable[[django.db.models.Model], str] | ||||
| def create_admin_fk_link(field_name: str, short_description: str, view_name: str) -> LinkFunc: | def create_admin_fk_link(field_name: str, short_description: str, view_name: str) -> LinkFunc: | ||||
| """Construct a function that constructs a link to the admin:xxxx_change form. | """Construct a function that constructs a link to the admin:xxxx_change form. | ||||
| :param field_name: The object is taken from model_instance.{field_name} | :param field_name: The object is taken from model_instance.{field_name} | ||||
| :param short_description: The label for this link as shown in the admin. | :param short_description: The label for this link as shown in the admin. | ||||
| :param view_name: The admin view to link to. Must take one parameter `object_id`. | :param view_name: The admin view to link to. Must take one parameter `object_id`. | ||||
| For example, to create a link to a customer (instead of a drop-down to edit it), | For example, to create a link to a customer (instead of a drop-down to edit it), | ||||
| use: | use: | ||||
| `customer_link = create_admin_fk_link('customer', 'Customer', 'admin:looper_customer_change')` | `customer_link = create_admin_fk_link('customer', 'Customer', 'admin:looper_customer_change')` | ||||
| """ | """ | ||||
| def create_link(model_instance) -> str: | def create_link(model_instance: django.db.models.Model) -> str: | ||||
| referenced = getattr(model_instance, field_name) | referenced: Optional[object] = getattr(model_instance, field_name) | ||||
| if referenced is None: | if referenced is None: | ||||
| return '-' | return '-' | ||||
| assert isinstance(referenced, django.db.models.Model), \ | assert isinstance( | ||||
| f'Expected Model, not {type(referenced)}' | referenced, django.db.models.Model | ||||
| admin_link = reverse(view_name, | ), f'Expected Model, not {type(referenced)}' | ||||
| kwargs={'object_id': referenced.pk}) | admin_link = reverse(view_name, kwargs={'object_id': referenced.pk}) | ||||
| return format_html('<a href="{}">{}</a>', admin_link, str(referenced)) | return format_html('<a href="{}">{}</a>', admin_link, str(referenced)) | ||||
| # Callable[[Model], str] doesn't have those Django-specific attributes, | # Callable[[Model], str] doesn't have those Django-specific attributes, | ||||
| # and I don't feel like speccing that all out. ~~Sybren | # and I don't feel like speccing that all out. ~~Sybren | ||||
| create_link.admin_order_field = field_name # type: ignore | create_link.admin_order_field = field_name # type: ignore | ||||
| create_link.short_description = short_description # type: ignore | create_link.short_description = short_description # type: ignore | ||||
| return create_link | return create_link | ||||
| def customer_link(model_instance: models.Customer) -> str: | def customer_link(model_instance: models.Customer) -> str: | ||||
| customer = getattr(model_instance, 'customer') | customer = getattr(model_instance, 'customer') | ||||
| if customer is None: | if customer is None: | ||||
| return '-' | return '-' | ||||
| user = customer.user | user = customer.user | ||||
| admin_link = reverse('admin:auth_user_change', kwargs={'object_id': user.pk}) | admin_link = reverse('admin:auth_user_change', kwargs={'object_id': user.pk}) | ||||
| return format_html('<a href="{}">{}</a>', admin_link, str(customer)) | return format_html('<a href="{}">{}</a>', admin_link, str(customer)) | ||||
| customer_link.admin_order_field = 'customer' # type: ignore | customer_link.admin_order_field = 'customer' # type: ignore | ||||
| # TODO(Sybren): this is a total hack, as the membership is only a single product, and | # TODO(Sybren): this is a total hack, as the membership is only a single product, and | ||||
| # there can be subscriptions without memberships. Furthermore, this creates a dependency | # there can be subscriptions without memberships. Furthermore, this creates a dependency | ||||
| # cycle between Blender Fund Main and Looper. For now it's a quick way to get the link, though. | # cycle between Blender Fund Main and Looper. For now it's a quick way to get the link, though. | ||||
| membership_link = create_admin_fk_link('membership', 'Membership', | membership_link = create_admin_fk_link( | ||||
| 'admin:blender_fund_main_membership_change') | 'membership', 'Membership', 'admin:blender_fund_main_membership_change' | ||||
| subscription_link = create_admin_fk_link('subscription', 'subscription', | ) | ||||
| 'admin:looper_subscription_change') | subscription_link = create_admin_fk_link( | ||||
| 'subscription', 'subscription', 'admin:looper_subscription_change' | |||||
| ) | |||||
| order_link = create_admin_fk_link('order', 'order', 'admin:looper_order_change') | order_link = create_admin_fk_link('order', 'order', 'admin:looper_order_change') | ||||
| plan_link = create_admin_fk_link('plan', 'plan', 'admin:looper_plan_change') | plan_link = create_admin_fk_link('plan', 'plan', 'admin:looper_plan_change') | ||||
| product_link = create_admin_fk_link('product', 'product', 'admin:looper_product_change') | product_link = create_admin_fk_link('product', 'product', 'admin:looper_product_change') | ||||
| user_link = create_admin_fk_link('user', 'user', 'admin:auth_user_change') | user_link = create_admin_fk_link('user', 'user', 'admin:auth_user_change') | ||||
| @admin.register(models.Plan) | @admin.register(models.Plan) | ||||
| class PlanAdmin(admin.ModelAdmin): | class PlanAdmin(admin.ModelAdmin): | ||||
| list_display = [id_column, 'name', 'is_active', product_link] | list_display = [id_column, 'name', 'is_active', product_link] | ||||
| list_display_links = [id_column, 'name', 'is_active'] | list_display_links = [id_column, 'name', 'is_active'] | ||||
| inlines = [ | inlines = [ | ||||
| PlanVariationInline, | PlanVariationInline, | ||||
| ] | ] | ||||
| def copy_to_clipboard_link(view_name: str, kwargs: dict, link_text: str, title: str) -> SafeText: | def copy_to_clipboard_link( | ||||
| view_name: str, kwargs: Dict[str, object], link_text: str, title: str | |||||
| ) -> SafeText: | |||||
| """Construct a link that copies a link to the clipboard when clicked. | """Construct a link that copies a link to the clipboard when clicked. | ||||
| This is for things like payment links, e.g. links that should be sent to | This is for things like payment links, e.g. links that should be sent to | ||||
| a customer (instead of followed by the admin). | a customer (instead of followed by the admin). | ||||
| """ | """ | ||||
| link = reverse(view_name, kwargs=kwargs) | link = reverse(view_name, kwargs=kwargs) | ||||
| link = make_absolute_url(link) | link = make_absolute_url(link) | ||||
| return format_html( | return format_html( | ||||
| '<a href="{}" title="{}" data-clipboard-text="{}">{}</a><br>' | '<a href="{}" title="{}" data-clipboard-text="{}">{}</a><br>' | ||||
| '<span class="help">{}</span>', | '<span class="help">{}</span>', | ||||
| link, title, link, link_text, 'Click link to copy') | link, | ||||
| title, | |||||
| link, | |||||
| link_text, | |||||
| 'Click link to copy', | |||||
| ) | |||||
| @decorators.short_description('Payment Link') | @decorators.short_description('Payment Link') | ||||
| def payment_link(model_instance: typing.Optional[models.Order]) -> str: | def payment_link(model_instance: Optional[models.Order]) -> str: | ||||
| """Show the payment link, can be sent to users.""" | """Show the payment link, can be sent to users.""" | ||||
| if not model_instance or not model_instance.pk: | if not model_instance or not model_instance.pk: | ||||
| return '' | return '' | ||||
| return copy_to_clipboard_link('looper:checkout_existing_order', | return copy_to_clipboard_link( | ||||
| 'looper:checkout_existing_order', | |||||
| kwargs={'order_id': model_instance.pk}, | kwargs={'order_id': model_instance.pk}, | ||||
| link_text='Payment Link', | link_text='Payment Link', | ||||
| title='Send this to customers') | title='Send this to customers', | ||||
| ) | |||||
| class OrderInline(admin.TabularInline): | class OrderInline(admin.TabularInline): | ||||
| model = models.Order | model = models.Order | ||||
| min_num = 0 | min_num = 0 | ||||
| extra = 0 | extra = 0 | ||||
| show_change_link = True | show_change_link = True | ||||
| fields = ['status', 'created_at', 'updated_at', 'price', 'payment_method', 'collection_method', | fields = [ | ||||
| 'collection_attempts', payment_link] | 'status', | ||||
| 'created_at', | |||||
| 'updated_at', | |||||
| 'price', | |||||
| 'payment_method', | |||||
| 'collection_method', | |||||
| 'collection_attempts', | |||||
| payment_link, | |||||
| ] | |||||
| readonly_fields = fields | readonly_fields = fields | ||||
| can_delete = False | can_delete = False | ||||
| FieldsetType = typing.Sequence[typing.Tuple[typing.Optional[str], typing.Dict[str, typing.Any]]] | FieldsetType = Sequence[Tuple[Optional[str], Dict[str, Any]]] | ||||
| class EditableWhenNewMixin: | class EditableWhenNewMixin: | ||||
| # These should be set on the subclass using this mix-in. | # These should be set on the subclass using this mix-in. | ||||
| readonly_fields: typing.Sequence[typing.Union[str, typing.Callable]] | readonly_fields: Sequence[Union[str, Callable[..., object]]] | ||||
| fieldsets: FieldsetType | fieldsets: FieldsetType | ||||
| # Those callable fields will become editable when adding a new instance. | # Those callable fields will become editable when adding a new instance. | ||||
| # Assumes that these callables have a 'admin_order_field' property that | # Assumes that these callables have a 'admin_order_field' property that | ||||
| # contains the field they map to. | # contains the field they map to. | ||||
| editable_when_new: typing.Set[typing.Callable] = set() | editable_when_new: Set[Callable[..., object]] = set() | ||||
| def get_readonly_fields(self, request, obj=None): | def get_readonly_fields( | ||||
| self, request: HttpRequest, obj: Optional[django.db.models.Model] = None | |||||
| ) -> Any: | |||||
| if obj: # Editing an existing object | if obj: # Editing an existing object | ||||
| return self.readonly_fields | return self.readonly_fields | ||||
| # When adding a new subscription, remove the the 'xxx_link' callables | # When adding a new subscription, remove the the 'xxx_link' callables | ||||
| # from the readonly_fields list. This optimises for the more common | # from the readonly_fields list. This optimises for the more common | ||||
| # case of subscriptions being created by the checkout process, rather | # case of subscriptions being created by the checkout process, rather | ||||
| # than manually via the admin. | # than manually via the admin. | ||||
| return [field for field in self.readonly_fields | return [field for field in self.readonly_fields if not hasattr(field, '__call__')] | ||||
| if not hasattr(field, '__call__')] | |||||
| def get_fieldsets(self, request, obj=None) -> FieldsetType: | def get_fieldsets( | ||||
| self, request: HttpRequest, obj: Optional[django.db.models.Model] = None | |||||
| ) -> Any: | |||||
| if obj: # Editing an existing object. | if obj: # Editing an existing object. | ||||
| return self.fieldsets | return self.fieldsets | ||||
| fieldsets = copy.deepcopy(self.fieldsets) | fieldsets = copy.deepcopy(self.fieldsets) | ||||
| for fieldset in fieldsets: | for fieldset in fieldsets: | ||||
| label, config = fieldset | label, config = fieldset | ||||
| if 'fields' not in config: | if 'fields' not in config: | ||||
| continue | continue | ||||
| fields = [] | fields = [] | ||||
| # Replace callables with the field name they map to (using | # Replace callables with the field name they map to (using | ||||
| # the_callable.admin_order_field), or remove callables if | # the_callable.admin_order_field), or remove callables if | ||||
| # they are not in self.editable_when_new. | # they are not in self.editable_when_new. | ||||
| for field in config['fields']: | for field in config['fields']: | ||||
| if not hasattr(field, '__call__'): | if not hasattr(field, '__call__'): | ||||
| fields.append(field) | fields.append(field) | ||||
| continue | continue | ||||
| if field not in self.editable_when_new: | if field not in self.editable_when_new: | ||||
| continue | continue | ||||
| assert hasattr(field, 'admin_order_field'), \ | assert hasattr( | ||||
| f"callable {field} should have an attribute 'admin_order_field'" | field, 'admin_order_field' | ||||
| ), f"callable {field} should have an attribute 'admin_order_field'" | |||||
| fields.append(field.admin_order_field) | fields.append(field.admin_order_field) | ||||
| config['fields'] = fields | config['fields'] = fields | ||||
| return fieldsets | return fieldsets | ||||
| @admin.register(models.Subscription) | @admin.register(models.Subscription) | ||||
| class SubscriptionAdmin(EditableWhenNewMixin, admin.ModelAdmin): | class SubscriptionAdmin(EditableWhenNewMixin, admin.ModelAdmin): | ||||
| list_display = ['id', 'plan', 'status', | list_display = [ | ||||
| 'started_at', 'cancelled_at', | 'id', | ||||
| interval, 'intervals_elapsed', | 'plan', | ||||
| user_link, 'payment_method', 'collection_method'] | 'status', | ||||
| 'started_at', | |||||
| 'cancelled_at', | |||||
| interval, | |||||
| 'intervals_elapsed', | |||||
| user_link, | |||||
| 'payment_method', | |||||
| 'collection_method', | |||||
| ] | |||||
| list_display_links = ['id', 'plan', 'status', 'started_at', 'cancelled_at'] | list_display_links = ['id', 'plan', 'status', 'started_at', 'cancelled_at'] | ||||
| list_filter = ['plan', 'status', | list_filter = [ | ||||
| 'created_at', 'started_at', 'cancelled_at', | 'plan', | ||||
| 'collection_method'] | 'status', | ||||
| 'created_at', | |||||
| 'started_at', | |||||
| 'cancelled_at', | |||||
| 'collection_method', | |||||
| ] | |||||
| search_fields = ['id', *USER_SEARCH_FIELDS] | search_fields = ['id', *USER_SEARCH_FIELDS] | ||||
| date_hierarchy = 'created_at' | date_hierarchy = 'created_at' | ||||
| form = forms.SubscriptionAdminForm | form = forms.SubscriptionAdminForm | ||||
| raw_id_fields = ['user'] | raw_id_fields = ['user'] | ||||
| readonly_fields = [user_link, plan_link, membership_link, | readonly_fields = [ | ||||
| 'created_at', 'updated_at', 'intervals_elapsed', 'last_notification'] | user_link, | ||||
| plan_link, | |||||
| membership_link, | |||||
| 'created_at', | |||||
| 'updated_at', | |||||
| 'intervals_elapsed', | |||||
| 'last_notification', | |||||
| ] | |||||
| editable_when_new = {user_link, plan_link} | editable_when_new = {user_link, plan_link} | ||||
| fieldsets = [ | fieldsets = [ | ||||
| (None, { | ( | ||||
| 'fields': [user_link, plan_link, membership_link, 'status', | None, | ||||
| 'interval_unit', 'interval_length', 'intervals_elapsed'], | { | ||||
| }), | 'fields': [ | ||||
| ('Money', { | user_link, | ||||
| 'fields': ['payment_method', 'collection_method', | plan_link, | ||||
| 'currency', 'price', | membership_link, | ||||
| 'status', | |||||
| 'interval_unit', | |||||
| 'interval_length', | |||||
| 'intervals_elapsed', | |||||
| ], | |||||
| }, | |||||
| ), | |||||
| ( | |||||
| 'Money', | |||||
| { | |||||
| 'fields': [ | |||||
| 'payment_method', | |||||
| 'collection_method', | |||||
| 'currency', | |||||
| 'price', | |||||
| # 'tax', 'tax_type', 'tax_region', | # 'tax', 'tax_type', 'tax_region', | ||||
| ], | ], | ||||
| }), | }, | ||||
| ('Dates', { | ), | ||||
| 'fields': ['created_at', 'updated_at', | ( | ||||
| 'started_at', 'cancelled_at', | 'Dates', | ||||
| { | |||||
| 'fields': [ | |||||
| 'created_at', | |||||
| 'updated_at', | |||||
| 'started_at', | |||||
| 'cancelled_at', | |||||
| 'current_interval_started_at', | 'current_interval_started_at', | ||||
| 'next_payment', 'last_notification'], | 'next_payment', | ||||
| }), | 'last_notification', | ||||
| ], | |||||
| }, | |||||
| ), | |||||
| ] | ] | ||||
| inlines = [ | inlines = [ | ||||
| OrderInline, | OrderInline, | ||||
| ] | ] | ||||
| def refund_button_link(trans: models.Transaction) -> str: | def refund_button_link(trans: models.Transaction) -> str: | ||||
| assert isinstance(trans, models.Transaction), \ | assert isinstance(trans, models.Transaction), f'Expected Model, not {type(trans)}' | ||||
| f'Expected Model, not {type(trans)}' | |||||
| if trans.pk is None: | if trans.pk is None: | ||||
| return '' | return '' | ||||
| refund_link = reverse('admin:looper_transaction_change', | refund_link = reverse('admin:looper_transaction_change', kwargs={'object_id': trans.pk}) | ||||
| kwargs={'object_id': trans.pk}) | |||||
| return format_html( | return format_html( | ||||
| '<span class="object-tools"><a class="historylink" href="{}">View to refund</a></span>', | '<span class="object-tools"><a class="historylink" href="{}">View to refund</a></span>', | ||||
| refund_link) | refund_link, | ||||
| ) | |||||
| class TransactionsInline(admin.TabularInline): | class TransactionsInline(admin.TabularInline): | ||||
| model = models.Transaction | model = models.Transaction | ||||
| min_num = 0 | min_num = 0 | ||||
| extra = 0 | extra = 0 | ||||
| show_change_link = True | show_change_link = True | ||||
| fields = ['status', 'created_at', 'updated_at', 'failure_message', | fields = [ | ||||
| 'amount', 'amount_refunded', 'refunded_at', refund_button_link] | 'status', | ||||
| 'created_at', | |||||
| 'updated_at', | |||||
| 'failure_message', | |||||
| 'amount', | |||||
| 'amount_refunded', | |||||
| 'refunded_at', | |||||
| refund_button_link, | |||||
| ] | |||||
| readonly_fields = fields | readonly_fields = fields | ||||
| can_delete = False | can_delete = False | ||||
| def mark_as_paid(modeladmin, request, queryset): | def mark_as_paid( | ||||
| modeladmin: 'OrderAdmin', request: HttpRequest, queryset: 'QuerySet[models.Order]' | |||||
| ) -> None: | |||||
| """Mark all selected orders as 'paid'.""" | """Mark all selected orders as 'paid'.""" | ||||
| for order in queryset: | for order in queryset: | ||||
| order.status = 'paid' | order.status = 'paid' | ||||
| order.save() | order.save() | ||||
| # TODO(Sybren): this is a total hack, as the receipt PDF view is outside of Looper. | # TODO(Sybren): this is a total hack, as the receipt PDF view is outside of Looper. | ||||
| @decorators.short_description('Receipt') | @decorators.short_description('Receipt') | ||||
| def order_receipt_link(order: models.Order) -> str: | def order_receipt_link(order: models.Order) -> str: | ||||
| if order.status != 'paid': | if order.status != 'paid': | ||||
| return format_html('<span title="{}">-</span>', 'Only available for paid orders') | return format_html('<span title="{}">-</span>', 'Only available for paid orders') | ||||
| pdf_url = reverse('settings_receipt_pdf', kwargs={'order_id': order.id}) | pdf_url = reverse('settings_receipt_pdf', kwargs={'order_id': order.id}) | ||||
| return format_html('<a href="{}" target="_blank">PDF</a>', pdf_url) | return format_html('<a href="{}" target="_blank">PDF</a>', pdf_url) | ||||
| @admin.register(models.Order) | @admin.register(models.Order) | ||||
| class OrderAdmin(admin.ModelAdmin): | class OrderAdmin(admin.ModelAdmin): | ||||
| list_display = ['id', 'status', 'created_at', 'paid_at', 'price', | list_display = [ | ||||
| user_link, subscription_link, | 'id', | ||||
| 'payment_method', 'collection_method', order_receipt_link] | 'status', | ||||
| 'created_at', | |||||
| 'paid_at', | |||||
| 'price', | |||||
| user_link, | |||||
| subscription_link, | |||||
| 'payment_method', | |||||
| 'collection_method', | |||||
| order_receipt_link, | |||||
| ] | |||||
| list_display_links = ['id', 'status', 'created_at', 'paid_at'] | list_display_links = ['id', 'status', 'created_at', 'paid_at'] | ||||
| list_filter = ['status', 'created_at', 'collection_method', 'payment_method__gateway'] | list_filter = ['status', 'created_at', 'collection_method', 'payment_method__gateway'] | ||||
| search_fields = ['id', 'email', *USER_SEARCH_FIELDS] | search_fields = ['id', 'email', *USER_SEARCH_FIELDS] | ||||
| actions = [mark_as_paid] | actions = [mark_as_paid] | ||||
| form = forms.OrderAdminForm | form = forms.OrderAdminForm | ||||
| raw_id_fields = ['user'] | raw_id_fields = ['user'] | ||||
| readonly_fields = [user_link, subscription_link, payment_link, 'created_at', 'updated_at', | readonly_fields = [ | ||||
| 'collection_attempts', 'total_refunded'] | user_link, | ||||
| subscription_link, | |||||
| payment_link, | |||||
| 'created_at', | |||||
| 'updated_at', | |||||
| 'collection_attempts', | |||||
| 'total_refunded', | |||||
| ] | |||||
| fieldsets = [ | fieldsets = [ | ||||
| (None, { | (None, {'fields': [user_link, subscription_link, payment_link, 'status', 'name']}), | ||||
| 'fields': [user_link, subscription_link, payment_link, 'status', 'name'], | ( | ||||
| }), | 'Money', | ||||
| ('Money', { | { | ||||
| 'fields': ['payment_method', 'collection_method', 'collection_attempts', | 'fields': [ | ||||
| 'currency', 'price', 'total_refunded', | 'payment_method', | ||||
| 'collection_method', | |||||
| 'collection_attempts', | |||||
| 'currency', | |||||
| 'price', | |||||
| 'total_refunded', | |||||
| # 'tax', 'tax_type', 'tax_region', | # 'tax', 'tax_type', 'tax_region', | ||||
| ], | ], | ||||
| }), | }, | ||||
| ('Dates', { | ), | ||||
| ( | |||||
| 'Dates', | |||||
| { | |||||
| 'fields': ['created_at', 'updated_at', 'paid_at', 'retry_after'], | 'fields': ['created_at', 'updated_at', 'paid_at', 'retry_after'], | ||||
| 'classes': ('collapse',), | 'classes': ('collapse',), | ||||
| }), | }, | ||||
| ('Addresses', { | ), | ||||
| 'fields': ['email', 'billing_address'], | ('Addresses', {'fields': ['email', 'billing_address'], 'classes': ('collapse',)}), | ||||
| 'classes': ('collapse',), | |||||
| }), | |||||
| ] | ] | ||||
| inlines = [ | inlines = [ | ||||
| TransactionsInline, | TransactionsInline, | ||||
| ] | ] | ||||
| def total_refunded(self, order: models.Order) -> str: | def total_refunded(self, order: models.Order) -> str: | ||||
| refunded = order.total_refunded() | refunded = order.total_refunded() | ||||
| if not refunded: | if not refunded: | ||||
| return '-' | return '-' | ||||
| return refunded.with_currency_symbol_nonocents() | return refunded.with_currency_symbol_nonocents() | ||||
| @admin.register(models.Transaction) | @admin.register(models.Transaction) | ||||
| class TransactionAdmin(admin.ModelAdmin): | class TransactionAdmin(admin.ModelAdmin): | ||||
| list_display = ('id', 'status', 'created_at', 'updated_at', | list_display = ( | ||||
| 'amount', 'amount_refunded', 'refunded_at') | 'id', | ||||
| 'status', | |||||
| 'created_at', | |||||
| 'updated_at', | |||||
| 'amount', | |||||
| 'amount_refunded', | |||||
| 'refunded_at', | |||||
| ) | |||||
| list_display_links = ('id', 'status', 'created_at', 'updated_at') | list_display_links = ('id', 'status', 'created_at', 'updated_at') | ||||
| list_filter = ('status', 'created_at') | list_filter = ('status', 'created_at') | ||||
| search_fields = ('id', 'transaction_id', *USER_SEARCH_FIELDS) | search_fields = ('id', 'transaction_id', *USER_SEARCH_FIELDS) | ||||
| form = forms.AdminTransactionRefundForm | form = forms.AdminTransactionRefundForm | ||||
| fieldsets = [ | fieldsets = [ | ||||
| (None, { | ( | ||||
| 'fields': [user_link, order_link, | None, | ||||
| 'status', 'failure_message', 'paid', 'transaction_id'], | { | ||||
| }), | 'fields': [ | ||||
| ('Dates', { | user_link, | ||||
| 'fields': ['created_at', 'updated_at'], | order_link, | ||||
| }), | 'status', | ||||
| ('Money', { | 'failure_message', | ||||
| 'fields': ['payment_method', 'currency', 'amount', | 'paid', | ||||
| 'refunded_at', 'amount_refunded', 'refund_amount'], | 'transaction_id', | ||||
| }), | ], | ||||
| }, | |||||
| ), | |||||
| ('Dates', {'fields': ['created_at', 'updated_at']}), | |||||
| ( | |||||
| 'Money', | |||||
| { | |||||
| 'fields': [ | |||||
| 'payment_method', | |||||
| 'currency', | |||||
| 'amount', | |||||
| 'refunded_at', | |||||
| 'amount_refunded', | |||||
| 'refund_amount', | |||||
| ], | |||||
| }, | |||||
| ), | |||||
| ] | ] | ||||
| # Make everything read-only except the amount to refund. | # Make everything read-only except the amount to refund. | ||||
| readonly_fields = (user_link, order_link, 'status', 'failure_message', 'paid', | readonly_fields = ( | ||||
| 'transaction_id', 'created_at', 'updated_at', 'payment_method', | user_link, | ||||
| 'currency', 'amount', 'refunded_at', | order_link, | ||||
| 'amount_refunded') | 'status', | ||||
| 'failure_message', | |||||
| 'paid', | |||||
| 'transaction_id', | |||||
| 'created_at', | |||||
| 'updated_at', | |||||
| 'payment_method', | |||||
| 'currency', | |||||
| 'amount', | |||||
| 'refunded_at', | |||||
| 'amount_refunded', | |||||
| ) | |||||
| class CustomerInline(admin.StackedInline): | class CustomerInline(admin.StackedInline): | ||||
| model = models.Customer | model = models.Customer | ||||
| can_delete = False | can_delete = False | ||||
| # It's a one-on-one relationship, so users have only one 'customer'. | # It's a one-on-one relationship, so users have only one 'customer'. | ||||
| verbose_name_plural = 'customer' | verbose_name_plural = 'customer' | ||||
| Show All 23 Lines | |||||