Skip to content

Sponsor Management UI#2972

Draft
JacobCoffee wants to merge 19 commits intomainfrom
sponsor-mgmt-ui
Draft

Sponsor Management UI#2972
JacobCoffee wants to merge 19 commits intomainfrom
sponsor-mgmt-ui

Conversation

@JacobCoffee
Copy link
Member

No description provided.

Copilot AI review requested due to automatic review settings March 22, 2026 20:37
Comment on lines +28 to +29
MIN_YEAR = 2022
MAX_YEAR = 2050
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no

count++;
});
if (!count) html = '<div style="padding:10px 12px;font-size:13px;color:#999;">No matching benefits</div>';
dropdown.innerHTML = html;

Check failure

Code scanning / CodeQL

DOM text reinterpreted as HTML High

DOM text
is reinterpreted as HTML without escaping meta-characters.

Copilot Autofix

AI about 17 hours ago

In general, to fix this kind of issue you should stop building HTML by string concatenation with potentially tainted data and avoid assigning those strings to innerHTML. Instead, construct DOM elements with document.createElement, set attributes via setAttribute / dataset / property assignments, and set visible text with textContent. These APIs do not interpret the data as HTML, so meta‑characters are not treated as markup and cannot break out into scripts.

For this specific snippet in apps/sponsors/templates/sponsors/manage/sponsorship_detail.html, the safest approach is to:

  • Remove the html string accumulation and the dropdown.innerHTML = html; assignment.
  • In renderDropdown, clear the existing children of dropdown and then:
    • If there are no matches, create a single <div> node, set its textContent to "No matching benefits", and append it.
    • Otherwise, for each matching opt:
      • Create a <div> element.
      • Add the bs-opt class (and bs-sel when highlighted).
      • Set data-val using el.dataset.val = opt.value;.
      • Apply the inline styles via style properties or setAttribute.
      • Set el.textContent = opt.text; so the label text is inserted without HTML interpretation.
      • Append the element to dropdown.
  • Leave the event listeners as they are, but bind them to the newly created elements.

This preserves all existing behavior (styling, selection, event handling, filtering) while eliminating the dangerous re-interpretation of DOM text as HTML.

