Repository: carpentries/amy

Issue:
Get attendance count from list of learners
See https://amy.software-carpentry.org/workshops/event/2015-08-27-ualberta for example.  List of learners was imported into AMY but attendance count is still null.

Can we have something where attendance = null by default, can be updated by direct user input OR count(learners), whichever is greater.

(Also I've been interpreting attendance to be only learners, not helpers/instructors/etc)



Discussion:
Hi Maneesha,
1. You're correct, attendance should be just learners, not instructors 
   and helpers as well.  Sometimes the total reported for a workshop will 
   differ from the headcount in the attendance list because people choose 
   not to be listed (e.g., we get a total of 30 from the organizer, and 
   only 10 names are on the sign-up sheet).
2. How about adding to the problem report page so that if attendance == 
   0 _and_ there are people listed as learners, it's flagged?

Thanks,
Greg

What would make more sense to me is something like (pseudocode):

```
attendance = null      # to start
attendance = user_input    #at any time
if attendance < count(learners): attendance = count(learners)
```

So attendance starts off null.  It can take user input at any time.  If the count of learners is ever greater than what's in that field, that field is updated to count of learners.  This condition is to be evaluated only when new learners are added.  The value for attendance should never be less than the count(learners).

So if I got 10 names, attendance should automatically be 10.  If I then find out 30 people actually attended, i can manually update that to 30.

> So if I got 10 names, attendance should automatically be 10. If I then find out 30 people actually attended, i can manually update that to 30.

Sounds reasonable to me. Let me look for how and where to implement this.


Resolving patch:
diff --git a/workshops/migrations/0051_auto_20150929_0847.py b/workshops/migrations/0051_auto_20150929_0847.py
new file mode 100644
--- /dev/null
+++ b/workshops/migrations/0051_auto_20150929_0847.py
@@ -0,0 +1,33 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import models, migrations
+from django.db.models import Q
+
+
+def update_event_attendance_from_tasks(model, event):
+    """Increase event.attendance if there's more learner tasks belonging to the
+    event."""
+    learners = event.task_set.filter(role__name='learner').count()
+    model.objects \
+        .filter(pk=event.pk) \
+        .filter(Q(attendance__lt=learners) | Q(attendance__isnull=True)) \
+        .update(attendance=learners)
+
+
+def update_attendance_for_historical_events(apps, schema_editor):
+    """Count attendance for events in the database."""
+    Event = apps.get_model('workshops', 'Event')
+    for event in Event.objects.all():
+        update_event_attendance_from_tasks(Event, event)
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('workshops', '0050_merge'),
+    ]
+
+    operations = [
+        migrations.RunPython(update_attendance_for_historical_events),
+    ]
diff --git a/workshops/util.py b/workshops/util.py
--- a/workshops/util.py
+++ b/workshops/util.py
@@ -5,6 +5,7 @@
 
 from django.core.exceptions import ObjectDoesNotExist
 from django.db import IntegrityError, transaction
+from django.db.models import Q
 from django.core.paginator import Paginator as DjangoPaginator
 import requests
 
@@ -192,6 +193,8 @@ def create_uploaded_persons_tasks(data):
 
     persons_created = []
     tasks_created = []
+    events = set()
+
     with transaction.atomic():
         for row in data:
             try:
@@ -216,6 +219,12 @@ def create_uploaded_persons_tasks(data):
                 if row['event'] and row['role']:
                     e = Event.objects.get(slug=row['event'])
                     r = Role.objects.get(name=row['role'])
+
+                    # is the number of learners attending the event changed,
+                    # we should update ``event.attendance``
+                    if row['role'] == 'learner':
+                        events.add(e)
+
                     t, created = Task.objects.get_or_create(person=p, event=e,
                                                             role=r)
                     if created:
@@ -227,6 +236,11 @@ def create_uploaded_persons_tasks(data):
             except ObjectDoesNotExist as e:
                 raise ObjectDoesNotExist('{0} (for {1})'.format(str(e), row))
 
+    for event in events:
+        # if event.attendance is lower than number of learners, then
+        # update the attendance
+        update_event_attendance_from_tasks(event)
+
     return persons_created, tasks_created
 
 
@@ -428,3 +442,13 @@ def parse_tags_from_event_index(orig_url):
         'latitude': latitude,
         'longitude': longitude,
     }
