{"action":"created","comment":{"url":"https://api.github.com/repos/datamade/large-lots/pulls/comments/209973797","pull_request_review_id":146083440,"id":209973797,"node_id":"MDI0OlB1bGxSZXF1ZXN0UmV2aWV3Q29tbWVudDIwOTk3Mzc5Nw==","diff_hunk":"@@ -1,701 +1,430 @@\n+from datetime import datetime\n+from functools import partialmethod\n+from itertools import chain\n+import json\n+from smtplib import SMTPException\n+import time\n+\n from django.core.management.base import BaseCommand, CommandError\n from django.core.mail import EmailMultiAlternatives\n from django.template.loader import get_template\n from django.conf import settings\n from django.db import connection\n \n-from smtplib import SMTPException\n-import time\n-from datetime import datetime\n+import logging\n+from raven.handlers.logging import SentryHandler\n+from raven.conf import setup_logging\n \n-from lots_admin.models import Application, ApplicationStatus, ApplicationStep, Review, DenialReason, User, Lot\n+from lots_admin.models import Application, ApplicationStatus, ApplicationStep, Review, DenialReason, User\n from lots_admin.look_ups import DENIAL_REASONS\n \n-class Command(BaseCommand):\n-    help = 'Send bulk emails to Large Lots applicants. By default, all applications ' + \\\n-        'associated with an email address will be aggregated, and one email will ' + \\\n-        'be sent. See --separate_emails for handling shared email addresses.'\n-\n-\n-    def add_arguments(self, parser):\n-        parser.add_argument('--eds_denial',\n-                            action='store_true',\n-                            help='Send denial emails to applicants who did not submit EDS')\n-\n-        parser.add_argument('--separate_emails',\n-                            help='Comma-separated list of email addresses. ' + \\\n-                                'In contrast to the default behavior of one aggregate ' + \\\n-                                'email, separate emails will be sent for each ' + \\\n-                                'application associated with the provided email ' + \\\n-                                'addresses. This is useful when one email address ' + \\\n-                                'is in use by more than one applicant.')\n-\n-        parser.add_argument('--closing_invitations',\n-                            help='Send closing invitations to applicants')\n-\n-        parser.add_argument('--date',\n-                            help='Date in format YYYY-MM-DD for emails, etc.')\n \n-        parser.add_argument('--closing_time',\n-                            action='store_true',\n-                            help='Send closing notifications to applicants')\n+logger = logging.getLogger(__name__)\n+handler = SentryHandler(settings.SENTRY_DSN)\n+handler.setLevel(logging.ERROR)\n+setup_logging(handler)\n \n-        parser.add_argument('--city_council_denial',\n-                            action='store_true',\n-                            help='Send denial emails to applicants denied by City Council')\n \n-        parser.add_argument('--eds_final_email',\n-                            action='store_true',\n-                            help='Send email to all applicants on Step 7.')\n+class CannotSendEmailException(Exception):\n+    pass\n \n-        parser.add_argument('--lotto_email',\n-                            help='Send email with notification of lottery. Use one of two arguments: morning or afternoon.')\n \n-        parser.add_argument('--lotto_offset',\n-                            help='Set SQL offset.')\n+class Command(BaseCommand):\n+    help = 'Send bulk emails to Large Lots applicants.'\n \n+    def add_arguments(self, parser):\n+        '''\n+        To send a new email, add a Boolean argument called 'x_email' here, then\n+        write a method on the Command class called 'send_x_email'.\n+        '''\n+        # Meta options\n+        parser.add_argument('-n', '--number-to-send',\n+                            help='Set number of emails to send.',\n+                            type=int,\n+                            default=0)\n+\n+        parser.add_argument('-a', '--admin',\n+                            help='The ID of the admin sending the emails.' +\n+                            'Defaults to Jeanne Chandler.',\n+                            type=int,\n+                            default=5)\n+\n+        parser.add_argument('--base_context',\n+                            help='A string-encoded JSON object of values for' +\n+                            'email template context.',\n+                            default=r'{}')\n+\n+        # Select options\n         parser.add_argument('--eds_email',\n+                            action='store_true',\n                             help='Send email with link to complete EDS')\n \n-        parser.add_argument('--deed_upload',\n+        parser.add_argument('--lotto_email',\n                             action='store_true',\n-                            help='Send emails to applicants who need to resubmit a deed')\n+                            help='Send email with notification of lottery')\n \n-        parser.add_argument('--humboldt_denial',\n+        parser.add_argument('--eds_final_email',\n                             action='store_true',\n-                            help='Send denial emails to applicants in Homboldt Park and Ward 27')\n+                            help='Send email to all applicants on Step 7')\n \n-        parser.add_argument('--garfield_denial',\n+        parser.add_argument('--closing_time_email',\n                             action='store_true',\n-                            help='Send denial emails to applicants in Garfield and Ward 27')\n+                            help='Send closing notifications to applicants')\n \n-        parser.add_argument('--blank_deed_denial',\n+        parser.add_argument('--closing_invitations_email',\n                             action='store_true',\n-                            help='Send denial emails to applicants who did not resubmit blank deeds')\n+                            help='Send closing invitations to applicants')\n \n-        parser.add_argument('--update_email',\n+        parser.add_argument('--eds_denial_email',\n                             action='store_true',\n-                            help='Send emails to insure applicants that \"everything is okay\"')\n+                            help='Send denial emails to applicants who did' +\n+                            'not submit EDS')\n \n-        parser.add_argument('--wintrust_email',\n-                            action='store_true',\n-                            help='Send emails about a special event')\n+        parser.add_argument('--custom_email',\n+                            help='Send a custom email to applicants on or' +\n+                            'not on a given step. Accepted values: on_step,' +\n+                            'not_on_step, on_steps_before, on_steps_after')\n \n+        parser.add_argument('--step',\n+                            help='Required when sending a custom email.' +\n+                            'Step used to select applicants to email.',\n+                            type=int)\n \n     def handle(self, *args, **options):\n-        if options['eds_denial']:\n+        if options['number_to_send']:\n+            self.n = options['number_to_send']\n \n-            if options['separate_emails']:\n-                separate_emails = options['separate_emails'].split(',')\n-            else:\n-                separate_emails = []\n-\n-            # Get email and application IDs where there are active applications\n-            # on step 7.\n-            query = '''\n-                SELECT\n-                  email,\n-                  ARRAY_AGG(DISTINCT app.id)\n-                FROM lots_admin_application AS app\n-                JOIN lots_admin_applicationstatus AS status\n-                ON app.id = status.application_id\n-                JOIN lots_admin_applicationstep AS step\n-                ON status.current_step_id = step.id\n-                WHERE denied = False\n-                AND step = 7\n-                GROUP BY email\n-            '''\n-\n-            with connection.cursor() as cursor:\n-                cursor.execute(query)\n-                applicants = [(email, app_ids) for email, app_ids in cursor]\n-\n-            log_fmt = '{date} {applicant} ({email}) denied due to ' + \\\n-                    'lack of EDS for lots #{pins}'\n-\n-            # Set up denial globals\n-            no_eds, _ = DenialReason.objects.get_or_create(value=DENIAL_REASONS['EDS'])\n-            admin_user = User.objects.get(id=5)\n-\n-            # Define some reusable functionality.\n-\n-            def step_7_lots_list(application_obj):\n-                '''Return list of lots for given applicant for which there\n-                is an active application on step 7.\n-                '''\n-                return list(application_obj.lot_set\\\n-                                           .filter(application__applicationstatus__current_step_id__step=7)\\\n-                                           .filter(application__applicationstatus__denied=False)\\\n-                                           .distinct())\n-\n-            def assemble_and_send_email(application, lots):\n-                '''Assemble email context and send email.'''\n-                context = {\n-                    'app': application,\n-                    'lots': lots\n-                }\n-\n-                self.send_email(\n-                    'eds_denial_email',\n-                    'LargeLots application - Denial',\n-                    application.email,\n-                    context\n-                )\n-\n-            def log(application, lots):\n-                print(log_fmt.format(\n-                    date=datetime.now().strftime('%Y-%m-%d %H:%M'),\n-                    applicant=' '.join([application.first_name, application.last_name]),\n-                    email=application.email,\n-                    phone=application.phone,\n-                    pins=', #'.join(str(lot.pin) for lot in lots)\n-                ))\n-\n-            for email, app_ids in applicants:\n-                # Get application objects where the applicant is on step 7.\n-                # (Some applicants have more than one application.)\n-                applications = Application.objects.filter(id__in=list(app_ids))\n-\n-                if email in separate_emails:\n-                    # In the case of a shared email address, send denial\n-                    # emails for each individual application.\n-                    for application in applications:\n-                        lots = step_7_lots_list(application)\n-\n-                        for app_status in application.applicationstatus_set\\\n-                                                     .filter(current_step_id__step=7):\n-\n-                            self.deny(app_status, no_eds, admin_user)\n-\n-                        assemble_and_send_email(application, lots)\n-\n-                        log(application, lots)\n-\n-                else:\n-                    # Aggregate information from applications and send one email.\n-                    lots = []\n-\n-                    for app in applications:\n-                        lots += step_7_lots_list(app)\n-\n-                        for app_status in app.applicationstatus_set\\\n-                                             .filter(current_step_id__step=7):\n-\n-                            self.deny(app_status, no_eds, admin_user)\n-\n-                    application = applications.first()\n-\n-                    assemble_and_send_email(application, lots)\n-\n-                    log(application, lots)\n-\n-        if options['closing_invitations']:\n-            n = int(options['closing_invitations'])\n-            date = datetime.strptime(options['date'], '%Y-%m-%d')\n-\n-            with connection.cursor() as cursor:\n-                query = '''\n-                    SELECT\n-                      email,\n-                      ARRAY_AGG(lot_id) AS pins,\n-                      ARRAY_AGG(id) AS status_ids\n-                    FROM (\n-                      SELECT\n-                        email,\n-                        status.lot_id,\n-                        status.id\n-                      FROM lots_admin_application AS app\n-                      JOIN lots_admin_applicationstatus AS status\n-                      ON app.id = status.application_id\n-                      JOIN lots_admin_applicationstep AS step\n-                      ON status.current_step_id = step.id\n-                      WHERE step = 10\n-                        AND closing_invite_sent = False\n-                      ORDER BY last_name, first_name\n-                    ) AS applicants\n-                    GROUP BY email\n-                    LIMIT {n}\n-                '''.format(n=n)\n-\n-                cursor.execute(query)\n-\n-                applicant_list = [(email, pins, status_ids) for email, pins, status_ids in cursor]\n-\n-                date_fmt = '%Y-%m-%d %H:%M'\n-\n-                log_fmt = '{date} {applicant} ({email}, {phone}) invited to ' + \\\n-                    'Closing {event} for lots #{pins}'\n-\n-                for idx, applicant in enumerate(applicant_list, start=1):\n-                    email, pins, status_ids = applicant\n-\n-                    # Status ID filter prevents us from grabbing an Application\n-                    # with a shared email address that does _not_ belong to the\n-                    # applicant we mean to notify\n-\n-                    applications = Application.objects\\\n-                                              .filter(email=email)\\\n-                                              .filter(applicationstatus__id__in=status_ids)\n-\n-                    application = applications.first()\n-\n-                    lots = [Lot.objects.get(pin=pin) for pin in pins]\n-\n-                    date_parts = [getattr(date, k) for k in ('year', 'month', 'day')]\n-\n-                    # Invite half the applicants to the morning event, and\n-                    # half to the afternoon event, using len of applicant list\n-                    # rather than initial n to handle case where we get fewer\n-                    # than n applicants\n-\n-                    if idx <= len(applicant_list) / 2:\n-                        date_parts += [9, 0]\n-                        event = datetime(*date_parts)\n-                    else:\n-                        date_parts += [13, 0]\n-                        event = datetime(*date_parts)\n-\n-                    context = {\n-                        'app': application,\n-                        'lots': lots,\n-                        'event': event,\n-                    }\n-\n-                    self.send_email(\n-                        'closing_invitation_email',\n-                        'Large Lot Closing Date and Time',\n-                        email,\n-                        context,\n-                    )\n-\n-                    # Update all because we only want to send one invitation\n-                    # per applicant (who may have multiple applications)\n-\n-                    for app in applications:\n-                        app.closing_invite_sent = True\n-                        app.save()\n-\n-                    print(log_fmt.format(\n-                        date=datetime.now().strftime(date_fmt),\n-                        applicant=' '.join([application.first_name, application.last_name]),\n-                        email=application.email,\n-                        phone=application.phone,\n-                        event=event.strftime(date_fmt),\n-                        pins=', #'.join(str(pin) for pin in pins),\n-                    ))\n-\n-        if options['closing_time']:\n-            with connection.cursor() as cursor:\n-                query = '''\n-                    SELECT email, array_agg(status.lot_id) AS pins, array_agg(status.id) AS status_ids\n-                    FROM lots_admin_application AS app\n-                    JOIN lots_admin_applicationstatus AS status\n-                    ON status.application_id = app.id\n-                    JOIN lots_admin_applicationstep AS step\n-                    ON status.current_step_id = step.id \n-                    WHERE denied = False and step = 8\n-                    GROUP BY email\n-                '''\n+        self.base_context = json.loads(options['base_context'])\n+        self.admin = User.objects.get(id=options['admin'])\n \n-                cursor.execute(query)\n-\n-                applicant_list = [(email, pins, status_ids) for email, pins, status_ids in cursor]\n-\n-                print(\"Emails sent to:\")\n-                for email, pins, status_ids in applicant_list:\n-                    # Isolate one applicant - to pass into \"context\"\n-                    application = Application.objects.filter(email=email).filter(applicationstatus__id__in=status_ids).first()\n-\n-                    lots = [Lot.objects.get(pin=pin) for pin in pins]\n-                    \n-                    context = {\n-                        'app': application,\n-                        'lots': lots\n-                    }\n-\n-                    self.send_email(\n-                        'closing_time_email', \n-                        'Closing Time for Large Lots', \n-                        email, \n-                        context\n-                    )\n-\n-                    # Move applicants to Step 9\n-                    application_statuses = ApplicationStatus.objects.filter(id__in=status_ids)\n-                    step9 = ApplicationStep.objects.get(step=9)\n-                    for a in application_statuses:\n-                        a.current_step = step9\n-                        a.save()\n-\n-                    print('{0} - Lots: {1}'.format(email, pins))\n+        try:\n+            email, = (k for k, v in options.items() if k.endswith('email') and v)\n+        except ValueError:\n+            raise CommandError('Only specify one email to send at a time.')\n \n+        if email == 'custom_email':\n+            self.operator = options['custom_email']\n+            self.step = options['steps']\n \n-        if options['city_council_denial']:\n-            application_statuses = ApplicationStatus.objects.filter(lot_id__in=['25223200170000','25223200190000','20211210110000','20211120350000','20211210030000','20211130370000','20211130160000','20211120060000','20211110160000','20211130040000','20211130180000','20211120380000','20082150270000','20032190350000','20032230250000','20032230270000','20032230380000','20032240510000','25291050490000','20034050130000']).filter(denied=False)\n+        getattr(self, 'send_{}'.format(email))()\n \n-            print(\"Emails sent to:\")\n-            for app in application_statuses:             \n-                context = {\n-                    'app': app.application,\n-                    'lot': app.lot\n-                }\n-                \n-                applicant = self.applicant_detail_str(app.application)\n-                print('Notified {}'.format(applicant))\n-\n-                self.send_email(\n-                    'city_council_denial_email', \n-                    'LargeLots application - Denial', \n-                    app.application.email, \n-                    context\n-                )\n-\n-        if options['eds_final_email']:\n-            # Send final notice email to all applicants on Step 7.\n-            with connection.cursor() as cursor:\n-                query = '''\n-                    SELECT\n-                      MIN(id) as id,\n-                      email\n-                    FROM (\n-                      SELECT\n-                        app.id,\n-                        email,\n-                        step,\n-                        denied\n-                      FROM lots_admin_application AS app\n-                      JOIN lots_admin_applicationstatus AS status\n-                      ON app.id = status.application_id\n-                      JOIN lots_admin_applicationstep AS step\n-                      ON status.current_step_id = step.id\n-                      WHERE denied = False\n-                      AND step = 7\n-                    ) AS applicants\n-                    GROUP BY email \n-                '''\n-\n-                cursor.execute(query)\n-\n-                applicants = [(app_id, email_address) for app_id, email_address in cursor]\n-\n-            for app_id, email_address in applicants:\n-                \n-                application = Application.objects.filter(id=app_id).first()               \n-                context = {'app': application}\n-                self.send_email(\n-                    'eds_email', \n-                    'LargeLots application - Economic Disclosure Statement (EDS)', \n-                    email_address, \n-                    context\n-                )\n-\n-                applicant = self.applicant_detail_str(application)\n-                print('Notified {}'.format(applicant))\n-\n-\n-        if options['lotto_email']:\n-            time = options['lotto_email']\n-            offset = int(options['lotto_offset']) - 1\n-\n-            if time == 'morning':\n-                comparator = '<='\n-            if time == 'afternoon':\n-                comparator = '>' \n-\n-            # This query grabs the lot pin, which lies at a specified mid-way point, e.g.,\n-            # if we want to notify the applicants who applied to the first 85 lots (in ascending order),\n-            # then the offset will be 84.  \n-            with connection.cursor() as cursor:\n-                query = '''\n-                    SELECT DISTINCT lot_id\n-                    FROM lots_admin_applicationstatus as status\n-                    JOIN lots_admin_applicationstep as step\n-                    ON status.current_step_id=step.id\n-                    WHERE step=6\n-                    ORDER BY lot_id\n-                    LIMIT 1 OFFSET {offset}\n-                '''.format(offset=offset)\n-\n-                cursor.execute(query)\n-                lot_id = cursor.fetchone()[0]\n-\n-            with connection.cursor() as cursor:\n-                query = '''\n-                    SELECT status.id, status.lot_id, email \n-                    FROM lots_admin_applicationstatus as status\n-                    JOIN lots_admin_applicationstep as step\n-                    ON status.current_step_id=step.id\n-                    JOIN lots_admin_application as app\n-                    ON app.id=status.application_id\n-                    WHERE step=6\n-                    AND status.lottery_email_sent = False\n-                    AND status.lot_id {comparator} '{lot_id}'\n-                    ORDER BY status.lot_id\n-                '''.format(comparator=comparator, lot_id=lot_id)\n-\n-                cursor.execute(query)\n-                applicants = [(status_id, lot_id, email_address) for status_id, lot_id, email_address in cursor]\n-\n-            for status_id, lot_id, email_address in applicants:\n-                status = ApplicationStatus.objects.get(id=status_id)\n-                application = Application.objects.get(id=status.application_id)\n-                lot = Lot.objects.get(pin=lot_id)              \n-                context = {'app': application, \n-                           'lot': lot}\n-                self.send_email(\n-                    'lottery_notification_{}'.format(time), \n-                    'LargeLots application - Lottery', \n-                    email_address, \n-                    context\n-                )\n+    def _select_applicants(self, operator, step, **keywords):\n+        '''\n+        Build and execute a query to select applicants, grouped by email, name,\n+        and ZIP code (a rough unique identifier of individuals) and ordered by\n+        applicant name.\n \n-                status.lottery_email_sent = True\n-                status.save()\n+        Return a tuple, (email, Application queryset, ApplicationStatus queryset),\n+        such that the returned Application and ApplicationStatus objects meet\n+        the criteria of the query.\n+        '''\n+        applicant_select = '''\n+            WITH applicants AS (\n+              SELECT\n+                app.email,\n+                app.first_name,\n+                app.last_name,\n+                address.zip_code,\n+                app.id AS app_id,\n+                status.id AS status_id,\n+                step.step\n+              FROM lots_admin_application AS app\n+              JOIN lots_admin_applicationstatus AS status\n+              ON app.id = status.application_id\n+              JOIN lots_admin_applicationstep AS step\n+              ON status.current_step_id = step.id\n+              JOIN lots_admin_address AS address\n+              ON app.contact_address_id = address.id\n+              WHERE status.denied = FALSE\n+              {filter}\n+              ORDER BY app.last_name, app.first_name\n+            )\n+            SELECT\n+              email,\n+              ARRAY_AGG(app_id) AS apps,\n+              ARRAY_AGG(status_id) AS app_statuses\n+            FROM applicants\n+            {where}\n+            GROUP BY email, first_name, last_name, zip_code\n+            {having}\n+        '''\n \n-                applicant = self.applicant_detail_str(application)\n-                print('Notified {}'.format(applicant))\n-\n-\n-        if options['eds_email']:\n-            limit = options['eds_email']\n-\n-            # Select only applicants whose non-denied applications\n-            # are all on step 7 in order to avoid a situation where\n-            # an applicant has active applications at other steps,\n-            # as these will get stuck if the applicant submits an\n-            # EDS prior to their reaching step 7. (The applicant\n-            # will not submit another EDS, thus the endpoint to \n-            # advance the remaining applications will never be\n-            # pinged.)\n-\n-            with connection.cursor() as cursor:\n-                query = '''\n-                    SELECT\n-                      MIN(id) as id,\n-                      email\n-                    FROM (\n-                      SELECT\n-                        app.id,\n-                        email,\n-                        step,\n-                        denied\n-                      FROM lots_admin_application AS app\n-                      JOIN lots_admin_applicationstatus AS status\n-                      ON app.id = status.application_id\n-                      JOIN lots_admin_applicationstep AS step\n-                      ON status.current_step_id = step.id\n-                      WHERE eds_sent = False \n-                        AND denied = False\n-                    ) AS applicants\n-                    GROUP BY email \n-                    HAVING (EVERY(step = 7))\n-                    LIMIT {limit}\n-                '''.format(limit=limit)\n-\n-                cursor.execute(query)\n-\n-                applicants = [(app_id, email_address) for app_id, email_address in cursor]\n-\n-            for app_id, email_address in applicants:\n-                \n-                application = Application.objects.filter(id=app_id).first()               \n-                context = {'app': application}\n-                self.send_email(\n-                    'eds_email', \n-                    'LargeLots application - Economic Disclosure Statement (EDS)', \n-                    email_address, \n-                    context\n-                )\n-\n-                applicant = self.applicant_detail_str(application)\n-                print('Notified {}'.format(applicant))\n-\n-                # Set `eds_sent` = True on all applications for given applicant\n-                # (since we only need one EDS per applicant)\n-                active_applications = ApplicationStatus.objects\\\n-                                                       .filter(application__email=email_address)\\\n-                                                       .filter(denied=False)\n-\n-                print('Updated applications for {}:'.format(applicant))\n-                for app in active_applications:\n-                    print(self.applicant_detail_str(app.application))\n-                    app.application.eds_sent = True\n-                    app.application.save()\n-                    \n-\n-        if options['deed_upload']:\n-            applications = Application.objects.all()\n-\n-            print(\"Emails sent to:\")\n-            for application in applications:\n-                application_status_objs = ApplicationStatus.objects.filter(application_id=application.id)\n-                wards = []\n-                for status in application_status_objs:\n-                    wards.append(status.lot.address.ward)\n-\n-                try:\n-                    first_ward = wards[0]\n-                except:\n-                    first_ward = ''\n-\n-                try:\n-                    second_ward = wards[1]\n-                except:\n-                    second_ward = ''\n-\n-                if application.deed_image == '':\n-                    print(application.first_name, application.last_name, \" - Application ID\", application.id)\n-                    print('Wards:', wards)\n-\n-                    context = {\n-                        'app': application,\n-                        'lots': application.lot_set.all()\n-                    }\n-\n-                    self.send_email('deed_inquiry_email', 'Important notice from Large Lots', application.email, context)\n-\n-        if options['humboldt_denial']:\n-            application_statuses = ApplicationStatus.objects.all()\n-\n-            print(\"Emails sent to:\")\n-            for app in application_statuses:\n-                if app.current_step:\n-                    if app.current_step.step != 7 and app.denied == False and app.lot.address.ward == '27' and app.lot.address.community == 'HUMBOLDT PARK':\n-                        print(app.application.first_name, app.application.last_name, \" - Application ID\", app.application.id, \" - Status\", app.id)\n-                        print(datetime.now())\n-\n-                        context = {\n-                            'app': app.application,\n-                            'lot': app.lot\n-                        }\n-\n-                        self.send_email('denial_humboldt_email', 'Large Lots Application', app.application.email, context)\n-\n-        if options['garfield_denial']:\n-            application_statuses = ApplicationStatus.objects.all()\n-\n-            print(\"Emails sent to:\")\n-            for app in application_statuses:\n-                if app.current_step:\n-                    if app.current_step.step != 7:\n-                        print(app.application.first_name, app.application.last_name, \" - Application ID\", app.application.id, \" - Status\", app.id)\n-                        print(datetime.now())\n-\n-                        context = {\n-                            'app': app.application,\n-                            'lot': app.lot\n-                        }\n-\n-                        self.send_email('denial_garfield_email', 'Large Lots Application', app.application.email, context)\n-\n-        if options['blank_deed_denial']:\n-            application_statuses = ApplicationStatus.objects.all()\n-\n-            print(\"Emails sent to:\")\n-            for app in application_statuses:\n-                if app.current_step:\n-                    if app.current_step.step == 2 and app.denied == False and app.application.deed_image == '':\n-                        print(app.application.first_name, app.application.last_name, \" - Application ID\", app.application.id, \" - Status\", app.id)\n-                        print(datetime.now())\n-\n-                        user = User.objects.get(id=5)\n-                        reason, created = DenialReason.objects.get_or_create(value=DENIAL_REASONS['document'])\n-                        review = Review(reviewer=user, email_sent=True, denial_reason=reason, application=application_status, step_completed=2)\n-                        review.save()\n-\n-                        context = {\n-                            'app': application_status.application,\n-                            'lot': application_status.lot,\n-                            'review': review,\n-                            'DENIAL_REASONS': DENIAL_REASONS,\n-                        }\n-\n-                        self.send_email('emails/deny_html_email', 'Large Lots Application', application_status.application.email, context)\n-\n-        if options['update_email']:\n-            application_statuses = ApplicationStatus.objects.all()\n-\n-            print(\"Emails sent to:\")\n-            for app in application_statuses:\n-                if app.id in [710, 1156, 1171, 1996, 2274, 2567, 2893, 2912, 3198, 3591, 3734, 3208, 502]:\n-                    print(app.application.first_name, app.application.last_name, \" - Application ID\", app.application.id)\n-                    print(datetime.now())\n-\n-                    context = {\n-                        'app': app.application,\n-                        'lot': app.lot\n-                    }\n+        select_kwargs = {}\n \n-                    self.send_email('update_email', 'Important update from Large Lots', app.application.email, context)\n-\n-        if options['wintrust_email']:\n-            application_statuses = ApplicationStatus.objects.all()\n+        if keywords.get('filter'):\n+            select_kwargs['filter'] = 'AND {}'.format(keywords['filter'])\n+        else:\n+            select_kwargs['filter'] = ''\n \n-            print(\"Emails sent to:\")\n-            for app in application_statuses:\n-                if app.current_step:\n-                    if app.current_step.step in [4, 6] and app.denied == False:\n+        step_kwargs = {'operator': operator, 'step': step}\n \n-                        print(app.application.first_name, app.application.last_name, \" - Application ID\", app.application.id)\n-                        print(datetime.now())\n+        if keywords.get('every_status'):\n+            where = ''\n+            having = 'HAVING EVERY(step {operator} {step})'.format(**step_kwargs)\n+        else:\n+            where = 'WHERE step {operator} {step}'.format(**step_kwargs)\n+            having = ''\n \n-                        context = {\n-                            'app': app.application,\n-                            'lot': app.lot\n-                        }\n+        select_kwargs.update({'where': where, 'having': having})\n \n-                        self.send_email('wintrust_email', 'Special event for Large Lots applicants', app.application.email, context)\n+        with connection.cursor() as cursor:\n+            cursor.execute(applicant_select.format(**select_kwargs))\n \n-    def applicant_detail_str(self, application):\n-        return \"{0} {1} - Application ID {2}\".format(application.first_name,\n-                                                     application.last_name,\n-                                                     application.id)\n+            for email, apps, statuses in cursor:\n+                apps = Application.objects.filter(id__in=apps)\n+                statuses = ApplicationStatus.objects.filter(id__in=statuses)\n+                yield email, apps, statuses\n \n+    _select_applicants_on_step = partialmethod(_select_applicants, '=')\n+    _select_applicants_not_on_step = partialmethod(_select_applicants, '!=')\n+    _select_applicants_on_steps_before = partialmethod(_select_applicants, '<')\n+    _select_applicants_on_steps_after = partialmethod(_select_applicants, '>')\n \n-    def send_email(self, template_name, email_subject, email_to_address, context):\n-        html_template = get_template('{}.html'.format(template_name))\n-        txt_template = get_template('{}.txt'.format(template_name))\n+    def _send_email(self, template, subject, email_address, context):\n+        html_template = get_template('emails/{}.html'.format(template))\n+        txt_template = get_template('emails/{}.txt'.format(template))\n \n         html_content = html_template.render(context)\n         txt_content = txt_template.render(context)\n \n-        msg = EmailMultiAlternatives(email_subject,\n-                                txt_content,\n-                                settings.EMAIL_HOST_USER,\n-                                [email_to_address])\n+        msg = EmailMultiAlternatives(subject,\n+                                     txt_content,\n+                                     settings.EMAIL_HOST_USER,\n+                                     [email_address])\n \n         msg.attach_alternative(html_content, 'text/html')\n \n         try:\n             msg.send()\n-        except SMTPException as stmp_e:\n-            print(stmp_e)\n-            print(\"Not able to send email due to smtp exception.\")\n+\n+        except SMTPException as smtp_e:\n+            logging.error(\n+                \"Not able to send email due to SMTP exception.\",\n+                extra={\n+                    'exception': smtp_e,\n+                    'email_address': email_address,\n+                    'subject': subject,\n+                }\n+            )\n+            raise CannotSendEmailException(str(smtp_e))\n+\n         except Exception as e:\n-            print(e)\n-            print(\"Not able to send email.\")\n+            logging.error(\n+                \"Not able to send email due to unknown exception.\",\n+                extra={\n+                    'exception': e,\n+                    'email_address': email_address,\n+                    'subject': subject,\n+                }\n+            )\n+            raise CannotSendEmailException(str(e))\n+\n+        else:\n+            time.sleep(5)\n \n-        time.sleep(5)\n+    def _log(self, app, statuses, log_fmt=None, **kwargs):\n+        if not log_fmt:\n+            log_fmt = '{date} | {application} | {applicant} | {email} | {phone} | {pins}'\n \n+        log_kwargs = dict(\n+            date=datetime.now().strftime('%Y-%m-%d %H:%M'),\n+            application = app.id,\n+            applicant=' '.join([app.first_name, app.last_name]),\n+            email=app.email,\n+            phone=app.phone,\n+            pins=','.join(str(s.lot.pin) for s in statuses),\n+        )\n \n-    def deny(self, app_status, denial_reason, admin_user):\n-        '''Deny the given application and create a corresponding review\n-        object.\n+        log_kwargs.update(kwargs)\n+\n+        logging.info(log_fmt.format(**log_kwargs))\n+\n+    def _deny(self, app_status, denial_reason):\n+        '''\n+        Deny the given application and create a corresponding review object.\n \n         :app_status - ApplicationStatus object\n         :denial_reason - DenialReason object\n-        :admin_user - User object (usually, an admin)\n         '''\n         app_status.denied = True\n         app_status.current_step = None\n         app_status.save()\n \n-        review = Review(reviewer=admin_user,\n+        review = Review(reviewer=self.admin,\n                         denial_reason=denial_reason,\n                         application=app_status,\n                         email_sent=True)\n \n         review.save()\n \n+    def send_eds_email(self):\n+        '''\n+        Email only applicants whose non-denied applications are all on step 7,\n+        in order to avoid a situation where an applicant has active application\n+        statuses on other steps, as these will get stuck if the applicant\n+        submits an EDS before the remaining statuses reach step 7.\n+        '''\n+        subject = 'LargeLots application - Economic Disclosure Statement (EDS)'\n+\n+        for email, apps, statuses in self._select_applicants_on_step(7, every_status=True, filter='app.eds_sent = FALSE'):\n+            context = {'app': apps.first()}\n+\n+            try:\n+                self._send_email('eds_email', subject, email, context)\n+\n+            except CannotSendEmailException:\n+                self.stdout.write('{0} | {1}'.format(email, ','.join(str(app.id) for app in apps)))\n+\n+            else:\n+                self._log(apps.first(), statuses)\n+\n+                for app in apps:\n+                    app.eds_sent = True\n+                    app.save()\n+\n+    def send_lotto_email(self):\n+        '''\n+        Send a lottery notification on a per-status basis, since all lots\n+        will have their own lottery event.\n+        '''\n+        subject = 'LargeLots application - Lottery'\n+        log_fmt = '{date} {applicant} ({email}, {phone}) invited to Lottery at {event} for lots {pins}'\n+\n+        # Sort applicants by lot PIN\n+        applicants = []\n+\n+        for email, apps, statuses in self._select_applicants_on_step(6, filter='status.lottery_email_sent = FALSE'):\n+            for status in statuses:\n+                app_data = (email, apps.get(applicationstatus=status), status)\n+                applicants.append(app_data)\n+\n+        sorted_applicants = sorted(list(applicants), key=lambda x: x[2].lot.pin)\n+\n+        for idx, app_data in enumerate(sorted_applicants[:self.n], start=1):\n+            email, app, status = app_data\n+\n+            context = {'lot': status.lot, 'app': app}\n+            context.update(self.base_context)\n+\n+            try:\n+                self._send_email('lottery_notification', subject, email, context)\n+\n+            except CannotSendEmailException:\n+                self.stdout.write('{0} | {1}'.format(email, str(app.id)))\n+\n+            else:\n+                event = ' on '.join([context['time'], context['date']])\n+                self._log(app, [status], log_fmt, event=event)\n+\n+                status.lottery_email_sent = True\n+                status.save()\n+\n+    def send_eds_final_email(self):\n+        subject = 'LargeLots application - Economic Disclosure Statement (EDS)'\n+\n+        for email, apps, statuses in self._select_applicants_on_step(7):\n+            context = {'app': apps.first()}\n+\n+            try:\n+                self._send_email('eds_email', subject, email, context)\n+\n+            except CannotSendEmailException:\n+                self.stdout.write('{0} | {1}'.format(email, ','.join(str(app.id) for app in apps)))\n+\n+            else:\n+                self._log(app, statuses.filter(application=app))\n+\n+    def send_closing_time_email(self):\n+        subject = 'Closing Time for Large Lots'\n+\n+        next_step = ApplicationStep.objects.get(step=9)\n+\n+        for email, apps, statuses in self._select_applicants_on_step(8):\n+            lots = [s.lot for s in statuses]\n \n+            context = {'app': apps.first(), 'lots': lots}\n \n+            try:\n+                self._send_email('closing_time_email', subject, email, context)\n+\n+            except CannotSendEmailException:\n+                self.stdout.write('{0} | {1}'.format(email, ','.join(str(app.id) for app in apps)))\n+\n+            else:\n+                self._log(apps.first(), statuses)\n+\n+                for status in statuses:\n+                    status.current_step = next_step\n+                    status.save()\n+\n+    def send_closing_invitations_email(self):\n+        subject = 'Large Lot Closing Date and Time'\n+        log_fmt = '{date} {applicant} ({email}, {phone}) invited to Closing {event} for lots {pins}'\n+\n+        applicants = list(self._select_applicants_on_step(10, filter='app.closing_invite_sent = FALSE'))\n+\n+        for idx, app_data in enumerate(applicants[:self.n], start=1):\n+            email, apps, statuses = app_data\n+\n+            lots = [s.lot for s in statuses]\n+            context = {'app': apps.first(), 'lots': lots}\n+            context.update(self.base_context)\n+\n+            try:\n+                self._send_email('closing_invitation_email', subject, email, context)\n+\n+            except CannotSendEmailException:\n+                self.stdout.write('{0} | {1}'.format(email, ','.join(str(app.id) for app in apps)))\n+\n+            else:\n+                event = ' on '.join([context['time'], context['date']])\n+                self._log(apps.first(), statuses, log_fmt, event=event)\n+\n+                for app in apps:\n+                    app.closing_invite_sent = True\n+                    app.save()\n+\n+    def send_eds_denial_email(self):\n+        subject = 'LargeLots application - Denial'\n+        log_fmt = '{date} {applicant} ({email}) denied due to lack of EDS for lots {pins}'\n+\n+        no_eds, _ = DenialReason.objects.get_or_create(value=DENIAL_REASONS['EDS'])\n+\n+        for email, apps, statuses in self._select_applicants_on_step(7):\n+            lots = [s.lot for s in statuses]\n+            context = {'app': apps.first(), 'lots': lots}\n+\n+            try:\n+                self._send_email('eds_denial_email', subject, email, context)\n+\n+            except CannotSendEmailException:\n+                self.stdout.write('{0} | {1}'.format(email, ','.join(str(app.id) for app in apps)))\n+\n+            else:\n+                self._log(apps.first(), statuses, log_fmt)\n+\n+                for status in statuses:\n+                    self._deny(status, no_eds)\n+\n+    def send_custom_email(self):\n+        '''\n+        e.g., python manage.py send_emails --custom_email on_step --step 6\n+        '''\n+        select_method = getattr(self, '_select_applicants_{}'.format(self.operator))\n+\n+        for email, apps, statuses in select_method(self.step):\n+            lots = [s.lot for s in statuses]\n+            context = {'app': apps.first(), 'lots': lots}\n+            context.update(self.base_context)\n+\n+            try:\n+                '''\n+                TO-DO: Make a base template.\n+                '''","path":"lots_admin/management/commands/send_emails.py","position":1049,"original_position":1049,"commit_id":"1d4deaa0f221c60df1f2ab00c71117c14c79b541","original_commit_id":"1d4deaa0f221c60df1f2ab00c71117c14c79b541","user":{"login":"reginafcompton","id":13078679,"node_id":"MDQ6VXNlcjEzMDc4Njc5","avatar_url":"https://avatars1.githubusercontent.com/u/13078679?v=4","gravatar_id":"","url":"https://api.github.com/users/reginafcompton","html_url":"https://github.com/reginafcompton","followers_url":"https://api.github.com/users/reginafcompton/followers","following_url":"https://api.github.com/users/reginafcompton/following{/other_user}","gists_url":"https://api.github.com/users/reginafcompton/gists{/gist_id}","starred_url":"https://api.github.com/users/reginafcompton/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/reginafcompton/subscriptions","organizations_url":"https://api.github.com/users/reginafcompton/orgs","repos_url":"https://api.github.com/users/reginafcompton/repos","events_url":"https://api.github.com/users/reginafcompton/events{/privacy}","received_events_url":"https://api.github.com/users/reginafcompton/received_events","type":"User","site_admin":false},"body":"Heh. I suppose this is a TODO for me?","created_at":"2018-08-14T14:30:10Z","updated_at":"2018-08-14T14:40:26Z","html_url":"https://github.com/datamade/large-lots/pull/376#discussion_r209973797","pull_request_url":"https://api.github.com/repos/datamade/large-lots/pulls/376","author_association":"COLLABORATOR","_links":{"self":{"href":"https://api.github.com/repos/datamade/large-lots/pulls/comments/209973797"},"html":{"href":"https://github.com/datamade/large-lots/pull/376#discussion_r209973797"},"pull_request":{"href":"https://api.github.com/repos/datamade/large-lots/pulls/376"}}},"pull_request":{"url":"https://api.github.com/repos/datamade/large-lots/pulls/376","id":207087668,"node_id":"MDExOlB1bGxSZXF1ZXN0MjA3MDg3NjY4","html_url":"https://github.com/datamade/large-lots/pull/376","diff_url":"https://github.com/datamade/large-lots/pull/376.diff","patch_url":"https://github.com/datamade/large-lots/pull/376.patch","issue_url":"https://api.github.com/repos/datamade/large-lots/issues/376","number":376,"state":"open","locked":false,"title":"refactor email management command","user":{"login":"hancush","id":12176173,"node_id":"MDQ6VXNlcjEyMTc2MTcz","avatar_url":"https://avatars2.githubusercontent.com/u/12176173?v=4","gravatar_id":"","url":"https://api.github.com/users/hancush","html_url":"https://github.com/hancush","followers_url":"https://api.github.com/users/hancush/followers","following_url":"https://api.github.com/users/hancush/following{/other_user}","gists_url":"https://api.github.com/users/hancush/gists{/gist_id}","starred_url":"https://api.github.com/users/hancush/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/hancush/subscriptions","organizations_url":"https://api.github.com/users/hancush/orgs","repos_url":"https://api.github.com/users/hancush/repos","events_url":"https://api.github.com/users/hancush/events{/privacy}","received_events_url":"https://api.github.com/users/hancush/received_events","type":"User","site_admin":false},"body":"the refactored email command issues emails to applicants grouped by email, first & last name, and zip (as enabled by #276). it also updates the tests to reflect the change in functionality, and adds new methods to send emails to arbitrary groups of applicants. \r\n\r\nfailures to send emails are written to stdout. these can be accessed by application code by capturing stdout, [like so](https://stackoverflow.com/a/22571528). for fun, i've added a small utility to return the implicated applications, usable like:\r\n\r\n```python\r\nfrom lots_admin.utils import EmailLogParser\r\n\r\nout = EmailLogParser\r\ncall_command('send_emails', ..., stdout=out)\r\nfailures = out.applications   # queryset of eligible applicants to whom email could not be sent\r\n```\r\n\r\nusage includes:\r\n\r\n```bash\r\n# send out eds applications\r\npython manage.py send_emails --eds_email\r\n\r\n# invite 50 applicants to a lottery event at 9:00 on march 1\r\npython manage.py send_emails --lotto_email -n 50 --base_context='{\"date\": \"2019-03-01\", \"time\": \"9:00\"}'\r\n\r\n# send some message to all applicants on step 4\r\npython manage.py send_emails --custom_email on_step --step 4 --base_context='{\"subject\": \"a subject line\"}'\r\n```","created_at":"2018-08-08T17:51:36Z","updated_at":"2018-08-14T14:40:26Z","closed_at":null,"merged_at":null,"merge_commit_sha":"3fa41618aa452df1aeeec7e752f1afe50a5d74ea","assignee":null,"assignees":[],"requested_reviewers":[],"requested_teams":[],"labels":[],"milestone":null,"commits_url":"https://api.github.com/repos/datamade/large-lots/pulls/376/commits","review_comments_url":"https://api.github.com/repos/datamade/large-lots/pulls/376/comments","review_comment_url":"https://api.github.com/repos/datamade/large-lots/pulls/comments{/number}","comments_url":"https://api.github.com/repos/datamade/large-lots/issues/376/comments","statuses_url":"https://api.github.com/repos/datamade/large-lots/statuses/1d4deaa0f221c60df1f2ab00c71117c14c79b541","head":{"label":"datamade:refactor_email_sending","ref":"refactor_email_sending","sha":"1d4deaa0f221c60df1f2ab00c71117c14c79b541","user":{"login":"datamade","id":3723931,"node_id":"MDEyOk9yZ2FuaXphdGlvbjM3MjM5MzE=","avatar_url":"https://avatars3.githubusercontent.com/u/3723931?v=4","gravatar_id":"","url":"https://api.github.com/users/datamade","html_url":"https://github.com/datamade","followers_url":"https://api.github.com/users/datamade/followers","following_url":"https://api.github.com/users/datamade/following{/other_user}","gists_url":"https://api.github.com/users/datamade/gists{/gist_id}","starred_url":"https://api.github.com/users/datamade/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/datamade/subscriptions","organizations_url":"https://api.github.com/users/datamade/orgs","repos_url":"https://api.github.com/users/datamade/repos","events_url":"https://api.github.com/users/datamade/events{/privacy}","received_events_url":"https://api.github.com/users/datamade/received_events","type":"Organization","site_admin":false},"repo":{"id":18152936,"node_id":"MDEwOlJlcG9zaXRvcnkxODE1MjkzNg==","name":"large-lots","full_name":"datamade/large-lots","owner":{"login":"datamade","id":3723931,"node_id":"MDEyOk9yZ2FuaXphdGlvbjM3MjM5MzE=","avatar_url":"https://avatars3.githubusercontent.com/u/3723931?v=4","gravatar_id":"","url":"https://api.github.com/users/datamade","html_url":"https://github.com/datamade","followers_url":"https://api.github.com/users/datamade/followers","following_url":"https://api.github.com/users/datamade/following{/other_user}","gists_url":"https://api.github.com/users/datamade/gists{/gist_id}","starred_url":"https://api.github.com/users/datamade/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/datamade/subscriptions","organizations_url":"https://api.github.com/users/datamade/orgs","repos_url":"https://api.github.com/users/datamade/repos","events_url":"https://api.github.com/users/datamade/events{/privacy}","received_events_url":"https://api.github.com/users/datamade/received_events","type":"Organization","site_admin":false},"private":false,"html_url":"https://github.com/datamade/large-lots","description":":seedling: The City of Chicago is selling vacant residential lots for $1. Here's how you get one.","fork":false,"url":"https://api.github.com/repos/datamade/large-lots","forks_url":"https://api.github.com/repos/datamade/large-lots/forks","keys_url":"https://api.github.com/repos/datamade/large-lots/keys{/key_id}","collaborators_url":"https://api.github.com/repos/datamade/large-lots/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/datamade/large-lots/teams","hooks_url":"https://api.github.com/repos/datamade/large-lots/hooks","issue_events_url":"https://api.github.com/repos/datamade/large-lots/issues/events{/number}","events_url":"https://api.github.com/repos/datamade/large-lots/events","assignees_url":"https://api.github.com/repos/datamade/large-lots/assignees{/user}","branches_url":"https://api.github.com/repos/datamade/large-lots/branches{/branch}","tags_url":"https://api.github.com/repos/datamade/large-lots/tags","blobs_url":"https://api.github.com/repos/datamade/large-lots/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/datamade/large-lots/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/datamade/large-lots/git/refs{/sha}","trees_url":"https://api.github.com/repos/datamade/large-lots/git/trees{/sha}","statuses_url":"https://api.github.com/repos/datamade/large-lots/statuses/{sha}","languages_url":"https://api.github.com/repos/datamade/large-lots/languages","stargazers_url":"https://api.github.com/repos/datamade/large-lots/stargazers","contributors_url":"https://api.github.com/repos/datamade/large-lots/contributors","subscribers_url":"https://api.github.com/repos/datamade/large-lots/subscribers","subscription_url":"https://api.github.com/repos/datamade/large-lots/subscription","commits_url":"https://api.github.com/repos/datamade/large-lots/commits{/sha}","git_commits_url":"https://api.github.com/repos/datamade/large-lots/git/commits{/sha}","comments_url":"https://api.github.com/repos/datamade/large-lots/comments{/number}","issue_comment_url":"https://api.github.com/repos/datamade/large-lots/issues/comments{/number}","contents_url":"https://api.github.com/repos/datamade/large-lots/contents/{+path}","compare_url":"https://api.github.com/repos/datamade/large-lots/compare/{base}...{head}","merges_url":"https://api.github.com/repos/datamade/large-lots/merges","archive_url":"https://api.github.com/repos/datamade/large-lots/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/datamade/large-lots/downloads","issues_url":"https://api.github.com/repos/datamade/large-lots/issues{/number}","pulls_url":"https://api.github.com/repos/datamade/large-lots/pulls{/number}","milestones_url":"https://api.github.com/repos/datamade/large-lots/milestones{/number}","notifications_url":"https://api.github.com/repos/datamade/large-lots/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/datamade/large-lots/labels{/name}","releases_url":"https://api.github.com/repos/datamade/large-lots/releases{/id}","deployments_url":"https://api.github.com/repos/datamade/large-lots/deployments","created_at":"2014-03-26T20:34:00Z","updated_at":"2018-07-23T20:04:20Z","pushed_at":"2018-08-13T19:34:25Z","git_url":"git://github.com/datamade/large-lots.git","ssh_url":"git@github.com:datamade/large-lots.git","clone_url":"https://github.com/datamade/large-lots.git","svn_url":"https://github.com/datamade/large-lots","homepage":"https://www.largelots.org/","size":49369,"stargazers_count":12,"watchers_count":12,"language":"Java","has_issues":true,"has_projects":true,"has_downloads":true,"has_wiki":true,"has_pages":false,"forks_count":5,"mirror_url":null,"archived":false,"open_issues_count":36,"license":{"key":"mit","name":"MIT License","spdx_id":"MIT","url":"https://api.github.com/licenses/mit","node_id":"MDc6TGljZW5zZTEz"},"forks":5,"open_issues":36,"watchers":12,"default_branch":"master"}},"base":{"label":"datamade:master","ref":"master","sha":"d50e101d43c4f7c7af27e5869036d24cc71fb280","user":{"login":"datamade","id":3723931,"node_id":"MDEyOk9yZ2FuaXphdGlvbjM3MjM5MzE=","avatar_url":"https://avatars3.githubusercontent.com/u/3723931?v=4","gravatar_id":"","url":"https://api.github.com/users/datamade","html_url":"https://github.com/datamade","followers_url":"https://api.github.com/users/datamade/followers","following_url":"https://api.github.com/users/datamade/following{/other_user}","gists_url":"https://api.github.com/users/datamade/gists{/gist_id}","starred_url":"https://api.github.com/users/datamade/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/datamade/subscriptions","organizations_url":"https://api.github.com/users/datamade/orgs","repos_url":"https://api.github.com/users/datamade/repos","events_url":"https://api.github.com/users/datamade/events{/privacy}","received_events_url":"https://api.github.com/users/datamade/received_events","type":"Organization","site_admin":false},"repo":{"id":18152936,"node_id":"MDEwOlJlcG9zaXRvcnkxODE1MjkzNg==","name":"large-lots","full_name":"datamade/large-lots","owner":{"login":"datamade","id":3723931,"node_id":"MDEyOk9yZ2FuaXphdGlvbjM3MjM5MzE=","avatar_url":"https://avatars3.githubusercontent.com/u/3723931?v=4","gravatar_id":"","url":"https://api.github.com/users/datamade","html_url":"https://github.com/datamade","followers_url":"https://api.github.com/users/datamade/followers","following_url":"https://api.github.com/users/datamade/following{/other_user}","gists_url":"https://api.github.com/users/datamade/gists{/gist_id}","starred_url":"https://api.github.com/users/datamade/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/datamade/subscriptions","organizations_url":"https://api.github.com/users/datamade/orgs","repos_url":"https://api.github.com/users/datamade/repos","events_url":"https://api.github.com/users/datamade/events{/privacy}","received_events_url":"https://api.github.com/users/datamade/received_events","type":"Organization","site_admin":false},"private":false,"html_url":"https://github.com/datamade/large-lots","description":":seedling: The City of Chicago is selling vacant residential lots for $1. Here's how you get one.","fork":false,"url":"https://api.github.com/repos/datamade/large-lots","forks_url":"https://api.github.com/repos/datamade/large-lots/forks","keys_url":"https://api.github.com/repos/datamade/large-lots/keys{/key_id}","collaborators_url":"https://api.github.com/repos/datamade/large-lots/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/datamade/large-lots/teams","hooks_url":"https://api.github.com/repos/datamade/large-lots/hooks","issue_events_url":"https://api.github.com/repos/datamade/large-lots/issues/events{/number}","events_url":"https://api.github.com/repos/datamade/large-lots/events","assignees_url":"https://api.github.com/repos/datamade/large-lots/assignees{/user}","branches_url":"https://api.github.com/repos/datamade/large-lots/branches{/branch}","tags_url":"https://api.github.com/repos/datamade/large-lots/tags","blobs_url":"https://api.github.com/repos/datamade/large-lots/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/datamade/large-lots/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/datamade/large-lots/git/refs{/sha}","trees_url":"https://api.github.com/repos/datamade/large-lots/git/trees{/sha}","statuses_url":"https://api.github.com/repos/datamade/large-lots/statuses/{sha}","languages_url":"https://api.github.com/repos/datamade/large-lots/languages","stargazers_url":"https://api.github.com/repos/datamade/large-lots/stargazers","contributors_url":"https://api.github.com/repos/datamade/large-lots/contributors","subscribers_url":"https://api.github.com/repos/datamade/large-lots/subscribers","subscription_url":"https://api.github.com/repos/datamade/large-lots/subscription","commits_url":"https://api.github.com/repos/datamade/large-lots/commits{/sha}","git_commits_url":"https://api.github.com/repos/datamade/large-lots/git/commits{/sha}","comments_url":"https://api.github.com/repos/datamade/large-lots/comments{/number}","issue_comment_url":"https://api.github.com/repos/datamade/large-lots/issues/comments{/number}","contents_url":"https://api.github.com/repos/datamade/large-lots/contents/{+path}","compare_url":"https://api.github.com/repos/datamade/large-lots/compare/{base}...{head}","merges_url":"https://api.github.com/repos/datamade/large-lots/merges","archive_url":"https://api.github.com/repos/datamade/large-lots/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/datamade/large-lots/downloads","issues_url":"https://api.github.com/repos/datamade/large-lots/issues{/number}","pulls_url":"https://api.github.com/repos/datamade/large-lots/pulls{/number}","milestones_url":"https://api.github.com/repos/datamade/large-lots/milestones{/number}","notifications_url":"https://api.github.com/repos/datamade/large-lots/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/datamade/large-lots/labels{/name}","releases_url":"https://api.github.com/repos/datamade/large-lots/releases{/id}","deployments_url":"https://api.github.com/repos/datamade/large-lots/deployments","created_at":"2014-03-26T20:34:00Z","updated_at":"2018-07-23T20:04:20Z","pushed_at":"2018-08-13T19:34:25Z","git_url":"git://github.com/datamade/large-lots.git","ssh_url":"git@github.com:datamade/large-lots.git","clone_url":"https://github.com/datamade/large-lots.git","svn_url":"https://github.com/datamade/large-lots","homepage":"https://www.largelots.org/","size":49369,"stargazers_count":12,"watchers_count":12,"language":"Java","has_issues":true,"has_projects":true,"has_downloads":true,"has_wiki":true,"has_pages":false,"forks_count":5,"mirror_url":null,"archived":false,"open_issues_count":36,"license":{"key":"mit","name":"MIT License","spdx_id":"MIT","url":"https://api.github.com/licenses/mit","node_id":"MDc6TGljZW5zZTEz"},"forks":5,"open_issues":36,"watchers":12,"default_branch":"master"}},"_links":{"self":{"href":"https://api.github.com/repos/datamade/large-lots/pulls/376"},"html":{"href":"https://github.com/datamade/large-lots/pull/376"},"issue":{"href":"https://api.github.com/repos/datamade/large-lots/issues/376"},"comments":{"href":"https://api.github.com/repos/datamade/large-lots/issues/376/comments"},"review_comments":{"href":"https://api.github.com/repos/datamade/large-lots/pulls/376/comments"},"review_comment":{"href":"https://api.github.com/repos/datamade/large-lots/pulls/comments{/number}"},"commits":{"href":"https://api.github.com/repos/datamade/large-lots/pulls/376/commits"},"statuses":{"href":"https://api.github.com/repos/datamade/large-lots/statuses/1d4deaa0f221c60df1f2ab00c71117c14c79b541"}},"author_association":"MEMBER"}}
{"actor":{"display_login":"reginafcompton"}}