Skip to content

Commit dbd2547

Browse files
Add route to export requests (#310)
* Add route to export requests * Fix view and broken decorator * Change URL * Add admin-only frontend UI * Add tests * Add admin user in local data population * Make linter happy
1 parent 7bbd363 commit dbd2547

File tree

5 files changed

+206
-6
lines changed

5 files changed

+206
-6
lines changed

app/tests.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
from __future__ import unicode_literals
33

44
import json
5+
import csv
6+
import datetime
57

68
from django.test import TestCase
79
from unittest import skip
@@ -345,3 +347,100 @@ def test_get_or_none_exists(self):
345347

346348
def test_get_or_none_does_not_exist(self):
347349
self.assertEqual(None, helpers.get_or_none(Event, pk=2))
350+
351+
352+
class TestExportRequests(TestCase):
353+
def setUp(self):
354+
super().setUp()
355+
self.admin_user = User.objects.create_user(username='admin', email='admin@upenn.edu', password='adminpassword', is_staff=True)
356+
self.requester = User.objects.create_user(username='requester', email='requester@upenn.edu', password='requesterpassword')
357+
self.requester_profile = self.requester.profile
358+
self.requester_profile.user_type = 'R'
359+
self.requester_profile.save()
360+
self.funder = create_funder()
361+
362+
self.event1 = Event.objects.create(
363+
name="Test Event 1", date="2023-01-01", time="15:30:00", location="Houston Hall",
364+
requester=self.requester_profile, contact_name="Test Contact", contact_email="contact@upenn.edu",
365+
contact_phone="123-456-7890", anticipated_attendance=100, advisor_email="advisor@upenn.edu",
366+
advisor_phone="098-765-4321", organizations="Test Organization", funding_already_received=50.00, status="B"
367+
)
368+
self.event1.applied_funders.add(self.funder.profile)
369+
370+
self.event2 = Event.objects.create(
371+
name="Test Event 2", date="2023-02-01", time="16:30:00", location="College Hall",
372+
requester=self.requester_profile, contact_name="Test Contact 2", contact_email="contact2@upenn.edu",
373+
contact_phone="123-456-7891", anticipated_attendance=200, advisor_email="advisor2@upenn.edu",
374+
advisor_phone="098-765-4322", organizations="Test Organization 2", funding_already_received=100.00, status="F"
375+
)
376+
self.event2.applied_funders.add(self.funder.profile)
377+
378+
self.event3 = Event.objects.create(
379+
name="Test Event 3", date="2023-03-01", time="17:30:00", location="Van Pelt Library",
380+
requester=self.requester_profile, contact_name="Test Contact 3", contact_email="contact3@upenn.edu",
381+
contact_phone="123-456-7892", anticipated_attendance=300, advisor_email="advisor3@upenn.edu",
382+
advisor_phone="098-765-4323", organizations="Test Organization 3", funding_already_received=150.00, status="S"
383+
)
384+
385+
self.item1 = self.event1.item_set.create(name="Item 1", quantity=10, price_per_unit=20.00, funding_already_received=0.00, category="H", revenue=False)
386+
self.item2 = self.event1.item_set.create(name="Item 2", quantity=5, price_per_unit=30.00, funding_already_received=25.00, category="F", revenue=False)
387+
self.item3 = self.event2.item_set.create(name="Item 3", quantity=15, price_per_unit=40.00, funding_already_received=50.00, category="E", revenue=False)
388+
self.item4 = self.event2.item_set.create(name="Item 4 (Revenue)", quantity=20, price_per_unit=10.00, funding_already_received=0.00, category="O", revenue=True)
389+
390+
self.grant1 = Grant.objects.create(funder=self.funder.profile, item=self.item1, amount=100.00)
391+
self.grant2 = Grant.objects.create(funder=self.funder.profile, item=self.item3, amount=200.00)
392+
393+
def test_export_requests_access(self):
394+
self.client.login(username='requester', password='requesterpassword')
395+
self.assertEqual(self.client.get('/export-requests/').status_code, 302)
396+
397+
self.client.login(username='admin', password='adminpassword')
398+
resp = self.client.get('/export-requests/')
399+
self.assertEqual(resp.status_code, 200)
400+
self.assertEqual(resp['Content-Type'], 'text/csv')
401+
self.assertEqual(resp['Content-Disposition'], 'attachment; filename="funding_requests.csv"')
402+
403+
def test_export_requests_content(self):
404+
self.client.login(username='admin', password='adminpassword')
405+
resp = self.client.get('/export-requests/')
406+
content = resp.content.decode('utf-8')
407+
rows = list(csv.reader(content.strip().split('\n')))
408+
409+
self.assertEqual(len(rows[0]), 25)
410+
self.assertEqual(rows[0][0], 'Event ID')
411+
self.assertEqual(len(rows), 2)
412+
413+
event_row = rows[1]
414+
self.assertEqual(event_row[1], 'Test Event 1')
415+
self.assertEqual(event_row[4], 'Houston Hall')
416+
self.assertEqual(event_row[5], str(self.requester_profile))
417+
self.assertEqual(event_row[6], 'requester@upenn.edu')
418+
self.assertEqual(event_row[14], '50.00')
419+
self.assertEqual(event_row[15], 'SUBMITTED')
420+
421+
self.assertEqual(float(event_row[18]), 75.00)
422+
self.assertEqual(float(event_row[19]), 100.00)
423+
self.assertEqual(float(event_row[20]), 175.00)
424+
self.assertEqual(float(event_row[21]), 350.00)
425+
self.assertEqual(float(event_row[22]), 0.00)
426+
self.assertEqual(float(event_row[23]), 175.00)
427+
self.assertIn(str(self.funder.profile), event_row[24])
428+
429+
def test_export_requests_date_filter(self):
430+
old_date = datetime.datetime.now() - datetime.timedelta(days=731)
431+
Event.objects.create(
432+
name="Old Test Event", date="2020-01-01", time="12:30:00", location="Old Location",
433+
requester=self.requester_profile, contact_name="Old Contact", contact_email="old@upenn.edu",
434+
contact_phone="111-222-3333", anticipated_attendance=50, advisor_email="oldadvisor@upenn.edu",
435+
advisor_phone="444-555-6666", organizations="Old Organization", funding_already_received=25.00,
436+
status="B", created_at=old_date, updated_at=old_date
437+
)
438+
439+
self.client.login(username='admin', password='adminpassword')
440+
resp = self.client.get('/export-requests/')
441+
rows = list(csv.reader(resp.content.decode('utf-8').strip().split('\n')))
442+
443+
self.assertEqual(len(rows), 2)
444+
event_names = [row[1] for row in rows[1:]]
445+
self.assertIn('Test Event 1', event_names)
446+
self.assertNotIn('Old Test Event', event_names)

app/urls.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,5 @@
1111
re_path(r'^(\d+)/destroy/$', views.event_destroy, name='event-destroy'),
1212
# re_path(r'^(\d+)/download/$', views.event_download, name='event-download'),
1313
re_path(r'^funders/(\d+)/edit/$', views.funder_edit, name='funder-edit'),
14+
re_path(r'^export-requests/$', views.export_requests, name='export-requests'),
1415
]

