Skip to content

Commit dc89152

Browse files
JacobCoffeeclaude
andcommitted
feat: asset browser with filters for type, owner, and submission status
Adds an asset browser under More > Assets that lists all sponsor and sponsorship assets with filters for asset type (text, image, file, response), related object (sponsor vs sponsorship), submission status (submitted vs missing), and internal name search. Uses batch-loaded lookups to avoid N+1 queries on generic relations. Removes "Detailed asset filtering" from the admin-only guide section. Includes 6 tests. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 19636fa commit dc89152

File tree

6 files changed

+257
-1
lines changed

6 files changed

+257
-1
lines changed

apps/sponsors/manage/tests.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
SponsorshipCurrentYear,
2525
SponsorshipPackage,
2626
SponsorshipProgram,
27+
TextAsset,
2728
)
2829

2930

@@ -2459,3 +2460,54 @@ def test_move_down(self):
24592460
def test_nav_has_legal_clauses_link(self):
24602461
response = self.client.get(reverse("manage_dashboard"))
24612462
self.assertContains(response, "Legal Clauses")
2463+
2464+
2465+
class AssetBrowserViewTests(SponsorshipReviewTestBase):
2466+
"""Test asset browser view."""
2467+
2468+
def _create_text_asset(self, content_object, internal_name, text=""):
2469+
"""Helper to create a TextAsset via generic relation."""
2470+
from django.contrib.contenttypes.models import ContentType
2471+
2472+
ct = ContentType.objects.get_for_model(content_object)
2473+
return TextAsset.objects.create(
2474+
content_type=ct,
2475+
object_id=content_object.pk,
2476+
internal_name=internal_name,
2477+
text=text,
2478+
)
2479+
2480+
def test_browser_loads(self):
2481+
response = self.client.get(reverse("manage_assets"))
2482+
self.assertEqual(response.status_code, 200)
2483+
2484+
def test_browser_shows_assets(self):
2485+
self._create_text_asset(self.sponsor, "company_bio", text="Acme makes things")
2486+
response = self.client.get(reverse("manage_assets"))
2487+
self.assertContains(response, "company_bio")
2488+
self.assertContains(response, "Acme Corp")
2489+
2490+
def test_filter_by_value_with(self):
2491+
self._create_text_asset(self.sponsor, "filled_asset", text="Has value")
2492+
self._create_text_asset(self.sponsor, "empty_asset", text="")
2493+
response = self.client.get(reverse("manage_assets") + "?value=with")
2494+
self.assertContains(response, "filled_asset")
2495+
self.assertNotContains(response, "empty_asset")
2496+
2497+
def test_filter_by_value_without(self):
2498+
self._create_text_asset(self.sponsor, "filled_asset", text="Has value")
2499+
self._create_text_asset(self.sponsor, "empty_asset", text="")
2500+
response = self.client.get(reverse("manage_assets") + "?value=without")
2501+
self.assertNotContains(response, "filled_asset")
2502+
self.assertContains(response, "empty_asset")
2503+
2504+
def test_filter_by_search(self):
2505+
self._create_text_asset(self.sponsor, "logo_2025", text="logo")
2506+
self._create_text_asset(self.sponsor, "bio_text", text="bio")
2507+
response = self.client.get(reverse("manage_assets") + "?search=logo")
2508+
self.assertContains(response, "logo_2025")
2509+
self.assertNotContains(response, "bio_text")
2510+
2511+
def test_nav_has_assets_link(self):
2512+
response = self.client.get(reverse("manage_dashboard"))
2513+
self.assertContains(response, "Assets")

apps/sponsors/manage/urls.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
path(
2424
"benefit-configs/<int:pk>/delete/", views.BenefitConfigDeleteView.as_view(), name="manage_benefit_config_delete"
2525
),
26+
# Asset browser
27+
path("assets/", views.AssetBrowserView.as_view(), name="manage_assets"),
2628
# Legal clauses
2729
path("legal-clauses/", views.LegalClauseListView.as_view(), name="manage_legal_clauses"),
2830
path("legal-clauses/new/", views.LegalClauseCreateView.as_view(), name="manage_legal_clause_create"),