Suggested changeset 1
apps/sponsors/templates/sponsors/manage/sponsorship_detail.html

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/apps/sponsors/templates/sponsors/manage/sponsorship_detail.html b/apps/sponsors/templates/sponsors/manage/sponsorship_detail.html
--- a/apps/sponsors/templates/sponsors/manage/sponsorship_detail.html
+++ b/apps/sponsors/templates/sponsors/manage/sponsorship_detail.html
@@ -459,20 +459,47 @@
             }
 
             function renderDropdown(filter) {
-                var html = '';
                 var q = (filter || '').toLowerCase();
                 var count = 0;
+
+                // Clear existing dropdown content
+                while (dropdown.firstChild) {
+                    dropdown.removeChild(dropdown.firstChild);
+                }
+
                 options.forEach(function(opt) {
                     if (q && opt.text.toLowerCase().indexOf(q) === -1) return;
                     var highlighted = opt.value === select.value;
-                    html += '<div class="bs-opt' + (highlighted ? ' bs-sel' : '') + '" data-val="' + opt.value + '"'
-                         + ' style="padding:7px 12px;font-size:13px;cursor:pointer;border-bottom:1px solid #f5f5f5;'
-                         + (highlighted ? 'background:#e8f0fe;font-weight:600;' : '')
-                         + '">' + opt.text + '</div>';
+
+                    var item = document.createElement('div');
+                    item.classList.add('bs-opt');
+                    if (highlighted) {
+                        item.classList.add('bs-sel');
+                    }
+                    item.dataset.val = opt.value;
+                    item.style.padding = '7px 12px';
+                    item.style.fontSize = '13px';
+                    item.style.cursor = 'pointer';
+                    item.style.borderBottom = '1px solid #f5f5f5';
+                    if (highlighted) {
+                        item.style.background = '#e8f0fe';
+                        item.style.fontWeight = '600';
+                    }
+                    item.textContent = opt.text;
+
+                    dropdown.appendChild(item);
                     count++;
                 });
-                if (!count) html = '<div style="padding:10px 12px;font-size:13px;color:#999;">No matching benefits</div>';
-                dropdown.innerHTML = html;
+
+                if (!count) {
+                    var empty = document.createElement('div');
+                    empty.style.padding = '10px 12px';
+                    empty.style.fontSize = '13px';
+                    empty.style.color = '#999';
+                    empty.textContent = 'No matching benefits';
+                    dropdown.appendChild(empty);
+                }
+
                 dropdown.style.display = 'block';
 
                 dropdown.querySelectorAll('.bs-opt').forEach(function(el) {
EOF
@@ -459,20 +459,47 @@
}

function renderDropdown(filter) {
var html = '';
var q = (filter || '').toLowerCase();
var count = 0;

// Clear existing dropdown content
while (dropdown.firstChild) {
dropdown.removeChild(dropdown.firstChild);
}

options.forEach(function(opt) {
if (q && opt.text.toLowerCase().indexOf(q) === -1) return;
var highlighted = opt.value === select.value;
html += '<div class="bs-opt' + (highlighted ? ' bs-sel' : '') + '" data-val="' + opt.value + '"'
+ ' style="padding:7px 12px;font-size:13px;cursor:pointer;border-bottom:1px solid #f5f5f5;'
+ (highlighted ? 'background:#e8f0fe;font-weight:600;' : '')
+ '">' + opt.text + '</div>';

var item = document.createElement('div');
item.classList.add('bs-opt');
if (highlighted) {
item.classList.add('bs-sel');
}
item.dataset.val = opt.value;
item.style.padding = '7px 12px';
item.style.fontSize = '13px';
item.style.cursor = 'pointer';
item.style.borderBottom = '1px solid #f5f5f5';
if (highlighted) {
item.style.background = '#e8f0fe';
item.style.fontWeight = '600';
}
item.textContent = opt.text;

dropdown.appendChild(item);
count++;
});
if (!count) html = '<div style="padding:10px 12px;font-size:13px;color:#999;">No matching benefits</div>';
dropdown.innerHTML = html;

if (!count) {
var empty = document.createElement('div');
empty.style.padding = '10px 12px';
empty.style.fontSize = '13px';
empty.style.color = '#999';
empty.textContent = 'No matching benefits';
dropdown.appendChild(empty);
}

dropdown.style.display = 'block';

dropdown.querySelectorAll('.bs-opt').forEach(function(el) {
Copilot is powered by AI and may make mistakes. Always verify output.
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a staff-facing Sponsor Management UI under the sponsors app, plus supporting infrastructure for email testing and outbound-notification auditing/logging.

Changes:

  • Add a new /sponsors/manage/ management UI (templates, forms, URLs) for handling sponsorships, packages, benefits, contacts, contracts, and notifications.
  • Persist outbound sponsorship notification sends to a new SponsorshipNotificationLog model and display history in the management UI.
  • Improve local dev ergonomics by adding MailDev + SMTP-backed local email configuration; update sponsor contract email templates; bump django-storages.

Reviewed changes

Copilot reviewed 43 out of 44 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
uv.lock Bumps django-storages from 1.14.4 to 1.14.6 (lockfile update).
pydotorg/settings/local.py Uses SMTP backend when EMAIL_HOST is set (for MailDev), otherwise console backend.
docker-compose.yml Adds a maildev service and wires EMAIL_HOST/EMAIL_PORT into the web service.
apps/sponsors/use_cases.py Logs each sent sponsorship notification to SponsorshipNotificationLog.
apps/sponsors/urls.py Mounts the management UI under sponsors/manage/.
apps/sponsors/templates/sponsors/manage/sponsorship_notify.html UI to preview/send a notification for a single sponsorship.
apps/sponsors/templates/sponsors/manage/sponsorship_list.html Sponsorship list UI with filters, bulk actions, and live search.
apps/sponsors/templates/sponsors/manage/sponsorship_edit.html Edit sponsorship details (package/fee/year).
apps/sponsors/templates/sponsors/manage/sponsorship_detail.html Sponsorship detail/review UI incl. contracts, benefits, assets, communications.
apps/sponsors/templates/sponsors/manage/sponsorship_approve_signed.html Approve sponsorship while uploading a signed contract document.
apps/sponsors/templates/sponsors/manage/sponsorship_approve.html Approve sponsorship (creates draft contract) with quick date helpers.
apps/sponsors/templates/sponsors/manage/sponsor_edit.html Create/edit sponsor company data.
apps/sponsors/templates/sponsors/manage/package_list.html Package list UI grouped by year.
apps/sponsors/templates/sponsors/manage/package_form.html Create/edit a sponsorship package.
apps/sponsors/templates/sponsors/manage/package_confirm_delete.html Confirm-delete UI for packages.
apps/sponsors/templates/sponsors/manage/notification_template_list.html List notification templates in the management UI.
apps/sponsors/templates/sponsors/manage/notification_template_form.html Create/edit notification templates with variable copy helpers.
apps/sponsors/templates/sponsors/manage/notification_template_confirm_delete.html Confirm-delete UI for notification templates.
apps/sponsors/templates/sponsors/manage/notification_history.html Management UI listing sent notifications (log history).
apps/sponsors/templates/sponsors/manage/guide.html End-user guide for PSF sponsorship team workflows.
apps/sponsors/templates/sponsors/manage/dashboard.html Management dashboard summary for a selected year.
apps/sponsors/templates/sponsors/manage/current_year_form.html UI to set the active sponsorship year.
apps/sponsors/templates/sponsors/manage/contract_send.html UI for generating/sending contracts and internal review.
apps/sponsors/templates/sponsors/manage/contract_execute.html UI to upload signed contract and execute/finalize sponsorship.
apps/sponsors/templates/sponsors/manage/contact_form.html Create/edit sponsor contact roles and info.
apps/sponsors/templates/sponsors/manage/clone_year.html Clone packages/benefits from one year to another.
apps/sponsors/templates/sponsors/manage/bulk_notify.html UI to send notifications in bulk across sponsorships.
apps/sponsors/templates/sponsors/manage/benefit_list.html Benefit list UI with filtering/pagination.
apps/sponsors/templates/sponsors/manage/benefit_form.html Create/edit benefit + manage feature configurations and related data.
apps/sponsors/templates/sponsors/manage/benefit_confirm_delete.html Confirm-delete UI for benefits.
apps/sponsors/templates/sponsors/manage/benefit_config_form.html Create/edit benefit feature configuration objects.
apps/sponsors/templates/sponsors/manage/_base.html Management UI shell + global JS table-sorting helper.
apps/sponsors/templates/sponsors/email/sponsor_contract_subject.txt Replaces placeholder subject with a real contract subject template.
apps/sponsors/templates/sponsors/email/sponsor_contract.txt Replaces placeholder contract email body with real copy.
apps/sponsors/models/notifications.py Adds SponsorshipNotificationLog model to persist sent notifications.
apps/sponsors/models/__init__.py Re-exports SponsorshipNotificationLog.
apps/sponsors/migrations/0104_add_notification_log.py Migration creating the notification log table.
apps/sponsors/management/commands/seed_sponsor_manage_data.py Dev-only command to seed realistic data for the management UI.
apps/sponsors/manage/urls.py URL routing for the management UI.
apps/sponsors/manage/forms.py Forms backing the management UI (benefits/packages/sponsorship ops/notifications/etc.).
apps/sponsors/manage/__init__.py Documents that the manage UI is staff/group restricted.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.


signed_document = forms.FileField(
label="Signed contract document",
help_text="Upload the signed PDF.",
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The help text says "Upload the signed PDF" but the file input accepts both .pdf and .docx. Align the help text and accepted file types (either restrict to PDF or update the copy/validation to explicitly support DOCX).

Suggested change
help_text="Upload the signed PDF.",
help_text="Upload the signed contract as a PDF or DOCX file.",

Copilot uses AI. Check for mistakes.
Comment on lines +724 to +726
var tbody = table.querySelector('tbody');
var rows = Array.prototype.slice.call(tbody.querySelectorAll('tr'));
var ascending = th.dataset.sortDir !== 'asc';
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The global table-sorting script sorts every <tr> in each .manage-table tbody. Some tables in this UI use paired "summary row + hidden detail row" patterns (e.g., communications/history), and sorting will break the pairing / toggling behavior. Consider opting certain tables out (e.g., a data-no-sort flag), or adjust the sorter to keep row pairs together / ignore detail rows (like those with colspan).

Copilot uses AI. Check for mistakes.
Comment on lines +199 to +207
# Persist notification log
SponsorshipNotificationLog.objects.create(
sponsorship=sponsorship,
subject=email.subject,
content=email.body,
recipients=", ".join(email.to),
contact_types=", ".join(contact_types),
sent_by=request.user if request and hasattr(request, "user") else None,
)
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This use case now persists SponsorshipNotificationLog entries after sending. There are existing unit tests for SendSponsorshipNotificationUseCase, but none assert that a log row is created (or that recipients/contact_types/sent_by are stored correctly). Add coverage to prevent regressions (e.g., ensure one log per sent email, and verify sent_by behavior when no authenticated user is available).

Copilot uses AI. Check for mistakes.
Comment on lines +204 to +207
emptyRow.innerHTML = '<td colspan="9" style="text-align:center;padding:20px;color:#999;">No sponsorships match &ldquo;' + q.replace(/</g, '&lt;') + '&rdquo;</td>';
rows[0].parentNode.appendChild(emptyRow);
} else {
emptyRow.innerHTML = '<td colspan="9" style="text-align:center;padding:20px;color:#999;">No sponsorships match &ldquo;' + q.replace(/</g, '&lt;') + '&rdquo;</td>';
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The empty-state row is built via innerHTML using raw user input q. Escaping only < is not sufficient (e.g., input like &lt;img src=x onerror=...&gt; contains no literal < but will be decoded by innerHTML and can execute). Build the message using DOM APIs/text nodes (e.g., set textContent) or perform full HTML escaping (&, <, >, quotes) before inserting.

Copilot uses AI. Check for mistakes.
Comment on lines +461 to +475
function renderDropdown(filter) {
var html = '';
var q = (filter || '').toLowerCase();
var count = 0;
options.forEach(function(opt) {
if (q && opt.text.toLowerCase().indexOf(q) === -1) return;
var highlighted = opt.value === select.value;
html += '<div class="bs-opt' + (highlighted ? ' bs-sel' : '') + '" data-val="' + opt.value + '"'
+ ' style="padding:7px 12px;font-size:13px;cursor:pointer;border-bottom:1px solid #f5f5f5;'
+ (highlighted ? 'background:#e8f0fe;font-weight:600;' : '')
+ '">' + opt.text + '</div>';
count++;
});
if (!count) html = '<div style="padding:10px 12px;font-size:13px;color:#999;">No matching benefits</div>';
dropdown.innerHTML = html;
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

renderDropdown() builds html using opt.text and assigns it via dropdown.innerHTML. Since select.options[i].text is already HTML-decoded, a benefit name containing entities like &lt;img ...&gt; will become real markup when inserted and can lead to XSS. Build the dropdown items with DOM APIs and set textContent, or robustly escape opt.text before concatenating into HTML.

Copilot uses AI. Check for mistakes.
Comment on lines +182 to +183
<span class="tag {% if contract.status == 'draft' %}tag-gray{% elif contract.status == 'awaiting_signature' %}tag-gold{% elif contract.status == 'executed' %}tag-green{% else %}tag-red{% endif %}">
{{ contract.get_status_display }}
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Contract.status values use strings like "awaiting signature" (with a space). This template checks for awaiting_signature (underscore), so awaiting-signature contracts will be styled as the fallback (red) status. Use the correct status value or (preferably) check a boolean property like contract.awaiting_signature / contract.is_draft to avoid string mismatches.

Copilot uses AI. Check for mistakes.
Comment on lines +204 to +207
recipients=", ".join(email.to),
contact_types=", ".join(contact_types),
sent_by=request.user if request and hasattr(request, "user") else None,
)
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sent_by is set to request.user when a request is present, but hasattr(request, 'user') isn't sufficient: request.user can be AnonymousUser (or other non-User objects), which will raise when assigned to the FK. Guard with request.user.is_authenticated (and possibly ensure it's an instance of the configured User model) before assigning; otherwise store None.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants