Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor current meeting logic #304

Merged
merged 6 commits into from
May 18, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 101 additions & 26 deletions lametro/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

from django.conf import settings
from django.db import models
from django.db.models.expressions import RawSQL
from django.utils import timezone
from django.contrib.auth.models import User

Expand Down Expand Up @@ -263,44 +264,118 @@ def upcoming_board_meeting(cls):
.first()


@staticmethod
def _time_ago(**kwargs):
'''
Convenience method for returning localized, negative timedeltas.
'''
return datetime.now(app_timezone) - timedelta(**kwargs)


@staticmethod
def _time_from_now(**kwargs):
'''
Convenience method for returning localized, positive timedeltas.
'''
return datetime.now(app_timezone) + timedelta(**kwargs)


@classmethod
def current_meeting(cls):
def _potentially_current_meetings(cls):
'''
Return meetings that could be "current" – that is, meetings that are
scheduled to start in the last six hours, or in the next five minutes.

Fun fact: The longest Metro meeting on record is 5.38 hours long (see
issue #251). Hence, we check for meetings scheduled to begin up to six
hours ago.

Used to determine whether to check Granicus for streaming meetings.
'''
six_hours_ago = cls._time_ago(hours=6)
five_minutes_from_now = cls._time_from_now(minutes=5)

return cls.objects.filter(start_time__gte=six_hours_ago,
start_time__lte=five_minutes_from_now)\
.exclude(status='cancelled')


@classmethod
def _streaming_meeting(cls):
'''
Discover and return events in progress.
Granicus provides a running events endpoint that returns an array of
GUIDs for streaming meetings. Metro events occur one at a time, but two
GUIDs appear when an event is live: one for the English audio, and one
for the Spanish audio.

Hit the endpoint, and return the corresponding meeting, or an empty
queryset.
'''
running_events = requests.get('http://metro.granicus.com/running_events.php')

for guid in running_events.json():
# We get back two GUIDs, but we won't know which is the English
# audio GUID stored in the 'guid' field of the extras dict. Thus,
# we iterate.
#
# Note that our stored GUIDs are all uppercase, because they come
# that way from the Legistar API. The running events endpoint
# returns all-lowercase GUIDs, so we need to uppercase them for
# comparison.
meeting = cls.objects.filter(extras__guid=guid.upper())

if meeting:
return meeting

The maximum recorded meeting duration is 5.38 hours, according to the
spreadsheet provided by Metro in issue #251. So, to determine initial
list of possible current events, we look for all events scheduled
in the past 6 hours.
return cls.objects.none()

A meeting displays as "current" if:

(1) it started in the last six hours, or it starts in the next five
minutes (determined by this method); and
(2) it started less than 55 minutes ago, and the previous meeting has
ended (determined by `calculate_current_meetings`); or
(3) Legistar indicates it is in progress (detemined by `calculate_
current_meetings`).
@classmethod
def current_meeting(cls):
'''
If there is a meeting scheduled to begin in the last six hours or in
the next five minutes, hit the running events endpoint.

If there is a running event, return the corresponding meeting.

This method returns a list (with zero or more elements).
If there are no running events, return meetings scheduled to begin in
the last 20 minutes (to account for late starts) or in the next five
minutes (to show meetings as current, five minutes ahead of time).

To hardcode current event(s) for testing, use these examples:
return LAMetroEvent.objects.filter(start_time='2017-06-15 13:30:00-05')
return LAMetroEvent.objects.filter(start_time='2017-11-30 11:00:00-06')
Otherwise, return an empty queryset.
'''
from .utils import calculate_current_meetings # Avoid circular import
scheduled_meetings = cls._potentially_current_meetings()

five_minutes_from_now = datetime.now(app_timezone) + timedelta(minutes=5)
six_hours_ago = datetime.now(app_timezone) - timedelta(hours=6)
found_events = cls.objects.filter(start_time__gte=six_hours_ago, start_time__lte=five_minutes_from_now)\
.exclude(status='cancelled')\
.order_by('start_time')
if scheduled_meetings:
streaming_meeting = cls._streaming_meeting()

if found_events:
return calculate_current_meetings(found_events)
if streaming_meeting:
current_meetings = streaming_meeting

else:
# Sometimes, streams start later than a meeting's start time.
# Check for meetings scheduled to begin in the last 20 minutes
# so they are returned as current in the event that the stream
# does not start on time.
#
# Note that 'scheduled_meetings' already contains meetings
# scheduled to start in the last six hours or in the next five
# minutes, so we just need to add the 20-minute lower bound to
# return meetings scheduled in the last 20 minutes or in the
# next five minutes.
twenty_minutes_ago = cls._time_ago(minutes=20)

