Skip to content

Commit

Permalink
Merge branch 'dev'
Browse files Browse the repository at this point in the history
* dev:
  update changelog, 2.4.1 notes
  libraryh3lp chat widget
  accessibility - label for search input
  fix hamburger icon aspect ratio, closes #93
  darken link color for accessibility, ref #93
  Instagram API update. Squashed commit.
  • Loading branch information
phette23 committed Mar 27, 2020
2 parents f17a55e + 518f881 commit 5889acb
Show file tree
Hide file tree
Showing 12 changed files with 165 additions and 51 deletions.
13 changes: 13 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# Changelog

## 2.4.1

**2020-03-27** — Another somewhat rushed release, to get our Libraryh3lp chat presence on the website.

### Features

- [#96](https://github.com/cca/libraries_wagtail/issues/96) Libraryh3lp chat tab appears on every page when we're signed in

### Bugfixes

- [#97](https://github.com/cca/libraries_wagtail/issues/97) update Instagram APIs to use new Facebook graph ones, new way of obtaining OAuth access tokens and different data structure
- [#93](https://github.com/cca/libraries_wagtail/issues/93) fix a variety of accessibility issues identified by audits (link color contrast, search input label, aspect ratio of hamburger icon)

## 2.4.0

**2020-03-13** - Emergency update to highlight teaching & learning online resources. Update to Wagtail 2.8, update other dependencies.
Expand Down
88 changes: 76 additions & 12 deletions libraries/instagram/api.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
import json
import logging
import re
import requests

from django.conf import settings

from .models import InstagramOAuthToken

# these functions will be used inside management scripts exclusively
logger = logging.getLogger('mgmt_cmd.script')


# @me -> <a href=link>@me</a> etc.
def linkify_text(text):
html = text
username_regex = r"(^|\s)(@[a-zA-Z0-9._]+)"
hashtag_regex = r"(^|\s)(#[a-zA-Z0-9]+)"
hashtag_regex = r"(^|\s)(#[a-zA-Z0-9_]+)"
ig_url = 'https://www.instagram.com/'


Expand All @@ -32,34 +38,92 @@ def replace_hashtag(match):
def get_instagram():
# just grab the latest OAuth token we have
token = InstagramOAuthToken.objects.last().token
url = 'https://api.instagram.com/v1/users/self/media/recent?count=1&access_token=' + token
url = 'https://graph.instagram.com/me/media?fields=id,caption,media_url,permalink,thumbnail_url,username&access_token=' + token
response = requests.get(url)
insta = json.loads(response.text)
insta = response.json()

if 'data' in insta:
gram = insta['data'][0]
text = gram['caption']['text']
text = gram['caption']

output = {
# link hashtags & usernames as they'd appear on IG itself
'html': linkify_text(text),
'id': gram['id'],
'image': gram['images']['low_resolution']['url'],
'image': gram['media_url'],
'text': text,
# we should already know this but just for ease of use
'username': gram['user']['username'],
'username': gram['username'],
}

elif 'meta' in insta:
elif 'error' in insta:
output = {
'error_type': insta['meta']['error_type'],
'error_message': insta['meta']['error_message']
'error_type': insta['error']['type'],
'error_message': insta['error']['message']
}

else:
output = {
'error_type': 'GenericError',
'error_message': 'No "meta" object containing an error type or message was present in the Instagram API response. This likely means a network connection problem or that Instagram changed the structure of their error messages.'
'error_message': 'No "error" object containing an error type or message was present in the Instagram API response. This likely means a network connection problem or that Instagram changed the structure of their error messages.'
}

return output


def get_token_from_code(code):
""" Turn a code from the app's redirect URI into a long-lived OAuth access token.
Parameters
----------
code : str
the "code" parameter in the app's redirect URI, DO NOT include the final two
"#_" characters
Returns
-------
boolean
True if token was successfully obtained, False if an error occurred.
"""
if len(code) == 0:
logger.info('No response code provided.')
return False

data = {
"client_id": settings.INSTAGRAM_APP_ID,
"client_secret": settings.INSTAGRAM_APP_SECRET,
"code": code,
"grant_type": "authorization_code",
"redirect_uri": settings.INSTAGRAM_REDIRECT_URI
}
logger.info('obtaining short-lived Instagram access token')
response = requests.post('https://api.instagram.com/oauth/access_token', data=data)
shortlived_token = response.json().get("access_token")
if not shortlived_token:
logger.error('Failed to acquire shortlived access token. Response JSON: {}'.format(response.json()))
return False

# https://developers.facebook.com/docs/instagram-basic-display-api/reference/access_token
# exchange this worthless shortlived token for a long-lived one
# Facebook is GREAT at API design, by the way, really love their work
logger.info('obtaining long-lived Instagram access token')
ll_response = requests.get('https://graph.instagram.com/access_token?grant_type=ig_exchange_token&client_secret={}&access_token={}'.format(settings.INSTAGRAM_APP_SECRET, shortlived_token))
token = ll_response.json().get("access_token")

if token:
InstagramOAuthToken.objects.create(token=token)
return True
logger.error('Failed to acquire long-lived OAuth token. Response JSON: {}'.format(response.json()))
return False


def refresh_token(token):
""" refresh Instagram long-lived access token
where the word "refresh" means "replace", it is not the same token """
response = requests.get('https://graph.instagram.com/refresh_access_token?grant_type=ig_refresh_token&access_token={}'.format(token))
new_token = response.json().get("access_token")
if new_token:
InstagramOAuthToken.objects.create(token=new_token)
logger.info('Successfully refreshed long-lived Instagram access token.')
return new_token
logger.critical('Unable to refresh long-lived Instagram access token. Response JSON: {}'.format(response.json()))
return None
25 changes: 12 additions & 13 deletions libraries/instagram/management/commands/get_oauth_token.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,27 @@

from django.core.management.base import BaseCommand, CommandError
from django.conf import settings
import requests

from instagram.models import InstagramOAuthToken
from instagram.api import get_token_from_code

logger = logging.getLogger('mgmt_cmd.script')


class Command(BaseCommand):
help = "this will open an approval in a browser, then take you to a redirect URI with an 'access_token' parameter in the URL."
help = "this will open an approval in a browser, then take you to a redirect URI with a 'code' parameter in the URL. It will use that code to obtain a shortlived access token and then exchange that in turn for a longlived access token."

def handle(self, *args, **options):
if (not hasattr(settings, 'INSTAGRAM_CLIENT_ID') or
if (not hasattr(settings, 'INSTAGRAM_APP_ID') or
not hasattr(settings, 'INSTAGRAM_APP_SECRET') or
not hasattr(settings, 'INSTAGRAM_REDIRECT_URI')):
logger.error('Need both an INSTAGRAM_CLIENT_ID & INSTAGRAM_REDIRECT_URI in settings.')
logger.error('Need INSTAGRAM_APP_ID, INSTAGRAM_APP_SECRET, & INSTAGRAM_REDIRECT_URI in settings.')
exit(1)
else:
self.stdout.write('Visit https://api.instagram.com/oauth/authorize/?client_id={}&redirect_uri={}&response_type=token in a web browser, accept the prompt, then copy the OAuth token out of the URL that you are redirected to.'.format(settings.INSTAGRAM_CLIENT_ID, settings.INSTAGRAM_REDIRECT_URI))
token = input('OAuth token:')

if len(token) > 0:
InstagramOAuthToken.objects.create(
token=token,
)
logger.info('Added a new Instagram OAuth token.')
self.stdout.write('Visit https://api.instagram.com/oauth/authorize?client_id={}&redirect_uri={}&scope=user_profile,user_media&response_type=code in a web browser, accept the prompt, then copy the code out of the URL that you are redirected to.'.format(settings.INSTAGRAM_APP_ID, settings.INSTAGRAM_REDIRECT_URI))
code = input('Response code (do not include "#_" at end):')
if get_token_from_code(code):
logger.info('Added a new Instagram access token.')
else:
self.stdout.write('No Instagram OAuth token was provided.')
logger.info('Failed to acquire Instagram access token.')
exit(1)
34 changes: 19 additions & 15 deletions libraries/instagram/management/commands/instagram.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
from datetime import datetime, timedelta
import logging

from django.core.management.base import BaseCommand
from django.conf import settings

from instagram.api import get_instagram
from instagram.api import get_instagram, refresh_token
from instagram.models import Instagram, InstagramOAuthToken

logger = logging.getLogger('mgmt_cmd.script')
Expand All @@ -17,29 +18,32 @@ def handle(self, *args, **options):
logger.error('No Instagram OAuth tokens in database, run `python manage.py get_oauth_token` and follow the instructions to add one.')
exit(1)
else:
otoken = InstagramOAuthToken.objects.last()
# if the long-lived access token (lasts 60 days) is within 3 days of expiring, refresh it
# DB is timezone-aware while now() is naive so need to normalize them
if otoken.date_added.replace(tzinfo=None) - datetime.now() > timedelta(57, 0, 0):
logger.info('Instagram OAuth token is 57 days old, refreshing it...')
refresh_token(otoken.token)

# returns dict in form { html, image, text, username }
insta = get_instagram()

# did we get an error?
if insta.get('error_type') is not None:
logger.critical('Unable to retrieved latest Instagram. IG Error Type: "{0}". Message: "{1}"'.format(insta['error_type'], insta['error_message']))
exit(1)

# do we already have this one?
elif len(Instagram.objects.filter(ig_id=insta['id'])) > 0:
logger.info('No new Instagram posts; we already have the most recent one.')
exit(0)

elif 'html' in insta:
# new Instagram from API response
Instagram.objects.create(
text=insta['text'],
html=insta['html'],
ig_id=insta['id'],
image=insta['image'],
username=insta['username'],
)

logger.info('Latest Instagram retrieved successfully: "{0}"'.format(insta['text']))

else:
logger.critical('Unable to retrieved latest Instagram. IG Error Type: "{0}". Message: "{1}"'.format(insta['error_type'], insta['error_message']))
# new Instagram from API response
Instagram.objects.create(
text=insta['text'],
html=insta['html'],
ig_id=insta['id'],
image=insta['image'],
username=insta['username'],
)
logger.info('Latest Instagram retrieved successfully: "{0}"'.format(insta['text']))
18 changes: 18 additions & 0 deletions libraries/instagram/migrations/0005_insta_token.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 2.2.11 on 2020-03-25 22:59

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('instagram', '0004_insta_url'),
]

operations = [
migrations.AlterField(
model_name='instagramoauthtoken',
name='token',
field=models.CharField(max_length=300),
),
]
2 changes: 1 addition & 1 deletion libraries/instagram/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ def __str__(self):

class InstagramOAuthToken(models.Model):
date_added = models.DateTimeField(auto_now=True)
token = models.CharField(max_length=51)
token = models.CharField(max_length=300)

def __str__(self):
return self.token
18 changes: 11 additions & 7 deletions libraries/instagram/readme.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
# Instagram

Pulls the latest Instagram post for an account, which is then used on the home page. This uses the `v1/users/self/media/recent` API ([dox](https://www.instagram.com/developer/endpoints/users/#get_users_media_recent_self)) and merely grabs the latest post available.
Pulls the latest Instagram post for an account, which is then used on the home page. This uses the Instagram Basic Display API ([dox](https://developers.facebook.com/docs/instagram-basic-display-api/getting-started)) and merely grabs the latest post available.

Configuration:
Configuration ([these steps are a good outline](https://developers.facebook.com/docs/instagram-basic-display-api/getting-started)):

- create an [Instagram API client](https://www.instagram.com/developer/clients/manage/) so you can get a client ID and set a redirect_uri
- add both `INSTAGRAM_CLIENT_ID` and `INSTAGRAM_REDIRECT_URI` to your settings
- run the management command, `python manage.py get_oauth_token`, copy the "access_token" parameter out of the redirect URI, then paste that back into the command prompt to save it to the database
- create a Facebook app on https://developers.facebook.com
- add the **Instagram** product, select "Basic Display" > **Create new app**
- Fill out all the fields, though we won't use many of them. We don't have a script that programmatically uses the redirect URI; I just manually copy the code out of the URL. Note that redirect URIs apparently cannot be a straight domain like http://example.com (Facebook will append a slash like `.com/` without telling you).
- add an "Instagram Test User" for the Instagram account you want to display
- add the app's `INSTAGRAM_CLIENT_ID`, `INSTAGRAM_CLIENT_SECRET`, and `INSTAGRAM_REDIRECT_URI` to your settings
- run the management command, `python manage.py get_oauth_token`, copy the "code" parameter out of the redirect URI (**IMPORTANT**: remove the `#\_` at the end), then paste that back into the command prompt
- the script will complete a couple steps to get an OAuth access token
- now when you run `python manage.py instagram` the latest gram is saved to the database
- set up a cron job to run the management command above on a schedule

See IG's [authentication](https://www.instagram.com/developer/authentication/) page for more. I couldn't think of an elegant way to automate renewing the token without human intervention—someone has to physically click a button on Instagram to trigger the redirect. For now, the best we can do is log an error when the OAuth token is out of date, then use `get_oauth_token` to get a new one.
This whole setup process should only need to run once. Once we obtain an access token it lasts for sixty days and can be [exchanged for a fresh token](https://developers.facebook.com/docs/instagram-basic-display-api/guides/long-lived-access-tokens) which also lasts that long, meaning that the app can keep checking the expiration date and refreshing its token during a routine `python manage.py instagram` cron job.

Previously, we attempted to use the undocumented https://www.instagram.com/ccalibraries/media/ and https://www.instagram.com/ccalibraries/?__a=1 URLs which returned JSON data about our Instagram account. But after multiple unannounced changes to the structure of this data, it was clear that using the Instagram API was a better decision. Unfortunately, Instagram is about to migrate to the Instagram Graph API and it sounds like it's only available for "business accounts" so it's unclear how easily we'll be able to do the same thing with the new API. But the one we're using currently should be functional until "early 2020".
Previously, we attempted to use the undocumented https://www.instagram.com/ccalibraries/media/ and https://www.instagram.com/ccalibraries/?__a=1 URLs which returned JSON data about our Instagram account. But after multiple unannounced changes to the structure of this data, it was clear that using the (now defunct) Instagram API was a better decision. Unfortunately, Instagram then migrated to the Instagram Graph API which required us to rewrite the whole authentication portion of this app for a second time.
Binary file modified libraries/libraries/static/images/hamburger-icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 12 additions & 0 deletions libraries/libraries/static/js/src/librarian-chat.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// use libraryh3lp Presence API https://dev.libraryh3lp.com/presence.html
fetch('https://libraryh3lp.com/presence/jid/cca-libraries-queue/chat.libraryh3lp.com/text')
.then(resp => resp.text())
.then(status => {
// only display if we are signed in
if (status == 'available' || status == 'chat') {
$('body').append('<div class="needs-js">')
var x = document.createElement("script"); x.type = "text/javascript"; x.async = true;
x.src = (document.location.protocol === "https:" ? "https://" : "http://") + "libraryh3lp.com/js/libraryh3lp.js?13843";
var y = document.getElementsByTagName("script")[0]; y.parentNode.insertBefore(x, y);
}
})
4 changes: 2 additions & 2 deletions libraries/libraries/static/scss/components/misc/_link.scss
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,13 @@
// scss-lint:disable QualifyingElement
a[href] {
border-bottom: 2px solid transparent;
color: $lightblue;
color: $ccaBlue;
text-decoration: none;
transition: border .3s ease;
}

a[href]:hover {
border-bottom-color: $lightblue;
border-bottom-color: $ccaBlue;
}
// scss-lint:enable QualifyingElement
}
1 change: 0 additions & 1 deletion libraries/libraries/static/scss/settings/_colors.scss
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ $white: white();

$grey: rgba(245, 245, 245, 1);
$blue: rgba(0, 118, 160, 1);
$lightblue: rgba(0, 162, 206, 1);
$purple: purple();
$green: rgba(183, 191, 16, 1);
$turquoise: rgba(23, 191, 179, 1);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<div class="search-input">
<input
aria-label="Search"
class="search-input__input"
name="q"
placeholder="Enter a search term..."
Expand Down

0 comments on commit 5889acb

Please sign in to comment.