+
+
+def update_event_attendance_from_tasks(event):
+    """Increase event.attendance if there's more learner tasks belonging to the
+    event."""
+    learners = event.task_set.filter(role__name='learner').count()
+    Event.objects \
+        .filter(pk=event.pk) \
+        .filter(Q(attendance__lt=learners) | Q(attendance__isnull=True)) \
+        .update(attendance=learners)
diff --git a/workshops/views.py b/workshops/views.py
--- a/workshops/views.py
+++ b/workshops/views.py
@@ -58,7 +58,8 @@
 from workshops.util import (
     upload_person_task_csv,  verify_upload_person_task,
     create_uploaded_persons_tasks, InternalError, Paginator, merge_persons,
-    normalize_event_index_url, WrongEventURL, parse_tags_from_event_index
+    normalize_event_index_url, WrongEventURL, parse_tags_from_event_index,
+    update_event_attendance_from_tasks
 )
 
 from workshops.filters import (
@@ -853,6 +854,10 @@ def event_edit(request, event_ident):
                     ),
                 )
 
+                # if event.attendance is lower than number of learners, then
+                # update the attendance
+                update_event_attendance_from_tasks(event)
+
                 # to reset the form values
                 return redirect(request.path)
 
@@ -872,12 +877,15 @@ def event_edit(request, event_ident):
                     ),
                 )
 
+                # if event.attendance is lower than number of learners, then
+                # update the attendance
+                update_event_attendance_from_tasks(event)
+
                 return redirect(event)
 
             else:
                 messages.error(request, 'Fix errors below.')
 
-
     context = {'title': 'Edit Event {0}'.format(event.get_ident()),
                'event_form': event_form,
                'object': event,


Test patch:
diff --git a/workshops/test/test_event.py b/workshops/test/test_event.py
--- a/workshops/test/test_event.py
+++ b/workshops/test/test_event.py
@@ -213,6 +213,9 @@ class TestEventViews(TestBase):
 
     def setUp(self):
         self._setUpUsersAndLogin()
+        self._setUpNonInstructors()
+
+        self.learner = Role.objects.get_or_create(name='learner')[0]
 
         # Create a test host
         self.test_host = Host.objects.create(domain='example.com',
@@ -471,6 +474,25 @@ def test_negative_admin_fee_attendance(self):
         response = self.client.post(reverse('event_add'), data)
         assert response.status_code == 302
 
+    def test_number_of_attendees_increasing(self):
+        """Ensure event.attendance gets bigger after adding new learners."""
+        event = Event.objects.get(slug='test_event_0')
+        event.attendance = 0  # testing for numeric case
+        event.save()
+
+        url, values = self._get_initial_form_index(1, 'event_edit', event.pk)
+        values['task-role'] = self.learner.pk
+        values['task-event'] = event.pk
+        values['task-person_0'] = str(self.spiderman)
+        values['task-person_1'] = self.spiderman.pk
+
+        rv = self.client.post(reverse('event_edit', args=[event.pk]), values,
+                              follow=True)
+        self._check_status_code_and_parse(rv, 200)
+
+        event.refresh_from_db()
+        assert event.attendance == 1
+
 
 class TestEventNotes(TestBase):
     """Make sure notes once written are saved forever!"""
diff --git a/workshops/test/test_util.py b/workshops/test/test_util.py
--- a/workshops/test/test_util.py
+++ b/workshops/test/test_util.py
@@ -98,6 +98,7 @@ def setUp(self):
                                         fullname='Test Host')
 
         Role.objects.create(name='Instructor')
+        Role.objects.create(name='learner')
         Event.objects.create(start=datetime.now(),
                              host=test_host,
                              slug='foobar',
@@ -329,6 +330,42 @@ def test_upload_existing_user_existing_task(self):
         self.assertEqual(users_pre, users_post)
         self.assertEqual(rv.status_code, 200)
 
+    def test_attendance_increases(self):
+        """
+        Check if uploading tasks with role "learner" increase event's
+        attendance.
+        """
+        foobar = Event.objects.get(slug="foobar")
+        assert foobar.attendance is None
+        foobar.save()
+
+        csv = """personal,middle,family,email,event,role
+Harry,,Potter,harry@hogwarts.edu,foobar,learner
+"""
+        data, _ = upload_person_task_csv(StringIO(csv))
+
+        # self.client is authenticated user so we have access to the session
+        store = self.client.session
+        store['bulk-add-people'] = data
+        store.save()
+
+        # send exactly what's in 'data'
+        payload = {
+            "personal": data[0]['personal'],
+            "middle": data[0]['middle'],
+            "family": data[0]['family'],
+            "email": data[0]['email'],
+            "event": data[0]['event'],
+            "role": data[0]['role'],
+            "confirm": "Confirm",
+        }
+
+        self.client.post(reverse('person_bulk_add_confirmation'), payload,
+                         follow=True)
+
+        foobar.refresh_from_db()
+        self.assertEqual(1, foobar.attendance)
+
 
 class TestEventURLNormalization(TestCase):
     def setUp(self):