Skip to content

Commit 6f0c8c8

Browse files
berinhardewdurbin
andauthored
Admin view to list uploaded assets (#1960)
* Bugfix: missing required_asset on querystring to enable post to update single asset * Bugfix: wrong variable name * Add new view to list uploaded assets * Admin list view to list all the assets within the same page * Enable PSF staff to edit uploaded asset information * Only tries to fetch for file URL if there's an existing file * Add custom admin list filter by asset type * Admin list to filter assets by related sponsorship benefits * Optimize DB queries to display content object Django does not allow to select related nested FK from generic fks, such as content_object. Because of that, a lot of collateral queries were being executed to display the sponsorship name because it depends on both its package and sponsor names. This commit changes this by filtering all the sponsorships and organizing it as a in-memory dict that is only load for this page. * Admin asset list filter by content type (sponsor or sponsorship) * Asset list filter by with or without value (uploaded asset) * Admin should prevent user from adding assets Since assets are created automatically by the process to handle new sponsorship applications, having the possibility for users to manually add assets can lead to states that are unknown to the application. This commit prevents that from happening. * Minimal working code to return a zipfile as a HTTP response * Safety check to avoid exporting partial data * Refactor to check if asset has value * More helper properties * Add text assets to the zipfile * Use temporary files to add images to zipfile * Use the sponsor name as the directory name within the zipfile * Unit test action to export assets as a zipfile * fix AssetsInline class for admin * register admin views for File/Response assets Co-authored-by: Ee Durbin <[email protected]>
1 parent f25f223 commit 6f0c8c8

File tree

10 files changed

+416
-14
lines changed

10 files changed

+416
-14
lines changed

sponsors/admin.py

Lines changed: 187 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
from django.contrib.contenttypes.admin import GenericTabularInline
2+
from django.contrib.contenttypes.models import ContentType
23
from ordered_model.admin import OrderedModelAdmin
3-
from polymorphic.admin import PolymorphicInlineSupportMixin, StackedPolymorphicInline
4+
from polymorphic.admin import PolymorphicInlineSupportMixin, StackedPolymorphicInline, PolymorphicParentModelAdmin, \
5+
PolymorphicChildModelAdmin
46

57
from django.db.models import Subquery
68
from django.template import Context, Template
@@ -13,6 +15,7 @@
1315

1416
from mailing.admin import BaseEmailTemplateAdmin
1517
from sponsors.models import *
18+
from sponsors.models.benefits import RequiredAssetMixin
1619
from sponsors import views_admin
1720
from sponsors.forms import SponsorshipReviewAdminForm, SponsorBenefitAdminInlineForm, RequiredImgAssetConfigurationForm, \
1821
SponsorshipBenefitAdminForm
@@ -26,14 +29,16 @@ class AssetsInline(GenericTabularInline):
2629
has_delete_permission = lambda self, request, obj: False
2730
readonly_fields = ["internal_name", "user_submitted_info", "value"]
2831

29-
def value(self, request, obj=None):
32+
def value(self, obj=None):
3033
if not obj or not obj.value:
3134
return ""
3235
return obj.value
36+
3337
value.short_description = "Submitted information"
3438

35-
def user_submitted_info(self, request, obj=None):
36-
return bool(self.value(request, obj))
39+
def user_submitted_info(self, obj=None):
40+
return bool(self.value(obj))
41+
3742
user_submitted_info.short_description = "Fullfilled data?"
3843
user_submitted_info.boolean = True
3944

@@ -348,6 +353,7 @@ def get_queryset(self, *args, **kwargs):
348353

349354
def send_notifications(self, request, queryset):
350355
return views_admin.send_sponsorship_notifications_action(self, request, queryset)
356+
351357
send_notifications.short_description = 'Send notifications to selected'
352358

353359
def get_readonly_fields(self, request, obj):
@@ -431,6 +437,11 @@ def get_urls(self):
431437
self.admin_site.admin_view(self.rollback_to_editing_view),
432438
name="sponsors_sponsorship_rollback_to_edit",
433439
),
440+
path(
441+
"<int:pk>/list-assets",
442+
self.admin_site.admin_view(self.list_uploaded_assets_view),
443+
name="sponsors_sponsorship_list_uploaded_assets",
444+
),
434445
]
435446
return my_urls + urls
436447

@@ -551,6 +562,9 @@ def approve_sponsorship_view(self, request, pk):
551562
def approve_signed_sponsorship_view(self, request, pk):
552563
return views_admin.approve_signed_sponsorship_view(self, request, pk)
553564

565+
def list_uploaded_assets_view(self, request, pk):
566+
return views_admin.list_uploaded_assets(self, request, pk)
567+
554568