apps/sponsors/manage/views.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
BenefitFeature,
4949
BenefitFeatureConfiguration,
5050
Contract,
51+
GenericAsset,
5152
LegalClause,
5253
Sponsor,
5354
SponsorBenefit,
@@ -448,6 +449,80 @@ def post(self, request, pk):
448449
return redirect(reverse("manage_legal_clauses"))
449450

450451

452+
# ── Asset Browser ─────────────────────────────────────────────────────
453+
454+
455+
class AssetBrowserView(SponsorshipAdminRequiredMixin, TemplateView):
456+
"""Browse all sponsor/sponsorship assets with filters."""
457+
458+
template_name = "sponsors/manage/asset_browser.html"
459+
460+
def get_context_data(self, **kwargs):
461+
"""Return context with filtered assets and lookup caches."""
462+
context = super().get_context_data(**kwargs)
463+
from django.contrib.contenttypes.models import ContentType
464+
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+
473+
if self.filter_type:
474+
type_map = {cls.__name__: cls for cls in GenericAsset.all_asset_types()}
475+
if self.filter_type in type_map:
476+
qs = qs.instance_of(type_map[self.filter_type])
477+
478+
if self.filter_related:
479+
with contextlib.suppress(ContentType.DoesNotExist, ValueError):
480+
qs = qs.filter(content_type=ContentType.objects.get(pk=int(self.filter_related)))
481+
482+
if self.filter_search:
483+
qs = qs.filter(internal_name__icontains=self.filter_search)
484+
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]
492+
493+
# Batch-load related objects to avoid N+1
494+
sponsor_ids = {a.object_id for a in assets if a.from_sponsor}
495+
sponsorship_ids = {a.object_id for a in assets if a.from_sponsorship}
496+
sponsors_map = {s.pk: s for s in Sponsor.objects.filter(pk__in=sponsor_ids)} if sponsor_ids else {}
497+
sponsorships_map = (
498+
{s.pk: s for s in Sponsorship.objects.filter(pk__in=sponsorship_ids).select_related("sponsor", "package")}
499+
if sponsorship_ids
500+
else {}
501+
)
502+
for asset in assets:
503+
if asset.from_sponsor:
504+
asset.resolved_owner = sponsors_map.get(asset.object_id)
505+
asset.owner_type = "sponsor"
506+
else:
507+
asset.resolved_owner = sponsorships_map.get(asset.object_id)
508+
asset.owner_type = "sponsorship"
509+
510+
content_types = ContentType.objects.filter(model__in=["sponsor", "sponsorship"])
511+
context.update(
512+
{
513+
"assets": assets,
514+
"asset_count": len(assets),
515+
"type_choices": [(cls.__name__, cls._meta.verbose_name) for cls in GenericAsset.all_asset_types()],
516+
"content_type_choices": [(ct.pk, ct.model.title()) for ct in content_types],
517+
"filter_type": self.filter_type,
518+
"filter_related": self.filter_related,
519+
"filter_value": self.filter_value,
520+
"filter_search": self.filter_search,
521+
}
522+
)
523+
return context
524+
525+
451526
class PackageListView(SponsorshipAdminRequiredMixin, ListView):
452527
"""List sponsorship packages grouped by year."""
453528