app/views.py

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@
1818
from django.core.exceptions import ValidationError
1919
from django.core.validators import validate_email
2020

21+
import io
22+
import csv
23+
2124
from .models import (
2225
Event,
2326
Grant,
@@ -66,7 +69,7 @@ def protected_view(request, event_id, *args, **kwargs):
6669
return view(request, event_id, *args, **kwargs)
6770
else:
6871
return redirect(EVENTS_HOME)
69-
72+
7073
return protected_view
7174

7275

@@ -83,6 +86,16 @@ def protected_view(request, event_id, *args, **kwargs):
8386

8487
return protected_view
8588

89+
def admin_only(view):
90+
"""Ensure only admins can access a page."""
91+
92+
def protected_view(request, *args, **kwargs):
93+
if request.user.is_staff:
94+
return view(request, *args, **kwargs)
95+
else:
96+
return redirect(EVENTS_HOME)
97+
98+
return protected_view
8699

87100
def save_from_form(event, POST):
88101
"""Save an event from form data."""
@@ -463,3 +476,84 @@ def funder_edit(request, user_id):
463476
)
464477
else:
465478
return HttpResponseNotAllowed(["GET"])
479+
480+
@admin_only
481+
@require_http_methods(["GET"])
482+
def export_requests(request):
483+
"""
484+
Export funding requests submitted in the last 2 years to a CSV file.
485+
"""
486+
# Query the last two years of submitted funding requests
487+
cutoff_date = datetime.datetime.now() - datetime.timedelta(days=730)
488+
qs = (
489+
Event.objects.filter(created_at__gte=cutoff_date, status="B")
490+
.select_related("requester", "requester__user")
491+
.prefetch_related("applied_funders", "item_set", "item_set__grant_set")
492+
)
493+
494+
output = io.StringIO()
495+
writer = csv.writer(output)
496+
497+
writer.writerow([
498+
'Event ID', 'Event Name', 'Event Date', 'Event Time', 'Location',
499+
'Requester', 'Requester Email',
500+
'Contact Name', 'Contact Email', 'Contact Phone', 'Anticipated Attendance',
501+
'Advisor Email', 'Advisor Phone', 'Organizations',
502+
'Funding Already Received', 'Status', 'Created At', 'Updated At',
503+
'Total Funds Already Received', 'Total Funds Granted', 'Total Funds Received',
504+
'Total Expense', 'Total Additional Funds', 'Total Remaining',
505+
'Applied Funders'
506+
])
507+
508+
for event in qs:
509+
total_funds_already_received = event.funding_already_received
510+
for item in event.item_set.all():
511+
total_funds_already_received += item.funding_already_received
512+
513+
total_funds_granted = sum(
514+
sum(grant.amount for grant in item.grant_set.all() if grant.amount is not None)
515+
for item in event.item_set.all()
516+
)
517+
total_funds_received = total_funds_already_received + total_funds_granted
518+
519+
total_expense = sum(
520+
item.price_per_unit * item.quantity for item in event.item_set.all() if not item.revenue
521+
)
522+
total_additional_funds = sum(
523+
item.price_per_unit * item.quantity for item in event.item_set.all() if item.revenue
524+
)
525+
total_remaining = total_expense - total_funds_received - total_additional_funds
526+
527+
applied_funders = ", ".join([str(f) for f in event.applied_funders.all()])
528+
529+
writer.writerow([
530+
event.id,
531+
event.name,
532+
event.date,
533+
event.time,
534+
event.location,
535+
str(event.requester),
536+
event.requester.user.email,
537+
event.contact_name,
538+
event.contact_email,
539+
event.contact_phone,
540+
event.anticipated_attendance,
541+
event.advisor_email,
542+
event.advisor_phone,
543+
event.organizations,
544+
event.funding_already_received,
545+
event.get_status_display(),
546+
event.created_at,
547+
event.updated_at,
548+
total_funds_already_received,
549+
total_funds_granted,
550+
total_funds_received,
551+
total_expense,
552+
total_additional_funds,
553+
total_remaining,
554+
applied_funders,
555+
])
556+
557+
response = HttpResponse(output.getvalue(), content_type='text/csv')
558+
response['Content-Disposition'] = 'attachment; filename="funding_requests.csv"'
559+
return response