555569
@admin.register(LegalClause)
556570
class LegalClauseModelAdmin(OrderedModelAdmin):
@@ -714,9 +728,178 @@ def nullify_contract_view(self, request, pk):
714728

715729
@admin.register(SponsorEmailNotificationTemplate)
716730
class SponsorEmailNotificationTemplateAdmin(BaseEmailTemplateAdmin):
731+
717732
def get_form(self, request, obj=None, **kwargs):
718733
help_texts = {
719734
"content": SPONSOR_TEMPLATE_HELP_TEXT,
720735
}
721736
kwargs.update({"help_texts": help_texts})
722737
return super().get_form(request, obj, **kwargs)
738+
739+
740+
class AssetTypeListFilter(admin.SimpleListFilter):
741+
title = "Asset Type"
742+
parameter_name = 'type'
743+
744+
@property
745+
def assets_types_mapping(self):
746+
return {asset_type.__name__: asset_type for asset_type in GenericAsset.all_asset_types()}
747+
748+
def lookups(self, request, model_admin):
749+
return [(k, v._meta.verbose_name_plural) for k, v in self.assets_types_mapping.items()]
750+
751+
def queryset(self, request, queryset):
752+
asset_type = self.assets_types_mapping.get(self.value())
753+
if not asset_type:
754+
return queryset
755+
return queryset.instance_of(asset_type)
756+
757+
758+
class AssociatedBenefitListFilter(admin.SimpleListFilter):
759+
title = "From Benefit Which Requires Asset"
760+
parameter_name = 'from_benefit'
761+
762+
@property
763+
def benefits_with_assets(self):
764+
qs = BenefitFeature.objects.required_assets().values_list("sponsor_benefit__sponsorship_benefit",
765+
flat=True).distinct()
766+
benefits = SponsorshipBenefit.objects.filter(id__in=Subquery(qs))
767+
return {str(b.id): b for b in benefits}
768+
769+
def lookups(self, request, model_admin):
770+
return [(k, b.name) for k, b in self.benefits_with_assets.items()]
771+
772+
def queryset(self, request, queryset):
773+
benefit = self.benefits_with_assets.get(self.value())
774+
if not benefit:
775+
return queryset
776+
internal_names = [
777+
cfg.internal_name
778+
for cfg in benefit.features_config.all()
779+
if hasattr(cfg, "internal_name")
780+
]
781+
return queryset.filter(internal_name__in=internal_names)
782+
783+
784+
class AssetContentTypeFilter(admin.SimpleListFilter):
785+
title = "Related Object"
786+
parameter_name = 'content_type'
787+
788+
def lookups(self, request, model_admin):
789+
qs = ContentType.objects.filter(model__in=["sponsorship", "sponsor"])
790+
return [(c_type.pk, c_type.model.title()) for c_type in qs]
791+
792+
def queryset(self, request, queryset):
793+
value = self.value()
794+
if not value:
795+
return queryset
796+
return queryset.filter(content_type=value)
797+
798+
799+
class AssetWithOrWithoutValueFilter(admin.SimpleListFilter):
800+
title = "Value"
801+
parameter_name = "value"
802+
803+
def lookups(self, request, model_admin):
804+
return [
805+
("with-value", "With value"),
806+
("no-value", "Without value"),
807+
]
808+
809+
def queryset(self, request, queryset):
810+
value = self.value()
811+
if not value:
812+
return queryset
813+
with_value_id = [asset.pk for asset in queryset if asset.value]
814+
if value == "with-value":
815+
return queryset.filter(pk__in=with_value_id)
816+
else:
817+
return queryset.exclude(pk__in=with_value_id)
818+
819+
820+
@admin.register(GenericAsset)
821+
class GenericAssetModelAdmin(PolymorphicParentModelAdmin):
822+
list_display = ["id", "internal_name", "get_value", "content_type", "get_related_object"]
823+
list_filter = [AssetContentTypeFilter, AssetTypeListFilter, AssetWithOrWithoutValueFilter,
824+
AssociatedBenefitListFilter]
825+
actions = ["export_assets_as_zipfile"]
826+
827+
def get_child_models(self, *args, **kwargs):
828+
return GenericAsset.all_asset_types()
829+
830+
def get_queryset(self, *args, **kwargs):
831+
classes = self.get_child_models(*args, **kwargs)
832+
return self.model.objects.select_related("content_type").instance_of(*classes)
833+
834+
def has_delete_permission(self, *args, **kwargs):
835+
return False
836+
837+
def has_add_permission(self, *args, **kwargs):
838+
return False
839+
840+
@cached_property
841+
def all_sponsors(self):
842+
qs = Sponsor.objects.all()
843+
return {sp.id: sp for sp in qs}
844+
845+
@cached_property
846+
def all_sponsorships(self):
847+
qs = Sponsorship.objects.all().select_related("package", "sponsor")
848+
return {sp.id: sp for sp in qs}
849+
850+
def get_value(self, obj):
851+
html = obj.value
852+
if obj.value and getattr(obj.value, "url", None):
853+
html = f"<a href='{obj.value.url}' target='_blank'>{obj.value}</a>"
854+
return mark_safe(html)
855+
856+
get_value.short_description = "Value"
857+
858+
def get_related_object(self, obj):
859+
"""
860+
Returns the content_object as an URL and performs better because
861+
of sponsors and sponsorship cached properties
862+
"""
863+
content_object = None
864+
if obj.from_sponsorship:
865+
content_object = self.all_sponsorships[obj.object_id]
866+
elif obj.from_sponsor:
867+
content_object = self.all_sponsors[obj.object_id]
868+
869+
if not content_object: # safety belt
870+
return obj.content_object
871+
872+
html = f"<a href='{content_object.admin_url}' target='_blank'>{content_object}</a>"
873+
return mark_safe(html)
874+
875+
get_related_object.short_description = "Associated with"
876+
877+
def export_assets_as_zipfile(self, request, queryset):
878+
return views_admin.export_assets_as_zipfile(self, request, queryset)
879+
export_assets_as_zipfile.short_description = "Export selected"
880+
881+
882+
class GenericAssetChildModelAdmin(PolymorphicChildModelAdmin):
883+
""" Base admin class for all GenericAsset child models """
884+
base_model = GenericAsset
885+
readonly_fields = ["uuid", "content_type", "object_id", "content_object", "internal_name"]
886+
887+
888+
@admin.register(TextAsset)
889+
class TextAssetModelAdmin(GenericAssetChildModelAdmin):
890+
base_model = TextAsset
891+
892+
893+
@admin.register(ImgAsset)
894+
class ImgAssetModelAdmin(GenericAssetChildModelAdmin):
895+
base_model = ImgAsset
896+
897+
898+
@admin.register(FileAsset)
899+
class ImgAssetModelAdmin(GenericAssetChildModelAdmin):
900+
base_model = FileAsset
901+
902+
903+
@admin.register(ResponseAsset)
904+
class ResponseAssetModelAdmin(GenericAssetChildModelAdmin):
905+
base_model = ResponseAsset

