Skip to content

Commit 30df0ff

Browse files
JacobCoffeeclaude
andcommitted
fix: group asset browser by company, hide expired, show status
Assets grouped by sponsor company with collapsible sections and submitted/total badge (green/orange/red). Expired and rejected sponsorship assets excluded. Each row shows package name and sponsorship status. Companies with missing assets get a Send Reminder link. Refactored view into helper methods for linting. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent dc89152 commit 30df0ff

File tree

3 files changed

+152
-87
lines changed

3 files changed

+152
-87
lines changed

apps/sponsors/manage/tests.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2508,6 +2508,28 @@ def test_filter_by_search(self):
25082508
self.assertContains(response, "logo_2025")
25092509
self.assertNotContains(response, "bio_text")
25102510

2511+
def test_excludes_expired_sponsorship_assets(self):
2512+
"""Assets from expired sponsorships are hidden."""
2513+
today = timezone.now().date()
2514+
self.sponsorship.status = Sponsorship.FINALIZED
2515+
self.sponsorship.start_date = today - datetime.timedelta(days=400)
2516+
self.sponsorship.end_date = today - datetime.timedelta(days=10)
2517+
self.sponsorship.save()
2518+
self._create_text_asset(self.sponsorship, "old_asset", text="stale")
2519+
response = self.client.get(reverse("manage_assets"))
2520+
self.assertNotContains(response, "old_asset")
2521+
2522+
def test_shows_active_sponsorship_assets(self):
2523+
"""Assets from active sponsorships are shown."""
2524+
today = timezone.now().date()
2525+
self.sponsorship.status = Sponsorship.FINALIZED
2526+
self.sponsorship.start_date = today - datetime.timedelta(days=100)
2527+
self.sponsorship.end_date = today + datetime.timedelta(days=265)
2528+
self.sponsorship.save()
2529+
self._create_text_asset(self.sponsorship, "active_asset", text="current")
2530+
response = self.client.get(reverse("manage_assets"))
2531+
self.assertContains(response, "active_asset")
2532+
25112533
def test_nav_has_assets_link(self):
25122534
response = self.client.get(reverse("manage_dashboard"))
25132535
self.assertContains(response, "Assets")

apps/sponsors/manage/views.py

Lines changed: 56 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -457,40 +457,26 @@ class AssetBrowserView(SponsorshipAdminRequiredMixin, TemplateView):
457457

458458
template_name = "sponsors/manage/asset_browser.html"
459459

460-
def get_context_data(self, **kwargs):
461-
"""Return context with filtered assets and lookup caches."""
462-
context = super().get_context_data(**kwargs)
460+
def _apply_queryset_filters(self, qs):
461+
"""Apply database-level filters from query params and return filtered queryset."""
463462
from django.contrib.contenttypes.models import ContentType
464463

465-
qs = GenericAsset.objects.all_assets().select_related("content_type")
466-
467-
# Filters
468-
self.filter_type = self.request.GET.get("type", "")
469-
self.filter_related = self.request.GET.get("related", "")
470-
self.filter_value = self.request.GET.get("value", "")
471-
self.filter_search = self.request.GET.get("search", "")
472-
473464
if self.filter_type:
474465
type_map = {cls.__name__: cls for cls in GenericAsset.all_asset_types()}
475466
if self.filter_type in type_map:
476467
qs = qs.instance_of(type_map[self.filter_type])
477-
478468
if self.filter_related:
479469
with contextlib.suppress(ContentType.DoesNotExist, ValueError):
480470
qs = qs.filter(content_type=ContentType.objects.get(pk=int(self.filter_related)))
481-
482471
if self.filter_search:
483472
qs = qs.filter(internal_name__icontains=self.filter_search)
473+
return qs
484474