apps/sponsors/templates/sponsors/manage/_base.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -680,6 +680,7 @@
680680
<a href="#" class="{% if active_tab == 'clone' or active_tab == 'guide' %}active{% endif %}" onclick="event.preventDefault();this.parentElement.classList.toggle('open');">More &#9662;</a>
681681
<div class="manage-nav-dropdown">
682682
<a href="{% url 'manage_legal_clauses' %}">Legal Clauses</a>
683+
<a href="{% url 'manage_assets' %}">Assets</a>
683684
<a href="{% url 'manage_clone_year' %}">Clone Year</a>
684685
<a href="{% url 'manage_guide' %}">Guide</a>
685686
</div>
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
{% extends "sponsors/manage/_base.html" %}
2+
3+
{% block page_title %}Assets | Sponsor Management{% endblock %}
4+
5+
{% with active_tab="more" %}
6+
7+
{% block manage_breadcrumbs %}
8+
<div class="manage-crumbs">
9+
<a href="{% url 'manage_dashboard' %}">Dashboard</a>
10+
<span class="sep">/</span>
11+
<span class="current">Assets</span>
12+
</div>
13+
{% endblock %}
14+
15+
{% block manage_content %}
16+
<!-- Filter bar -->
17+
<div class="manage-filter-bar" style="padding:12px 20px;">
18+
<form method="get" style="display:flex;gap:10px;align-items:flex-end;flex-wrap:wrap;">
19+
<div class="filter-field">
20+
<select name="type" onchange="this.form.submit()">
21+
<option value="">All types</option>
22+
{% for val, label in type_choices %}
23+
<option value="{{ val }}" {% if val == filter_type %}selected{% endif %}>{{ label }}</option>
24+
{% endfor %}
25+
</select>
26+
</div>
27+
<div class="filter-field">
28+
<select name="related" onchange="this.form.submit()">
29+
<option value="">All objects</option>
30+
{% for val, label in content_type_choices %}
31+
<option value="{{ val }}" {% if val|slugify == filter_related %}selected{% endif %}>{{ label }}</option>
32+
{% endfor %}
33+
</select>
34+
</div>
35+
<div class="filter-field">
36+
<select name="value" onchange="this.form.submit()">
37+
<option value="">Any status</option>
38+
<option value="with" {% if filter_value == "with" %}selected{% endif %}>Submitted</option>
39+
<option value="without" {% if filter_value == "without" %}selected{% endif %}>Missing</option>
40+
</select>
41+
</div>
42+
<div class="filter-field">
43+
<input type="text" name="search" placeholder="Search internal name..." value="{{ filter_search }}" style="width:180px;">
44+
</div>
45+
<div class="filter-actions">
46+
<button type="submit" class="btn btn-sm btn-secondary">Go</button>
47+
{% if filter_type or filter_related or filter_value or filter_search %}
48+
<a href="{% url 'manage_assets' %}" class="btn btn-sm btn-secondary">Clear</a>
49+
{% endif %}
50+
</div>
51+
<div style="margin-left:auto;font-size:12px;color:#777;">
52+
{{ asset_count }} asset{{ asset_count|pluralize }}{% if asset_count >= 200 %} (first 200){% endif %}
53+
</div>
54+
</form>
55+
</div>
56+
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>
119+
{% else %}
120+
<div class="empty-state">
121+
<div class="empty-icon">&#128193;</div>
122+
<p>No assets match your filters.</p>
123+
</div>
124+
{% endif %}
125+
{% endblock %}
126+
127+
{% endwith %}

apps/sponsors/templates/sponsors/manage/guide.html

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -339,7 +339,6 @@ <h3 style="font-size:16px;font-weight:700;color:#e74c3c;border-bottom:2px solid
339339
<li><strong>Benefit conflicts</strong> &mdash; configuring which benefits are mutually exclusive (M2M relationship in the admin)</li>
340340
<li><strong>Sponsorship program management</strong> &mdash; adding or editing programs (Foundation, PyCon, PyPI, etc.)</li>
341341
<li><strong>Advanced import/export</strong> &mdash; django-import-export for bulk data operations</li>
342-
<li><strong>Detailed asset filtering</strong> &mdash; filtering and administering individual uploaded assets across all sponsors</li>
343342
</ul>
344343

345344
<div class="manage-alert manage-alert-info">

0 commit comments

Comments
 (0)