sponsors/models/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
structured as a python package.
55
"""
66

7-
from .assets import GenericAsset, ImgAsset, TextAsset
7+
from .assets import GenericAsset, ImgAsset, TextAsset, FileAsset, ResponseAsset
88
from .notifications import SponsorEmailNotificationTemplate, SPONSOR_TEMPLATE_HELP_TEXT
99
from .sponsors import Sponsor, SponsorContact, SponsorBenefit
1010
from .benefits import BaseLogoPlacement, BaseTieredQuantity, BaseEmailTargetable, BenefitFeatureConfiguration, \

sponsors/models/assets.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from django.db import models
1010
from django.contrib.contenttypes.fields import GenericForeignKey
1111
from django.contrib.contenttypes.models import ContentType
12+
from django.db.models.fields.files import ImageFieldFile, FileField
1213
from polymorphic.models import PolymorphicModel
1314

1415

@@ -51,6 +52,29 @@ class Meta:
5152
def value(self):
5253
return None
5354

55+
@property
56+
def is_file(self):
57+
return isinstance(self.value, FileField) or isinstance(self.value, ImageFieldFile)
58+
59+
@property
60+
def from_sponsorship(self):
61+
return self.content_type.name == "sponsorship"
62+
63+
@property
64+
def from_sponsor(self):
65+
return self.content_type.name == "sponsor"
66+
67+
@property
68+
def has_value(self):
69+
if self.is_file:
70+
return self.value and getattr(self.value, "url", None)
71+
else:
72+
return bool(self.value)
73+
74+
@classmethod
75+
def all_asset_types(cls):
76+
return cls.__subclasses__()
77+
5478

5579
class ImgAsset(GenericAsset):
5680
image = models.ImageField(

sponsors/models/sponsors.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from allauth.account.models import EmailAddress
55
from django.conf import settings
66
from django.db import models
7-
from django.core.exceptions import ObjectDoesNotExist
7+
from django.urls import reverse
88
from django_countries.fields import CountryField
99
from ordered_model.models import OrderedModel
1010
from django.contrib.contenttypes.fields import GenericRelation
@@ -102,6 +102,10 @@ def primary_contact(self):
102102
except SponsorContact.DoesNotExist:
103103
return None
104104

105+
@property
106+
def admin_url(self):
107+
return reverse("admin:sponsors_sponsor_change", args=[self.pk])
108+
105109

106110
class SponsorContact(models.Model):
107111
"""

0 commit comments

Comments
 (0)