485-
assets = list(qs[:200])
486-
487-
# Value filter (property-based, must be done in Python)
488-
if self.filter_value == "with":
489-
assets = [a for a in assets if a.has_value]
490-
elif self.filter_value == "without":
491-
assets = [a for a in assets if not a.has_value]
475+
def _resolve_and_group(self, assets):
476+
"""Resolve owners, exclude expired/rejected, and group assets by company."""
477+
from collections import OrderedDict
492478

493-
# Batch-load related objects to avoid N+1
479+
today = tz.now().date()
494480
sponsor_ids = {a.object_id for a in assets if a.from_sponsor}
495481
sponsorship_ids = {a.object_id for a in assets if a.from_sponsorship}
496482
sponsors_map = {s.pk: s for s in Sponsor.objects.filter(pk__in=sponsor_ids)} if sponsor_ids else {}
@@ -499,18 +485,64 @@ def get_context_data(self, **kwargs):
499485
if sponsorship_ids
500486
else {}
501487
)
488+
489+
def _is_active_sponsorship_asset(a):
490+
sp = sponsorships_map.get(a.object_id)
491+
return sp and sp.status != Sponsorship.REJECTED and not (sp.end_date and sp.end_date < today)
492+
493+
assets = [a for a in assets if a.from_sponsor or _is_active_sponsorship_asset(a)]
494+
502495
for asset in assets:
503496
if asset.from_sponsor:
504-
asset.resolved_owner = sponsors_map.get(asset.object_id)
497+
owner = sponsors_map.get(asset.object_id)
498+
asset.resolved_owner = owner
505499
asset.owner_type = "sponsor"
500+
asset.company_name = owner.name if owner else "Unknown"
506501
else:
507-
asset.resolved_owner = sponsorships_map.get(asset.object_id)
502+
owner = sponsorships_map.get(asset.object_id)
503+
asset.resolved_owner = owner
508504
asset.owner_type = "sponsorship"
505+
asset.company_name = owner.sponsor.name if owner and owner.sponsor else "Unknown"
506+
507+
grouped = OrderedDict()
508+
for asset in assets:
509+
name = asset.company_name
510+
if name not in grouped:
511+
grouped[name] = {"assets": [], "submitted": 0, "total": 0, "sponsorship_id": None}
512+
grouped[name]["assets"].append(asset)
513+
grouped[name]["total"] += 1
514+
if asset.has_value:
515+
grouped[name]["submitted"] += 1
516+
if not grouped[name]["sponsorship_id"] and asset.owner_type == "sponsorship" and asset.resolved_owner:
517+
grouped[name]["sponsorship_id"] = asset.resolved_owner.pk
518+
519+
return assets, grouped
520+
521+
def get_context_data(self, **kwargs):
522+
"""Return context with filtered assets grouped by company."""
523+
context = super().get_context_data(**kwargs)
524+
from django.contrib.contenttypes.models import ContentType
525+
526+
self.filter_type = self.request.GET.get("type", "")
527+
self.filter_related = self.request.GET.get("related", "")
528+
self.filter_value = self.request.GET.get("value", "")
529+
self.filter_search = self.request.GET.get("search", "")
530+
531+
qs = self._apply_queryset_filters(GenericAsset.objects.all_assets().select_related("content_type"))
532+
assets = list(qs[:200])
533+
534+
if self.filter_value == "with":
535+
assets = [a for a in assets if a.has_value]
536+
elif self.filter_value == "without":
537+
assets = [a for a in assets if not a.has_value]
538+
539+
assets, grouped = self._resolve_and_group(assets)
509540