import_demo.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,14 @@ def import_users():
206206
User.objects.filter(is_staff=False).delete()
207207
FreeResponseQuestion.objects.all().delete()
208208

209+
# Create admin user
210+
User.objects.create_user(
211+
username='bfranklin',
212+
password='test',
213+
email=TEST_EMAIL,
214+
is_staff=True
215+
)
216+
209217
# Import funders
210218
for funder in FUNDERS:
211219
add_funder(**funder)

penncfa/templates/base-page.html

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,13 @@
1212
</button>
1313
<div class="collapse navbar-collapse" id="navbarNav">
1414
<ul class="navbar-nav ml-auto">
15-
{% if user.is_staff %}
16-
<li class="nav-item mr-1">
17-
<a class="nav-link" href="{% url 'admin:index' %}">Admin</a>
18-
</li>
19-
{% endif %}
2015
{% if user.is_authenticated %}
2116
<li class="nav-item dropdown mr-1">
2217
<a class="nav-link dropdown-toggle" href="#" data-toggle="dropdown">Settings</a>
2318
<div class="dropdown-menu dropdown-menu-right">
19+
{% if user.is_staff %}
20+
<a class="dropdown-item" href="{% url 'export-requests' %}"><i class="fa fa-download fa-fw"></i> Export Data</a>
21+
{% endif %}
2422
<a class="dropdown-item" href="{% url 'auth_password_change' %}"><i class="fa fa-key fa-fw"></i> Change Password</a>
2523
</div>
2624
</li>

0 commit comments

Comments
 (0)