# '.annotate' adds a boolean field, 'is_board_meeting'. We want
# to show board meetings first, so order in reverse, since False
# (0) comes before True (1).
current_meetings = scheduled_meetings.filter(start_time__gte=twenty_minutes_ago)\
.annotate(is_board_meeting=RawSQL("name like %s", ('%Board Meeting%',)))\
.order_by('-is_board_meeting')

else:
return cls.objects.none()
current_meetings = cls.objects.none()

return current_meetings


@classmethod
Expand Down
85 changes: 0 additions & 85 deletions lametro/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
import lxml.html
from lxml.etree import tostring

from django.db.models.expressions import RawSQL
from django.conf import settings

from councilmatic_core.models import EventParticipant, Organization
Expand Down Expand Up @@ -41,87 +40,3 @@ def format_full_text(full_text):
def parse_subject(text):
if ('[PROJECT OR SERVICE NAME]' not in text) and ('[DESCRIPTION]' not in text) and ('[CONTRACT NUMBER]' not in text):
return text


def calculate_current_meetings(found_events, now=datetime.now(app_timezone)):
'''
Accept a queryset of events that started in the last six hours, or start
in the next five minutes, and determine which is in progress, by checking
Legistar for an "In Progress" indicator.

If there are meetings scheduled concurrently, and one is a board meeting,
show the board meeting first.

Based on a spreadsheet of average meeting times from Metro, we assume
meetings last at least 55 minutes. If any found events started less than
55 minutes ago, display them as current if the previous meeting has
ended. Otherwise, check Legistar for the "In Progress" indicator.

This method should always return a queryset.
'''
fifty_five_minutes_ago = now - timedelta(minutes=55)

# '.annotate' adds a field called 'val', which contains a boolean – we order
# in reverse, since False comes before True, to show board meetings first.
found_events = found_events.annotate(val=RawSQL("name like %s", ('%Board Meeting%',)))\
.order_by('-val')

earliest_start = found_events.earliest('start_time').start_time
latest_start = found_events.latest('start_time').start_time

if found_events.filter(start_time__gte=fifty_five_minutes_ago):
previous_meeting = found_events.filter(start_time__gte=fifty_five_minutes_ago)\
.last()\
.get_previous_by_start_time()

if legistar_meeting_progress(previous_meeting):
return LAMetroEvent.objects.filter(ocd_id=previous_meeting.ocd_id)

return found_events.filter(start_time__gte=fifty_five_minutes_ago)

elif earliest_start == latest_start:
# There is one event object, or there are multiple events scheduled to
# happen at the same time.
#
# n.b., In reality, concurrently scheduled events happen one after the
# other, but we want the current meeting display to line up with the
# scheduled events (i.e., if it's 9:15, show all of the events slated
# for 9, even if only one is technically in progress).
for event in found_events:
if legistar_meeting_progress(event):
return found_events

else:
for event in found_events:
if legistar_meeting_progress(event):
return LAMetroEvent.objects.filter(ocd_id=event.ocd_id)

return LAMetroEvent.objects.none()


def legistar_meeting_progress(event):
'''
This function helps determine the status of a meeting (i.e., is it 'In progess'?).

Granicus provides a list of current events (video ID only) on http://metro.granicus.com/running_events.php.
We get that ID and then check if the ID matches that of the event in question.
'''
organization_name = EventParticipant.objects.get(event_id=event.ocd_id).entity_name.strip()
# The strip handles cases where Metro admin added a trailing whitespace to the participant name, e.g., https://ocd.datamade.us/ocd-event/d78836eb-485f-4f5f-b0ce-f89ceaa66d6f/
try:
organization_detail_url = Organization.objects.get(name=organization_name).source_url
except Organization.DoesNotExist:
return False

# Get video ID from Grancius, if one exists.
running_events = requests.get("http://metro.granicus.com/running_events.php")
if running_events.json():
event_id = running_events.json()[0]
else:
return False

organization_detail = requests.get(organization_detail_url)
if event_id in organization_detail.text:
return True

return False
3 changes: 2 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@ def build(self, **kwargs):
'ocd_updated_at': '2017-05-27 11:10:46.574-05',
'name': 'System Safety, Security and Operations Committee',
'start_time': '2017-05-18 12:15:00-05',
'updated_at': '2017-05-17 11:06:47.1853'
'updated_at': '2017-05-17 11:06:47.1853',
'slug': uuid4(),
}

event_info.update(kwargs)
Expand Down
Loading