510541
content_types = ContentType.objects.filter(model__in=["sponsor", "sponsorship"])
511542
context.update(
512543
{
513-
"assets": assets,
544+
"grouped_assets": grouped,
545+
"company_count": len(grouped),
514546
"asset_count": len(assets),
515547
"type_choices": [(cls.__name__, cls._meta.verbose_name) for cls in GenericAsset.all_asset_types()],
516548
"content_type_choices": [(ct.pk, ct.model.title()) for ct in content_types],

apps/sponsors/templates/sponsors/manage/asset_browser.html

Lines changed: 74 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -49,73 +49,84 @@
4949
{% endif %}
5050
</div>
5151
<div style="margin-left:auto;font-size:12px;color:#777;">
52-
{{ asset_count }} asset{{ asset_count|pluralize }}{% if asset_count >= 200 %} (first 200){% endif %}
52+
{{ company_count }} sponsor{{ company_count|pluralize }} &middot; {{ asset_count }} asset{{ asset_count|pluralize }}{% if asset_count >= 200 %} (first 200){% endif %}
5353
</div>
5454
</form>
5555
</div>
5656

57-
{% if assets %}
58-
<table class="manage-table">
59-
<thead>
60-
<tr>
61-
<th>Internal Name</th>
62-
<th>Type</th>
63-
<th>Belongs To</th>
64-
<th>Owner</th>
65-
<th style="text-align:center;">Status</th>
66-
<th>Value</th>
67-
</tr>
68-
</thead>
69-
<tbody>
70-
{% for asset in assets %}
71-
<tr>
72-
<td style="font-weight:600;font-size:13px;">{{ asset.internal_name }}</td>
73-
<td>
74-
<span class="tag tag-gray" style="font-size:10px;">{{ asset.polymorphic_ctype.name|title }}</span>
75-
</td>
76-
<td>
77-
{% if asset.owner_type == "sponsor" %}
78-
<span class="tag tag-blue" style="font-size:10px;">Sponsor</span>
79-
{% else %}
80-
<span class="tag tag-gold" style="font-size:10px;">Sponsorship</span>
81-
{% endif %}
82-
</td>
83-
<td style="font-size:13px;">
84-
{% if asset.resolved_owner %}
85-
{% if asset.owner_type == "sponsor" %}
86-
<a href="{% url 'manage_sponsor_edit' asset.resolved_owner.pk %}" style="color:#1a1a2e;text-decoration:none;">{{ asset.resolved_owner.name }}</a>
87-
{% else %}
88-
<a href="{% url 'manage_sponsorship_detail' asset.resolved_owner.pk %}" style="color:#1a1a2e;text-decoration:none;">
89-
{% if asset.resolved_owner.sponsor %}{{ asset.resolved_owner.sponsor.name }}{% else %}#{{ asset.resolved_owner.pk }}{% endif %}
90-
{% if asset.resolved_owner.package %}<span style="color:#999;font-size:11px;"> ({{ asset.resolved_owner.package.name }})</span>{% endif %}
91-
</a>
92-
{% endif %}
93-
{% else %}
94-
<span style="color:#ccc;">&mdash;</span>
95-
{% endif %}
96-
</td>
97-
<td style="text-align:center;">
98-
{% if asset.has_value %}
99-
<span class="tag tag-green" style="font-size:10px;">Submitted</span>
100-
{% else %}
101-
<span class="tag tag-red" style="font-size:10px;">Missing</span>
102-
{% endif %}
103-
</td>
104-
<td style="font-size:12px;color:#555;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">
105-
{% if asset.has_value %}
106-
{% if asset.is_file %}
107-
<a href="{{ asset.value.url }}" target="_blank" style="color:#3776ab;text-decoration:none;">{{ asset.value.name|truncatechars:40 }}</a>
108-
{% else %}
109-
{{ asset.value|truncatechars:60 }}
110-
{% endif %}
111-
{% else %}
112-
<span style="color:#ccc;">&mdash;</span>
113-
{% endif %}
114-
</td>
115-
</tr>
116-
{% endfor %}
117-
</tbody>
118-
</table>
57+
{% if grouped_assets %}
58+
{% for company_name, group in grouped_assets.items %}
59+
<div class="manage-section{% if not filter_value and not filter_search and not filter_type %} collapsed{% endif %}">
60+
<div class="manage-section-header collapse-toggle" onclick="this.parentElement.classList.toggle('collapsed')" style="cursor:pointer;user-select:none;">
61+
<h2>
62+
<span class="collapse-arrow">&#9662;</span>
63+
{{ company_name }}
64+
<span class="badge" style="{% if group.submitted == group.total %}background:#4caf50{% elif group.submitted == 0 %}background:#e74c3c{% else %}background:#e67e22{% endif %}">{{ group.submitted }}/{{ group.total }}</span>
65+
</h2>
66+
{% if group.submitted < group.total and group.sponsorship_id %}
67+
<a href="{% url 'manage_sponsorship_notify' group.sponsorship_id %}" class="btn btn-xs btn-secondary" onclick="event.stopPropagation();" title="Send asset reminder to this sponsor">Send Reminder</a>
68+
{% endif %}
69+
</div>
70+
<div class="collapse-body">
71+
<table class="manage-table">
72+
<thead>
73+
<tr>
74+
<th>Internal Name</th>
75+
<th>Type</th>
76+
<th>Belongs To</th>
77+
<th style="text-align:center;">Status</th>
78+
<th>Value</th>
79+
</tr>
80+
</thead>
81+
<tbody>
82+
{% for asset in group.assets %}
83+
<tr>
84+
<td style="font-weight:600;font-size:13px;">{{ asset.internal_name }}</td>
85+
<td>
86+
<span class="tag tag-gray" style="font-size:10px;">{{ asset.polymorphic_ctype.name|title }}</span>
87+
</td>
88+
<td>
89+
{% if asset.owner_type == "sponsor" %}
90+
<span class="tag tag-blue" style="font-size:10px;">Sponsor</span>
91+
{% else %}
92+
{% if asset.resolved_owner %}
93+
<a href="{% url 'manage_sponsorship_detail' asset.resolved_owner.pk %}" style="text-decoration:none;">
94+
{% if asset.resolved_owner.package %}<span class="tag tag-gold" style="font-size:10px;">{{ asset.resolved_owner.package.name }}</span>{% endif %}
95+
{% if asset.resolved_owner.status == 'applied' %}<span class="tag tag-blue" style="font-size:10px;">Applied</span>
96+
{% elif asset.resolved_owner.status == 'approved' %}<span class="tag tag-gold" style="font-size:10px;">Approved</span>
97+
{% elif asset.resolved_owner.status == 'finalized' %}<span class="tag tag-green" style="font-size:10px;">Finalized</span>
98+
{% endif %}
99+
</a>
100+
{% else %}
101+
<span class="tag tag-gray" style="font-size:10px;">Sponsorship</span>
102+
{% endif %}
103+
{% endif %}
104+
</td>
105+
<td style="text-align:center;">
106+
{% if asset.has_value %}
107+
<span class="tag tag-green" style="font-size:10px;">Submitted</span>
108+
{% else %}
109+
<span class="tag tag-red" style="font-size:10px;">Missing</span>
110+
{% endif %}
111+
</td>
112+
<td style="font-size:12px;color:#555;max-width:250px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">
113+
{% if asset.has_value %}
114+
{% if asset.is_file %}
115+
<a href="{{ asset.value.url }}" target="_blank" style="color:#3776ab;text-decoration:none;">{{ asset.value.name|truncatechars:40 }}</a>
116+
{% else %}
117+
{{ asset.value|truncatechars:80 }}
118+
{% endif %}
119+
{% else %}
120+
<span style="color:#ccc;">&mdash;</span>
121+
{% endif %}
122+
</td>
123+
</tr>
124+
{% endfor %}
125+
</tbody>
126+
</table>
127+
</div>
128+
</div>
129+
{% endfor %}
119130
{% else %}
120131
<div class="empty-state">
121132
<div class="empty-icon">&#128193;</div>

0 commit comments

Comments
 (0)