├── instagram_api ├── migrations │ ├── __init__.py │ ├── 0002_user_follows_count.py │ ├── 0015_auto_20160711_1646.py │ ├── 0006_media_filter.py │ ├── 0014_auto_20160622_1538.py │ ├── 0003_user_is_private.py │ ├── 0010_auto_20160212_1602.py │ ├── 0008_auto_20160212_1345.py │ ├── 0009_auto_20160212_1454.py │ ├── 0005_auto_20160212_0204.py │ ├── 0013_auto_20160607_1602.py │ ├── 0007_auto_20160212_0346.py │ ├── 0004_location.py │ ├── 0012_auto_20160215_0123.py │ ├── 0011_auto_20160213_0338.py │ └── 0001_initial.py ├── __init__.py ├── admin.py ├── fields.py ├── factories.py ├── api.py ├── graphql.py ├── decorators.py ├── tests.py └── models.py ├── MANIFEST.in ├── settings_test.py ├── travis_django_version.py ├── .coveragerc ├── .travis.yml ├── setup.py ├── LICENSE ├── README.md └── quicktest.py /instagram_api/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /instagram_api/__init__.py: -------------------------------------------------------------------------------- 1 | VERSION = (0, 3, 7) 2 | __version__ = '.'.join(map(str, VERSION)) 3 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include LICENSE 3 | include MANIFEST.in 4 | include quicktest.py 5 | include settings_test.py 6 | recursive-include instagram_api * 7 | -------------------------------------------------------------------------------- /settings_test.py: -------------------------------------------------------------------------------- 1 | INSTALLED_APPS = ( 2 | 'm2m_history', 3 | ) 4 | SOCIAL_API_TOKENS_STORAGES = [] 5 | SOCIAL_API_INSTAGRAM_CLIENT_ID = 'fac34adbc6fd4f56803ec100234bf682' 6 | SOCIAL_API_INSTAGRAM_CLIENT_SECRET = '84a6a4732d31441d8794fd0e9cf6fe01' 7 | -------------------------------------------------------------------------------- /travis_django_version.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import sys 3 | 4 | version = sys.argv[1] 5 | if version.startswith('http'): 6 | print(version) 7 | else: 8 | next_version = float(version) + 0.1 9 | print('Django>=%s,<%.1f' % (version, next_version)) -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = 3 | *migrations* 4 | *admin.py 5 | 6 | [report] 7 | exclude_lines = 8 | def __repr__ 9 | def __unicode__ 10 | def parse_args 11 | pragma: no cover 12 | raise NotImplementedError 13 | if __name__ == .__main__.: -------------------------------------------------------------------------------- /instagram_api/migrations/0002_user_follows_count.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('instagram_api', '0001_initial'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='user', 16 | name='follows_count', 17 | field=models.PositiveIntegerField(null=True), 18 | preserve_default=True, 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /instagram_api/migrations/0015_auto_20160711_1646.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('instagram_api', '0014_auto_20160622_1538'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='user', 16 | name='full_name', 17 | field=models.CharField(max_length=80), 18 | preserve_default=True, 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /instagram_api/migrations/0006_media_filter.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('instagram_api', '0005_auto_20160212_0204'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='media', 16 | name='filter', 17 | field=models.CharField(default='', max_length=40), 18 | preserve_default=False, 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /instagram_api/migrations/0014_auto_20160622_1538.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('instagram_api', '0013_auto_20160607_1602'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='user', 16 | name='username', 17 | field=models.CharField(max_length=30, db_index=True), 18 | preserve_default=True, 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /instagram_api/migrations/0003_user_is_private.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('instagram_api', '0002_user_follows_count'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='user', 16 | name='is_private', 17 | field=models.NullBooleanField(verbose_name=b'Account is private'), 18 | preserve_default=True, 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /instagram_api/migrations/0010_auto_20160212_1602.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | import m2m_history.fields 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('instagram_api', '0009_auto_20160212_1454'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name='user', 17 | name='followers', 18 | field=m2m_history.fields.ManyToManyHistoryField(related_name='follows', to='instagram_api.User'), 19 | preserve_default=True, 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /instagram_api/migrations/0008_auto_20160212_1345.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('instagram_api', '0007_auto_20160212_0346'), 11 | ] 12 | 13 | operations = [ 14 | migrations.RemoveField( 15 | model_name='location', 16 | name='street_address', 17 | ), 18 | migrations.AlterField( 19 | model_name='tag', 20 | name='name', 21 | field=models.CharField(unique=True, max_length=50), 22 | preserve_default=True, 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /instagram_api/migrations/0009_auto_20160212_1454.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('instagram_api', '0008_auto_20160212_1345'), 11 | ] 12 | 13 | operations = [ 14 | migrations.RemoveField( 15 | model_name='media', 16 | name='locations', 17 | ), 18 | migrations.AddField( 19 | model_name='media', 20 | name='location', 21 | field=models.ForeignKey(related_name='media_feed', to='instagram_api.Location', null=True), 22 | preserve_default=True, 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /instagram_api/migrations/0005_auto_20160212_0204.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('instagram_api', '0004_location'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='location', 16 | name='latitude', 17 | field=models.FloatField(null=True), 18 | preserve_default=True, 19 | ), 20 | migrations.AlterField( 21 | model_name='location', 22 | name='longitude', 23 | field=models.FloatField(null=True), 24 | preserve_default=True, 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /instagram_api/migrations/0013_auto_20160607_1602.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('instagram_api', '0012_auto_20160215_0123'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='media', 16 | name='comments_count', 17 | field=models.PositiveIntegerField(default=0, null=True), 18 | preserve_default=True, 19 | ), 20 | migrations.AlterField( 21 | model_name='media', 22 | name='likes_count', 23 | field=models.PositiveIntegerField(default=0, null=True), 24 | preserve_default=True, 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "2.7" 4 | # - "3.4" 5 | env: 6 | - DJANGO=1.7 DB=postgres 7 | - DJANGO=1.8 DB=postgres 8 | # - DJANGO=1.9 DB=postgres 9 | before_script: 10 | - mysql -e 'create database django;' 11 | - psql -c 'create database django;' -U postgres 12 | install: 13 | - if [[ $DB == mysql ]]; then pip install mysql-python; fi 14 | - if [[ $DB == postgres ]]; then pip install psycopg2; fi 15 | - DJANGO_VER=$(./travis_django_version.py $DJANGO) 16 | - pip install $DJANGO_VER 17 | - pip install simplejson 18 | - pip install factory_boy 19 | - pip install coveralls 20 | - pip install mock 21 | - pip install . 22 | script: 23 | - django-admin.py --version 24 | - coverage run --source=instagram_api quicktest.py instagram_api 25 | after_success: 26 | - coveralls 27 | notifications: 28 | email: 29 | recipients: 30 | - ramusus@gmail.com 31 | on_success: change 32 | on_failure: change 33 | -------------------------------------------------------------------------------- /instagram_api/migrations/0007_auto_20160212_0346.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('instagram_api', '0006_media_filter'), 11 | ] 12 | 13 | operations = [ 14 | migrations.RemoveField( 15 | model_name='location', 16 | name='media_feed', 17 | ), 18 | migrations.RemoveField( 19 | model_name='tag', 20 | name='media_feed', 21 | ), 22 | migrations.AddField( 23 | model_name='media', 24 | name='locations', 25 | field=models.ManyToManyField(related_name='media_feed', to='instagram_api.Location'), 26 | preserve_default=True, 27 | ), 28 | migrations.AddField( 29 | model_name='media', 30 | name='tags', 31 | field=models.ManyToManyField(related_name='media_feed', to='instagram_api.Tag'), 32 | preserve_default=True, 33 | ), 34 | ] 35 | -------------------------------------------------------------------------------- /instagram_api/migrations/0004_location.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('instagram_api', '0003_user_is_private'), 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='Location', 16 | fields=[ 17 | ('fetched', models.DateTimeField(null=True, verbose_name='Fetched', blank=True)), 18 | ('id', models.BigIntegerField(serialize=False, primary_key=True)), 19 | ('name', models.CharField(max_length=100)), 20 | ('latitude', models.FloatField()), 21 | ('longitude', models.FloatField()), 22 | ('street_address', models.CharField(max_length=100)), 23 | ('media_count', models.PositiveIntegerField(null=True)), 24 | ('media_feed', models.ManyToManyField(related_name='locations', to='instagram_api.Media')), 25 | ], 26 | options={ 27 | 'abstract': False, 28 | }, 29 | bases=(models.Model,), 30 | ), 31 | ] 32 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from setuptools import find_packages, setup 4 | 5 | setup( 6 | name='django-instagram-api', 7 | version=__import__('instagram_api').__version__, 8 | description='Django implementation for instagram API', 9 | long_description=open('README.md').read(), 10 | author='krupin.dv', 11 | author_email='krupin.dv19@gmail.com', 12 | url='https://github.com/ramusus/django-instagram-api', 13 | download_url='http://pypi.python.org/pypi/django-instagram-api', 14 | license='BSD', 15 | packages=find_packages(), 16 | include_package_data=True, 17 | zip_safe=False, # because we're including media that Django needs 18 | install_requires=[ 19 | 'requests>=2.5.3', 20 | 'python-instagram==1.3.0', 21 | 'django-social-api>=0.0.3', 22 | 'django-m2m-history>=0.2.2', 23 | ], 24 | classifiers=[ 25 | 'Development Status :: 4 - Beta', 26 | 'Environment :: Web Environment', 27 | 'Intended Audience :: Developers', 28 | 'License :: OSI Approved :: BSD License', 29 | 'Operating System :: OS Independent', 30 | 'Programming Language :: Python', 31 | 'Topic :: Software Development :: Libraries :: Python Modules', 32 | ], 33 | ) 34 | -------------------------------------------------------------------------------- /instagram_api/migrations/0012_auto_20160215_0123.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('instagram_api', '0011_auto_20160213_0338'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='user', 16 | name='followers_count', 17 | field=models.PositiveIntegerField(null=True, db_index=True), 18 | preserve_default=True, 19 | ), 20 | migrations.AlterField( 21 | model_name='user', 22 | name='follows_count', 23 | field=models.PositiveIntegerField(null=True, db_index=True), 24 | preserve_default=True, 25 | ), 26 | migrations.AlterField( 27 | model_name='user', 28 | name='is_private', 29 | field=models.NullBooleanField(db_index=True, verbose_name=b'Account is private'), 30 | preserve_default=True, 31 | ), 32 | migrations.AlterField( 33 | model_name='user', 34 | name='media_count', 35 | field=models.PositiveIntegerField(null=True, db_index=True), 36 | preserve_default=True, 37 | ), 38 | ] 39 | -------------------------------------------------------------------------------- /instagram_api/admin.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django.contrib import admin 3 | from models import User, Media, Comment 4 | 5 | 6 | class AllFieldsReadOnly(admin.ModelAdmin): 7 | def get_readonly_fields(self, request, obj=None): 8 | if obj: 9 | return [field.name for field in obj._meta.fields] 10 | return [] 11 | 12 | 13 | class UserAdmin(AllFieldsReadOnly): 14 | def instagram_link(self, obj): 15 | return u'%s' % (obj.instagram_link, obj.username) 16 | 17 | instagram_link.allow_tags = True 18 | 19 | list_display = ['id', 'full_name', 'instagram_link'] 20 | list_filter = ('is_private',) 21 | search_fields = ('username', 'full_name') 22 | 23 | exclude = ('followers',) 24 | 25 | 26 | class MediaAdmin(AllFieldsReadOnly): 27 | def instagram_link(self, obj): 28 | return u'%s' % (obj.link, obj.link) 29 | 30 | instagram_link.allow_tags = True 31 | 32 | list_display = ['id', 'user', 'caption', 'created_time', 'instagram_link'] 33 | search_fields = ('caption',) 34 | 35 | 36 | class CommentAdmin(AllFieldsReadOnly): 37 | list_display = ['id', 'user', 'media', 'text', 'created_time'] 38 | search_fields = ('text',) 39 | 40 | 41 | class TagAdmin(AllFieldsReadOnly): 42 | list_display = ['id', 'name', 'media_count', 'created_time'] 43 | search_fields = ('name',) 44 | 45 | 46 | admin.site.register(User, UserAdmin) 47 | admin.site.register(Media, MediaAdmin) 48 | admin.site.register(Comment, CommentAdmin) 49 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015, krupin.dv and contributors. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in the 12 | documentation and/or other materials provided with the distribution. 13 | 14 | 3. Neither the name of Django nor the names of its contributors may be used 15 | to endorse or promote products derived from this software without 16 | specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Django Instagram API 2 | ==================== 3 | 4 | [![Build Status](https://travis-ci.org/ramusus/django-instagram-api.png?branch=master)](https://travis-ci.org/ramusus/django-instagram-api) [![Coverage Status](https://coveralls.io/repos/ramusus/django-instagram-api/badge.png?branch=master)](https://coveralls.io/r/ramusus/django-instagram-api) 5 | 6 | Application for interaction with Instagram API objects using Django ORM 7 | 8 | Installation 9 | ------------ 10 | 11 | pip install django-instagram-api 12 | 13 | Add into `settings.py` lines: 14 | 15 | INSTALLED_APPS = ( 16 | ... 17 | 'oauth_tokens', 18 | 'm2m_history', 19 | 'taggit', 20 | 'instagram_api', 21 | ) 22 | 23 | # oauth-tokens settings 24 | OAUTH_TOKENS_HISTORY = True # to keep in DB expired access tokens 25 | OAUTH_TOKENS_TWITTER_CLIENT_ID = '' # application ID 26 | OAUTH_TOKENS_TWITTER_CLIENT_SECRET = '' # application secret key 27 | OAUTH_TOKENS_TWITTER_USERNAME = '' # user login 28 | OAUTH_TOKENS_TWITTER_PASSWORD = '' # user password 29 | 30 | Usage examples 31 | -------------- 32 | 33 | ### Simple API request 34 | 35 | >>>from instagram_api.models import User, Media 36 | >>>u = User.remote.fetch(237074561) 37 | >>>print u 38 | tnt_online 39 | 40 | >>>followers = u.fetch_followers() 41 | 42 | >>>medias = u.fetch_recent_media() 43 | >>>print medias 44 | [, , ...] 45 | 46 | 47 | >>>m = Media.remote.fetch('937539904871536462_190931988') 48 | >>>comments = m.fetch_comments() 49 | >>>likes = m.fetch_likes() 50 | -------------------------------------------------------------------------------- /instagram_api/fields.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django.core import validators 3 | from django.db import models 4 | from django.utils.translation import ugettext_lazy as _ 5 | import re 6 | #from annoying.fields import JSONField 7 | #from picklefield.fields import PickledObjectField 8 | 9 | 10 | 11 | class PositiveSmallIntegerRangeField(models.PositiveSmallIntegerField): 12 | ''' 13 | Range integer field with max_value and min_value properties 14 | from here http://stackoverflow.com/questions/849142/how-to-limit-the-maximum-value-of-a-numeric-field-in-a-django-model 15 | ''' 16 | def __init__(self, verbose_name=None, name=None, min_value=None, max_value=None, **kwargs): 17 | self.min_value, self.max_value = min_value, max_value 18 | models.IntegerField.__init__(self, verbose_name, name, **kwargs) 19 | 20 | def formfield(self, **kwargs): 21 | defaults = {'min_value': self.min_value, 'max_value':self.max_value} 22 | defaults.update(kwargs) 23 | return super(PositiveSmallIntegerRangeField, self).formfield(**defaults) 24 | 25 | comma_separated_string_list_re = re.compile(u'^(?u)[\w#\[\], ]+$') 26 | validate_comma_separated_string_list = validators.RegexValidator(comma_separated_string_list_re, _(u'Enter values separated by commas.'), 'invalid') 27 | 28 | class CommaSeparatedCharField(models.CharField): 29 | ''' 30 | Field for comma-separated strings 31 | TODO: added max_number validator 32 | ''' 33 | default_validators = [validate_comma_separated_string_list] 34 | description = _("Comma-separated strings") 35 | 36 | def formfield(self, **kwargs): 37 | defaults = { 38 | 'error_messages': { 39 | 'invalid': _(u'Enter values separated by commas.'), 40 | } 41 | } 42 | defaults.update(kwargs) 43 | return super(CommaSeparatedCharField, self).formfield(**defaults) 44 | 45 | try: 46 | from south.modelsinspector import add_introspection_rules 47 | add_introspection_rules([], ["^facebook_api\.fields"]) 48 | add_introspection_rules([], ["^annoying\.fields"]) 49 | except ImportError: 50 | pass 51 | -------------------------------------------------------------------------------- /instagram_api/migrations/0011_auto_20160213_0338.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('instagram_api', '0010_auto_20160212_1602'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='media', 16 | name='link', 17 | field=models.URLField(max_length=68), 18 | preserve_default=True, 19 | ), 20 | migrations.AlterField( 21 | model_name='media', 22 | name='remote_id', 23 | field=models.CharField(unique=True, max_length=30), 24 | preserve_default=True, 25 | ), 26 | migrations.AlterField( 27 | model_name='media', 28 | name='type', 29 | field=models.CharField(max_length=5), 30 | preserve_default=True, 31 | ), 32 | migrations.AlterField( 33 | model_name='media', 34 | name='video_low_bandwidth', 35 | field=models.URLField(max_length=130), 36 | preserve_default=True, 37 | ), 38 | migrations.AlterField( 39 | model_name='media', 40 | name='video_low_resolution', 41 | field=models.URLField(max_length=130), 42 | preserve_default=True, 43 | ), 44 | migrations.AlterField( 45 | model_name='media', 46 | name='video_standard_resolution', 47 | field=models.URLField(max_length=130), 48 | preserve_default=True, 49 | ), 50 | migrations.AlterField( 51 | model_name='user', 52 | name='bio', 53 | field=models.CharField(max_length=150), 54 | preserve_default=True, 55 | ), 56 | migrations.AlterField( 57 | model_name='user', 58 | name='full_name', 59 | field=models.CharField(max_length=30), 60 | preserve_default=True, 61 | ), 62 | migrations.AlterField( 63 | model_name='user', 64 | name='profile_picture', 65 | field=models.URLField(max_length=112), 66 | preserve_default=True, 67 | ), 68 | migrations.AlterField( 69 | model_name='user', 70 | name='username', 71 | field=models.CharField(unique=True, max_length=30), 72 | preserve_default=True, 73 | ), 74 | migrations.AlterField( 75 | model_name='user', 76 | name='website', 77 | field=models.URLField(max_length=150), 78 | preserve_default=True, 79 | ), 80 | ] 81 | -------------------------------------------------------------------------------- /instagram_api/factories.py: -------------------------------------------------------------------------------- 1 | import random 2 | import string 3 | 4 | import factory 5 | from django.utils import timezone 6 | 7 | from . import models 8 | 9 | 10 | class UserFactory(factory.DjangoModelFactory): 11 | 12 | id = factory.Sequence(lambda n: n + 1) 13 | username = factory.Sequence(lambda n: "".join([random.choice(string.letters) for _ in xrange(30)])) 14 | # full_name = factory.Sequence(lambda n: n) 15 | # bio = factory.Sequence(lambda n: n) 16 | 17 | followers_count = factory.LazyAttribute(lambda o: random.randint(0, 1000)) 18 | 19 | class Meta: 20 | model = models.User 21 | 22 | 23 | class MediaFactory(factory.DjangoModelFactory): 24 | 25 | remote_id = factory.Sequence(lambda n: "".join([random.choice(string.letters) for _ in xrange(30)])) 26 | user = factory.SubFactory(UserFactory) 27 | comments_count = factory.LazyAttribute(lambda o: random.randint(0, 1000)) 28 | likes_count = factory.LazyAttribute(lambda o: random.randint(0, 1000)) 29 | 30 | created_time = factory.LazyAttribute(lambda o: timezone.now()) 31 | 32 | class Meta: 33 | model = models.Media 34 | 35 | 36 | class CommentFactory(factory.DjangoModelFactory): 37 | 38 | id = factory.Sequence(lambda n: n + 1) 39 | owner = factory.SubFactory(UserFactory) 40 | user = factory.SubFactory(UserFactory) 41 | media = factory.SubFactory(MediaFactory) 42 | 43 | created_time = factory.LazyAttribute(lambda o: timezone.now()) 44 | 45 | class Meta: 46 | model = models.Comment 47 | 48 | 49 | class TagFactory(factory.DjangoModelFactory): 50 | 51 | id = factory.Sequence(lambda n: n + 1) 52 | name = factory.Sequence(lambda n: "".join([random.choice(string.letters) for _ in xrange(50)])) 53 | media_count = factory.LazyAttribute(lambda o: random.randint(0, 1000)) 54 | 55 | @factory.post_generation 56 | def media_feed(self, create, extracted, **kwargs): 57 | if not create: 58 | # Simple build, do nothing. 59 | return 60 | 61 | if extracted: 62 | # A list of groups were passed in, use them 63 | for media in extracted: 64 | self.media_feed.add(media) 65 | 66 | class Meta: 67 | model = models.Tag 68 | 69 | 70 | class LocationFactory(factory.DjangoModelFactory): 71 | 72 | id = factory.Sequence(lambda n: n + 1) 73 | name = factory.Sequence(lambda n: "".join([random.choice(string.letters) for _ in xrange(50)])) 74 | media_count = factory.LazyAttribute(lambda o: random.randint(0, 1000)) 75 | 76 | @factory.post_generation 77 | def media_feed(self, create, extracted, **kwargs): 78 | if not create: 79 | # Simple build, do nothing. 80 | return 81 | 82 | if extracted: 83 | # A list of groups were passed in, use them 84 | for media in extracted: 85 | self.media_feed.add(media) 86 | 87 | class Meta: 88 | model = models.Location 89 | -------------------------------------------------------------------------------- /instagram_api/api.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from time import sleep 3 | 4 | from django.conf import settings 5 | from instagram import InstagramAPIError as InstagramError, InstagramClientError 6 | from instagram.client import InstagramAPI 7 | from social_api.api import ApiAbstractBase, Singleton 8 | 9 | __all__ = ['get_api', ] 10 | 11 | CLIENT_ID = getattr(settings, 'SOCIAL_API_INSTAGRAM_CLIENT_ID') 12 | CLIENT_SECRET = getattr(settings, 'SOCIAL_API_INSTAGRAM_CLIENT_SECRET') 13 | 14 | log = logging.getLogger('instagram_api') 15 | 16 | 17 | @property 18 | def code(self): 19 | return self.status_code 20 | 21 | 22 | InstagramError.code = code 23 | 24 | 25 | class InstagramApi(ApiAbstractBase): 26 | __metaclass__ = Singleton 27 | 28 | provider = 'instagram' 29 | error_class = (InstagramError, InstagramClientError) 30 | 31 | def get_api(self, token): 32 | context = getattr(settings, 'SOCIAL_API_CALL_CONTEXT', None) 33 | if context and self.provider in context and context[self.provider].get('use_client_id', False) is True: 34 | kwargs = {'client_id': CLIENT_ID} 35 | else: 36 | kwargs = {'access_token': token, 'client_secret': CLIENT_SECRET} 37 | 38 | return InstagramAPI(**kwargs) 39 | 40 | def get_api_response(self, *args, **kwargs): 41 | return getattr(self.api, self.method)(*args, **kwargs) 42 | 43 | def handle_error_code_400(self, e, *args, **kwargs): 44 | # OAuthAccessTokenException-The access_token provided is invalid. 45 | if e.error_type in ['OAuthAccessTokenException', 'OAuthPermissionsException']: 46 | self.used_access_tokens += [self.api.access_token] 47 | return self.repeat_call(*args, **kwargs) 48 | raise e 49 | 50 | def handle_error_code_429(self, e, *args, **kwargs): 51 | # Rate limited-Your client is making too many request per second 52 | return self.handle_rate_limit_error(e, *args, **kwargs) 53 | 54 | def handle_error_code_500(self, e, *args, **kwargs): 55 | # InstagramClientError: (500) Unable to parse response, not valid JSON. 56 | return self.sleep_repeat_call(*args, **kwargs) 57 | 58 | def handle_error_code_502(self, e, *args, **kwargs): 59 | # InstagramClientError: (502) Unable to parse response, not valid JSON. 60 | return self.sleep_repeat_call(*args, **kwargs) 61 | 62 | def handle_error_code_503(self, e, *args, **kwargs): 63 | return self.sleep_repeat_call(*args, **kwargs) 64 | 65 | def handle_rate_limit_error(self, e, *args, **kwargs): 66 | self.used_access_tokens += [self.api.access_token] 67 | if len(self.used_access_tokens) == len(self.tokens): 68 | log.warning("All access tokens are rate limited, need to wait 600 sec") 69 | sleep(600) 70 | self.used_access_tokens = [] 71 | return self.sleep_repeat_call(*args, **kwargs) 72 | 73 | 74 | def api_call(*args, **kwargs): 75 | api = InstagramApi() 76 | api.used_access_tokens = [] 77 | return api.call(*args, **kwargs) 78 | -------------------------------------------------------------------------------- /instagram_api/graphql.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import time 3 | import simplejson as json 4 | from oauth_tokens.providers.instagram import InstagramAuthRequest 5 | 6 | 7 | class GraphQL(object): 8 | 9 | url = 'https://www.instagram.com/query/' 10 | cookies = '__utma=227057989.1129888433.1423302270.1433340455.1434559808.20; __utmc=227057989; mid=VpvY_gAEAAHrA7w3K-gGUZNO3gUn; sessionid=IGSC7c09257a24e6a71806e9f9dc952d22e2aeca0b9a471b86c84c75ffa10511a509%3Avb2zOhH21hwjUVG4UItD2ruVR09hq2tF%3A%7B%22_token_ver%22%3A2%2C%22_auth_user_id%22%3A1687258424%2C%22_token%22%3A%221687258424%3AWd6qVgJRS6fbkEhrfKM1D5xo2XM8YFHk%3Abaa3b7fd09a33cea01f659a626fc6cb738c577ca896390beb5960043ec113298%22%2C%22asns%22%3A%7B%22188.40.74.9%22%3A24940%2C%22time%22%3A1466597282%7D%2C%22_auth_user_backend%22%3A%22accounts.backends.CaseInsensitiveModelBackend%22%2C%22last_refreshed%22%3A1466597282.193233%2C%22_platform%22%3A4%7D; ig_pr=2; ig_vw=1618; csrftoken=CSRF_TOKEN; s_network=; ds_user_id=1687258424' 11 | 12 | def related_users(self, endpoint, user): 13 | req = InstagramAuthRequest() 14 | session = requests.Session() 15 | url = 'https://www.instagram.com/%s/' % user.username 16 | # response = req.authorized_request('get', url=url) 17 | response = session.get(url=url) 18 | csrf_token = req.get_csrf_token_from_content(response.content) 19 | headers = { 20 | 'Referer': url, 21 | 'X-CSRFToken': csrf_token, 22 | 'Cookie': self.cookies.replace('CSRF_TOKEN', csrf_token), 23 | } 24 | 25 | user_id = user.id 26 | limit = 1000 27 | method = 'first' 28 | args = limit 29 | next_page = True 30 | 31 | while next_page: 32 | graphql = ''' 33 | ig_user(%(user_id)s) { 34 | %(endpoint)s.%(method)s(%(args)s) { 35 | count, page_info { end_cursor, has_next_page }, 36 | nodes { id, is_verified, followed_by_viewer, requested_by_viewer, full_name, profile_pic_url, username } 37 | } 38 | }''' % locals() 39 | 40 | # response = req.authorized_request('post', url=self.url, data={'q': graphql}, headers=headers) 41 | response = session.post(url=self.url, data={'q': graphql}, headers=headers) 42 | json_response = json.loads(response.content) 43 | 44 | if json_response['status'] == 'fail' \ 45 | and json_response['message'] == 'Sorry, too many requests. Please try again later.': 46 | time.sleep(1) 47 | continue 48 | 49 | method = 'after' 50 | try: 51 | args = '%s, %s' % (json_response[endpoint]['page_info']['end_cursor'], limit) 52 | next_page = json_response[endpoint]['page_info']['has_next_page'] 53 | # print json_response[endpoint]['count'], len(json_response[endpoint]['nodes']), next_page 54 | yield json_response[endpoint]['nodes'] 55 | except KeyError: 56 | raise Exception('Unexpected response: "%s" of graphql request: "%s"' % (json_response, graphql)) 57 | -------------------------------------------------------------------------------- /instagram_api/decorators.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django.utils.functional import wraps 3 | from django.db.models.query import QuerySet 4 | 5 | try: 6 | from django.db.transaction import atomic 7 | except ImportError: 8 | from django.db.transaction import commit_on_success as atomic 9 | 10 | 11 | def opt_arguments(func): 12 | ''' 13 | Meta-decorator for ablity use decorators with optional arguments 14 | from here http://www.ellipsix.net/blog/2010/08/more-python-voodoo-optional-argument-decorators.html 15 | ''' 16 | def meta_wrapper(*args, **kwargs): 17 | if len(args) == 1 and callable(args[0]): 18 | # No arguments, this is the decorator 19 | # Set default values for the arguments 20 | return func(args[0]) 21 | else: 22 | def meta_func(inner_func): 23 | return func(inner_func, *args, **kwargs) 24 | return meta_func 25 | return meta_wrapper 26 | 27 | @opt_arguments 28 | def fetch_all(func, return_all=None, always_all=False): 29 | """ 30 | Class method decorator for fetching all items. Add parameter `all=False` for decored method. 31 | If `all` is True, method runs as many times as it returns any results. 32 | Decorator receive parameters: 33 | * callback method `return_all`. It's called with the same parameters 34 | as decored method after all itmes are fetched. 35 | * `always_all` bool - return all instances in any case of argument `all` 36 | of decorated method 37 | Usage: 38 | 39 | @fetch_all(return_all=lambda self,instance,*a,**k: instance.items.all()) 40 | def fetch_something(self, ..., *kwargs): 41 | .... 42 | """ 43 | def wrapper(self, all=False, instances_all=None, **kwargs): 44 | response = {} 45 | instances = func(self, **kwargs) 46 | if len(instances) == 2 and isinstance(instances, tuple): 47 | instances, response = instances 48 | 49 | if always_all or all: 50 | if isinstance(instances, QuerySet): 51 | if instances_all is None: 52 | instances_all = instances.none() 53 | instances_count = instances.count() 54 | if instances_count: 55 | instances_all |= instances 56 | elif isinstance(instances, list): 57 | if instances_all is None: 58 | instances_all = [] 59 | instances_count = len(instances) 60 | instances_all += instances 61 | else: 62 | raise ValueError("Wrong type of response from func %s. It should be QuerySet or list, not a %s" % (func, type(instances))) 63 | 64 | next_url = response['pagination'].get('next_url', None) 65 | if next_url: 66 | return wrapper(self, all=True, next_url=next_url, instances_all=instances_all, **kwargs) 67 | 68 | if return_all: 69 | kwargs['instances'] = instances_all 70 | return return_all(self, **kwargs) 71 | else: 72 | return instances_all 73 | 74 | else: 75 | return instances 76 | 77 | return wraps(func)(wrapper) 78 | -------------------------------------------------------------------------------- /instagram_api/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | import m2m_history.fields 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='Comment', 16 | fields=[ 17 | ('fetched', models.DateTimeField(null=True, verbose_name='Fetched', blank=True)), 18 | ('id', models.BigIntegerField(serialize=False, primary_key=True)), 19 | ('text', models.TextField()), 20 | ('created_time', models.DateTimeField()), 21 | ], 22 | options={ 23 | 'abstract': False, 24 | }, 25 | bases=(models.Model,), 26 | ), 27 | migrations.CreateModel( 28 | name='Media', 29 | fields=[ 30 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 31 | ('fetched', models.DateTimeField(null=True, verbose_name='Fetched', blank=True)), 32 | ('remote_id', models.CharField(unique=True, max_length=100)), 33 | ('caption', models.TextField(blank=True)), 34 | ('link', models.URLField(max_length=300)), 35 | ('type', models.CharField(max_length=20)), 36 | ('image_low_resolution', models.URLField()), 37 | ('image_standard_resolution', models.URLField()), 38 | ('image_thumbnail', models.URLField()), 39 | ('video_low_bandwidth', models.URLField()), 40 | ('video_low_resolution', models.URLField()), 41 | ('video_standard_resolution', models.URLField()), 42 | ('created_time', models.DateTimeField()), 43 | ('comments_count', models.PositiveIntegerField(null=True)), 44 | ('likes_count', models.PositiveIntegerField(null=True)), 45 | ], 46 | options={ 47 | 'abstract': False, 48 | }, 49 | bases=(models.Model,), 50 | ), 51 | migrations.CreateModel( 52 | name='Tag', 53 | fields=[ 54 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 55 | ('fetched', models.DateTimeField(null=True, verbose_name='Fetched', blank=True)), 56 | ('name', models.CharField(unique=True, max_length=29)), 57 | ('media_count', models.PositiveIntegerField(null=True)), 58 | ('media_feed', models.ManyToManyField(related_name='tags', to='instagram_api.Media')), 59 | ], 60 | options={ 61 | 'abstract': False, 62 | }, 63 | bases=(models.Model,), 64 | ), 65 | migrations.CreateModel( 66 | name='User', 67 | fields=[ 68 | ('fetched', models.DateTimeField(null=True, verbose_name='Fetched', blank=True)), 69 | ('id', models.BigIntegerField(serialize=False, primary_key=True)), 70 | ('username', models.CharField(unique=True, max_length=50)), 71 | ('full_name', models.CharField(max_length=255)), 72 | ('bio', models.CharField(max_length=255, verbose_name=b'BIO')), 73 | ('profile_picture', models.URLField(max_length=300)), 74 | ('website', models.URLField(max_length=300)), 75 | ('followers_count', models.PositiveIntegerField(null=True)), 76 | ('media_count', models.PositiveIntegerField(null=True)), 77 | ('followers', m2m_history.fields.ManyToManyHistoryField(to='instagram_api.User')), 78 | ], 79 | options={ 80 | 'abstract': False, 81 | }, 82 | bases=(models.Model,), 83 | ), 84 | migrations.AddField( 85 | model_name='media', 86 | name='likes_users', 87 | field=m2m_history.fields.ManyToManyHistoryField(related_name='likes_media', to='instagram_api.User'), 88 | preserve_default=True, 89 | ), 90 | migrations.AddField( 91 | model_name='media', 92 | name='user', 93 | field=models.ForeignKey(related_name='media_feed', to='instagram_api.User'), 94 | preserve_default=True, 95 | ), 96 | migrations.AddField( 97 | model_name='comment', 98 | name='media', 99 | field=models.ForeignKey(related_name='comments', to='instagram_api.Media'), 100 | preserve_default=True, 101 | ), 102 | migrations.AddField( 103 | model_name='comment', 104 | name='owner', 105 | field=models.ForeignKey(related_name='media_comments', to='instagram_api.User'), 106 | preserve_default=True, 107 | ), 108 | migrations.AddField( 109 | model_name='comment', 110 | name='user', 111 | field=models.ForeignKey(related_name='comments', to='instagram_api.User'), 112 | preserve_default=True, 113 | ), 114 | ] 115 | -------------------------------------------------------------------------------- /quicktest.py: -------------------------------------------------------------------------------- 1 | """ 2 | QuickDjangoTest module for testing in Travis CI https://travis-ci.org 3 | Changes log: 4 | * 2014-10-24 updated for compatibility with Django 1.7 5 | * 2014-11-03 different databases support: sqlite3, mysql, postgres 6 | * 2014-12-31 pep8, python 3 compatibility 7 | * 2015-02-01 Django 1.9 compatibility 8 | * 2015-02-25 updated code style 9 | * 2015-02-26 updated get_database() for Django 1.8 10 | * 2015-02-27 clean up variables 11 | """ 12 | 13 | import argparse 14 | import os 15 | import sys 16 | 17 | from django.conf import settings 18 | 19 | 20 | class QuickDjangoTest(object): 21 | 22 | """ 23 | A quick way to run the Django test suite without a fully-configured project. 24 | 25 | Example usage: 26 | 27 | >>> QuickDjangoTest('app1', 'app2') 28 | 29 | Based on a script published by Lukasz Dziedzia at: 30 | http://stackoverflow.com/questions/3841725/how-to-launch-tests-for-django-reusable-app 31 | """ 32 | DIRNAME = os.path.dirname(__file__) 33 | INSTALLED_APPS = ( 34 | 'django.contrib.auth', 35 | 'django.contrib.contenttypes', 36 | 'django.contrib.sessions', 37 | 'django.contrib.admin', 38 | ) 39 | 40 | def __init__(self, *args): 41 | self.apps = args 42 | 43 | # Call the appropriate one 44 | method = getattr(self, '_tests_%s' % self.version.replace('.', '_'), None) 45 | if method and callable(method): 46 | method() 47 | else: 48 | self._tests_old() 49 | 50 | @property 51 | def version(self): 52 | """ 53 | Figure out which version of Django's test suite we have to play with. 54 | """ 55 | from django import VERSION 56 | if VERSION[0] == 1 and VERSION[1] >= 8: 57 | return '1.8' 58 | elif VERSION[0] == 1 and VERSION[1] >= 7: 59 | return '1.7' 60 | elif VERSION[0] == 1 and VERSION[1] >= 2: 61 | return '1.2' 62 | else: 63 | return 64 | 65 | def get_database(self, version): 66 | test_db = os.environ.get('DB', 'sqlite') 67 | if test_db == 'mysql': 68 | database = { 69 | 'ENGINE': 'django.db.backends.mysql', 70 | 'NAME': 'django', 71 | 'USER': 'root', 72 | } 73 | elif test_db == 'postgres': 74 | database = { 75 | 'ENGINE': 'django.db.backends.postgresql_psycopg2', 76 | 'USER': 'postgres', 77 | 'NAME': 'django', 78 | } 79 | if version < 1.8: 80 | database['OPTIONS'] = {'autocommit': True} 81 | else: 82 | database = { 83 | 'ENGINE': 'django.db.backends.sqlite3', 84 | 'NAME': os.path.join(self.DIRNAME, 'database.db'), 85 | 'USER': '', 86 | 'PASSWORD': '', 87 | 'HOST': '', 88 | 'PORT': '', 89 | } 90 | return {'default': database} 91 | 92 | @property 93 | def custom_settings(self): 94 | """ 95 | Return custom settings from settings_test.py file 96 | :return: dict 97 | """ 98 | try: 99 | import settings_test 100 | test_settings = dict([(k, v) for k, v in settings_test.__dict__.items() if k[0] != '_']) 101 | except ImportError: 102 | test_settings = {'INSTALLED_APPS': []} 103 | return test_settings 104 | 105 | def _tests_old(self): 106 | """ 107 | Fire up the Django test suite from before version 1.2 108 | """ 109 | test_settings = self.custom_settings 110 | installed_apps = test_settings.pop('INSTALLED_APPS', ()) 111 | settings.configure( 112 | DEBUG=True, 113 | DATABASE_ENGINE='sqlite3', 114 | DATABASE_NAME=os.path.join(self.DIRNAME, 'database.db'), 115 | INSTALLED_APPS=tuple(self.INSTALLED_APPS + installed_apps + self.apps), 116 | **test_settings 117 | ) 118 | from django.test.simple import run_tests 119 | failures = run_tests(self.apps, verbosity=1) 120 | if failures: 121 | sys.exit(failures) 122 | 123 | def _tests_1_2(self): 124 | """ 125 | Fire up the Django test suite developed for version 1.2 and up 126 | """ 127 | test_settings = self.custom_settings 128 | installed_apps = test_settings.pop('INSTALLED_APPS', ()) 129 | settings.configure( 130 | DEBUG=True, 131 | DATABASES=self.get_database(1.2), 132 | INSTALLED_APPS=tuple(self.INSTALLED_APPS + installed_apps + self.apps), 133 | **test_settings 134 | ) 135 | from django.test.simple import DjangoTestSuiteRunner 136 | failures = DjangoTestSuiteRunner().run_tests(self.apps, verbosity=1) 137 | if failures: 138 | sys.exit(failures) 139 | 140 | def _tests_1_7(self): 141 | """ 142 | Fire up the Django test suite developed for version 1.7 and up 143 | """ 144 | test_settings = self.custom_settings 145 | installed_apps = test_settings.pop('INSTALLED_APPS', ()) 146 | settings.configure( 147 | DEBUG=True, 148 | DATABASES=self.get_database(1.7), 149 | MIDDLEWARE_CLASSES=('django.middleware.common.CommonMiddleware', 150 | 'django.middleware.csrf.CsrfViewMiddleware'), 151 | INSTALLED_APPS=tuple(self.INSTALLED_APPS + installed_apps + self.apps), 152 | **test_settings 153 | ) 154 | from django.test.simple import DjangoTestSuiteRunner 155 | import django 156 | django.setup() 157 | failures = DjangoTestSuiteRunner().run_tests(self.apps, verbosity=1) 158 | if failures: 159 | sys.exit(failures) 160 | 161 | def _tests_1_8(self): 162 | """ 163 | Fire up the Django test suite developed for version 1.8 and up 164 | """ 165 | test_settings = self.custom_settings 166 | installed_apps = test_settings.pop('INSTALLED_APPS', ()) 167 | settings.configure( 168 | DEBUG=True, 169 | DATABASES=self.get_database(1.8), 170 | MIDDLEWARE_CLASSES=('django.middleware.common.CommonMiddleware', 171 | 'django.middleware.csrf.CsrfViewMiddleware'), 172 | INSTALLED_APPS=tuple(self.INSTALLED_APPS + installed_apps + self.apps), 173 | **test_settings 174 | ) 175 | from django.test.runner import DiscoverRunner 176 | import django 177 | django.setup() 178 | failures = DiscoverRunner().run_tests(self.apps, verbosity=1) 179 | if failures: 180 | sys.exit(failures) 181 | 182 | 183 | if __name__ == '__main__': 184 | """ 185 | What do when the user hits this file from the shell. 186 | 187 | Example usage: 188 | 189 | $ python quicktest.py app1 app2 190 | 191 | """ 192 | parser = argparse.ArgumentParser( 193 | usage="[args]", 194 | description="Run Django tests on the provided applications." 195 | ) 196 | parser.add_argument('apps', nargs='+', type=str) 197 | QuickDjangoTest(*parser.parse_args().apps) 198 | -------------------------------------------------------------------------------- /instagram_api/tests.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from datetime import datetime 3 | 4 | from django.test import TestCase 5 | from django.conf import settings 6 | from django.utils import timezone 7 | 8 | from .factories import UserFactory, LocationFactory 9 | from .models import Media, User, Tag, Location 10 | from .api import InstagramError 11 | 12 | 13 | USER_ID = 237074561 # tnt_online 14 | USER_PRIVATE_ID = 176980649 15 | USER_ID_2 = 775667951 # about 200 media 16 | USER_ID_3 = 1741896487 # about 400 followers 17 | MEDIA_ID = '934625295371059186_205828054' 18 | MEDIA_ID_2 = '806703315661297054_190931988' # media without caption 19 | LOCATION_ID = 1 20 | TAG_NAME = "snowyday" 21 | TAG_SEARCH_NAME = "snowy" 22 | LOCATION_SEARCH_NAME = "Dog Patch Labs" 23 | 24 | TOKEN = '1687258424.0fdde74.9badafabca4e49df90da02798db6bf02' 25 | INSTAGRAM_USERNAME = 'atsepk' 26 | INSTAGRAM_PASSWORD = 'Jh6#dFwEHc' 27 | 28 | 29 | class InstagramApiTestCase(TestCase): 30 | 31 | _settings = None 32 | 33 | def setUp(self): 34 | context = getattr(settings, 'SOCIAL_API_CALL_CONTEXT', {}) 35 | self._settings = dict(context) 36 | context.update({'instagram': {'token': TOKEN}}) 37 | 38 | def tearDown(self): 39 | setattr(settings, 'SOCIAL_API_CALL_CONTEXT', self._settings) 40 | 41 | 42 | class UserTest(InstagramApiTestCase): 43 | 44 | def setUp(self): 45 | super(UserTest, self).setUp() 46 | self.time = timezone.now() 47 | 48 | def test_fetch_user_by_name(self): 49 | 50 | u = User.remote.get_by_slug('tnt_online') 51 | 52 | self.assertEqual(int(u.id), USER_ID) 53 | self.assertEqual(u.username, 'tnt_online') 54 | self.assertEqual(u.full_name, u'Телеканал ТНТ') 55 | self.assertGreater(len(u.profile_picture), 0) 56 | self.assertGreater(len(u.website), 0) 57 | 58 | def test_search_users(self): 59 | 60 | users = User.remote.search('tnt_online') 61 | 62 | self.assertGreater(len(users), 0) 63 | for user in users: 64 | self.assertIsInstance(user, User) 65 | 66 | def test_fetch_user(self): 67 | u = User.remote.fetch(USER_ID) 68 | 69 | self.assertEqual(int(u.id), USER_ID) 70 | self.assertEqual(u.username, 'tnt_online') 71 | self.assertEqual(u.full_name, u'Телеканал ТНТ') 72 | self.assertGreater(len(u.profile_picture), 0) 73 | self.assertGreater(len(u.website), 0) 74 | 75 | self.assertGreater(u.followers_count, 0) 76 | self.assertGreater(u.follows_count, 0) 77 | self.assertGreater(u.media_count, 0) 78 | 79 | self.assertGreater(u.fetched, self.time) 80 | 81 | u.followers_count = None 82 | u.save() 83 | self.assertIsNone(u.followers_count) 84 | 85 | u.refresh() 86 | self.assertGreater(u.followers_count, 0) 87 | 88 | u = User.objects.get(id=u.id) 89 | self.assertGreater(u.followers_count, 0) 90 | 91 | def test_fetch_user_follows_graphql(self): 92 | u = User.remote.fetch(USER_ID_3) 93 | self.assertEqual(u.is_private, False) 94 | 95 | settings_temp = dict(OAUTH_TOKENS_INSTAGRAM_USERNAME=INSTAGRAM_USERNAME, 96 | OAUTH_TOKENS_INSTAGRAM_PASSWORD=INSTAGRAM_PASSWORD) 97 | 98 | with self.settings(**settings_temp): 99 | users = u.fetch_follows(source='graphql') 100 | 101 | self.assertGreaterEqual(u.follows_count, 996) 102 | self.assertEqual(u.follows_count, users.count()) 103 | self.assertEqual(u.follows_count, User.objects.count() - 1) 104 | 105 | def test_fetch_user_followers_graphql(self): 106 | u = User.remote.fetch(USER_ID_3) 107 | self.assertEqual(u.is_private, False) 108 | 109 | settings_temp = dict(OAUTH_TOKENS_INSTAGRAM_USERNAME=INSTAGRAM_USERNAME, 110 | OAUTH_TOKENS_INSTAGRAM_PASSWORD=INSTAGRAM_PASSWORD) 111 | 112 | with self.settings(**settings_temp): 113 | users = u.fetch_followers(source='graphql') 114 | 115 | self.assertGreaterEqual(u.follows_count, 754) 116 | self.assertEqual(u.followers_count, users.count()) 117 | self.assertEqual(u.followers_count, User.objects.count() - 1) 118 | 119 | def test_fetch_user_follows(self): 120 | u = User.remote.fetch(USER_ID_3) 121 | self.assertEqual(u.is_private, False) 122 | users = u.fetch_follows() 123 | 124 | self.assertGreaterEqual(u.follows_count, 970) 125 | self.assertEqual(u.follows_count, users.count()) 126 | 127 | def test_fetch_user_followers(self): 128 | u = User.remote.fetch(USER_ID_3) 129 | self.assertEqual(u.is_private, False) 130 | users = u.fetch_followers() 131 | 132 | self.assertGreaterEqual(u.followers_count, 750) 133 | self.assertEqual(u.followers_count, users.count()) 134 | 135 | # check counts for any first public follower 136 | for f in users: 137 | self.assertIsNone(f.followers_count) 138 | self.assertIsNone(f.follows_count) 139 | self.assertIsNone(f.media_count) 140 | 141 | f = User.remote.fetch(f.id) 142 | if f.is_private is False: 143 | self.assertIsNotNone(f.followers_count) 144 | self.assertIsNotNone(f.follows_count) 145 | self.assertIsNotNone(f.media_count) 146 | break 147 | 148 | # fetch followers once again and check counts 149 | u.fetch_followers() 150 | f = User.objects.get(id=f.id) 151 | self.assertIsNotNone(f.followers_count) 152 | self.assertIsNotNone(f.follows_count) 153 | self.assertIsNotNone(f.media_count) 154 | 155 | def test_fetch_users_with_full_name_bad_overlength(self): 156 | user = User.remote.get_by_slug('stasplot') 157 | self.assertEqual(user.full_name, u'Stas from Ishim Ишим Тюмень Tymen region Тюмень') # noqa 158 | user = User.remote.fetch(47274770) 159 | self.assertEqual(user.full_name, u'Stas from Ishim Ишим Тюмень Ty') 160 | 161 | user = User.remote.get_by_slug('keratin_krasnodar1') 162 | self.assertEqual(user.full_name, u'Кератин, Ботокс в Краснодаре \ud83c\udf80') # noqa 163 | user = User.remote.fetch(2057367004) 164 | self.assertEqual(user.full_name, u'Кератин, Ботокс в Краснодаре ') 165 | 166 | user = User.remote.get_by_slug('beautypageantsfans') 167 | self.assertEqual(user.full_name, u'I Am A Girl \xbfAnd What?\ud83d\udc81\ud83c\udffb\u2728\ud83d\udc51\ud83d\udc8b') # noqa 168 | user = User.remote.fetch(1164190771) 169 | self.assertEqual(user.full_name, u'I Am A Girl \xbfAnd What?\ud83d\udc81\ud83c\udffb\u2728\ud83d\udc51') 170 | 171 | def test_fetch_duplicate_user(self): 172 | 173 | u = UserFactory(id=0, username='tnt_online') 174 | 175 | self.assertEqual(User.objects.count(), 1) 176 | self.assertNotEqual(int(u.id), USER_ID) 177 | self.assertEqual(u.username, 'tnt_online') 178 | 179 | u = User.remote.fetch(USER_ID) 180 | 181 | self.assertEqual(User.objects.count(), 1) 182 | self.assertEqual(int(u.id), USER_ID) 183 | self.assertEqual(u.username, 'tnt_online') 184 | 185 | def test_fetch_duble_duplicate_user(self): 186 | 187 | u1 = UserFactory(username='tnt_online', id=8910216) 188 | u2 = UserFactory(username='bmwru', id=237074561) 189 | 190 | self.assertEqual(User.objects.count(), 2) 191 | self.assertEqual(int(u1.id), 8910216) 192 | self.assertEqual(int(u2.id), 237074561) 193 | self.assertEqual(u1.username, 'tnt_online') 194 | self.assertEqual(u2.username, 'bmwru') 195 | 196 | u1 = User.remote.fetch(8910216) 197 | u2 = User.remote.fetch(237074561) 198 | 199 | self.assertEqual(User.objects.count(), 2) 200 | self.assertEqual(int(u1.id), 8910216) 201 | self.assertEqual(int(u2.id), 237074561) 202 | self.assertEqual(u1.username, 'bmwru') 203 | self.assertEqual(u2.username, 'tnt_online') 204 | 205 | def test_fetch_real_duplicates_user(self): 206 | 207 | UserFactory(id=2116301016) 208 | User.remote.fetch(2116301016) 209 | 210 | with self.assertRaises(InstagramError): 211 | User.remote.get(1206219929) 212 | 213 | try: 214 | User.remote.get(1206219929) 215 | except InstagramError as e: 216 | self.assertEqual(e.code, 400) 217 | 218 | def test_fetch_private_user(self): 219 | 220 | with self.assertRaises(InstagramError): 221 | User.remote.fetch(USER_PRIVATE_ID) 222 | 223 | try: 224 | User.remote.fetch(USER_PRIVATE_ID) 225 | except InstagramError as e: 226 | self.assertEqual(e.code, 400) 227 | 228 | userf = UserFactory(id=USER_PRIVATE_ID) 229 | user = User.remote.fetch(USER_PRIVATE_ID) 230 | 231 | self.assertEqual(userf, user) 232 | self.assertFalse(userf.is_private) 233 | self.assertTrue(user.is_private) 234 | 235 | userf.refresh() 236 | self.assertTrue(userf.is_private) 237 | 238 | def test_unexisted_user(self): 239 | with self.assertRaises(InstagramError): 240 | User.remote.get(0) 241 | 242 | try: 243 | User.remote.get(0) 244 | except InstagramError as e: 245 | self.assertEqual(e.code, 400) 246 | 247 | 248 | class MediaTest(InstagramApiTestCase): 249 | 250 | def setUp(self): 251 | super(MediaTest, self).setUp() 252 | self.time = timezone.now() 253 | 254 | def test_fetch_media(self): 255 | m = Media.remote.fetch(MEDIA_ID) 256 | 257 | self.assertEqual(m.remote_id, MEDIA_ID) 258 | self.assertGreater(len(m.caption), 0) 259 | self.assertGreater(len(m.link), 0) 260 | 261 | self.assertGreater(m.comments_count, 0) 262 | self.assertGreater(m.likes_count, 0) 263 | 264 | self.assertGreater(m.fetched, self.time) 265 | self.assertIsInstance(m.created_time, datetime) 266 | 267 | # specifying timezone and then making it naive again 268 | self.assertEqual(m.created_time, timezone.make_aware(m.created_time, timezone.get_current_timezone()).replace(tzinfo=None)) 269 | 270 | self.assertEqual(m.type, 'video') 271 | self.assertEqual(m.filter, 'Normal') 272 | 273 | self.assertGreater(len(m.image_low_resolution), 0) 274 | self.assertGreater(len(m.image_standard_resolution), 0) 275 | self.assertGreater(len(m.image_thumbnail), 0) 276 | self.assertGreater(len(m.video_low_bandwidth), 0) 277 | self.assertGreater(len(m.video_low_resolution), 0) 278 | self.assertGreater(len(m.video_standard_resolution), 0) 279 | 280 | self.assertGreater(m.comments.count(), 0) 281 | self.assertGreater(m.tags.count(), 0) 282 | # self.assertGreater(m.likes_users.count(), 0) 283 | 284 | # media without caption test 285 | m = Media.remote.fetch(MEDIA_ID_2) 286 | self.assertEqual(len(m.caption), 0) 287 | 288 | self.assertEqual(m.type, 'image') 289 | 290 | self.assertGreater(len(m.image_low_resolution), 0) 291 | self.assertGreater(len(m.image_standard_resolution), 0) 292 | self.assertGreater(len(m.image_thumbnail), 0) 293 | 294 | self.assertGreater(m.comments.count(), 0) 295 | # self.assertGreater(m.likes_users.count(), 0) 296 | 297 | def test_fetch_user_media_count(self): 298 | u = UserFactory(id=USER_ID) 299 | 300 | media = u.fetch_media(count=100) 301 | m = media[0] 302 | 303 | self.assertEqual(media.count(), 100) 304 | self.assertEqual(m.user, u) 305 | 306 | self.assertGreater(len(m.caption), 0) 307 | self.assertGreater(len(m.link), 0) 308 | 309 | self.assertGreater(m.comments_count, 0) 310 | self.assertGreater(m.likes_count, 0) 311 | 312 | self.assertGreater(m.fetched, self.time) 313 | self.assertIsInstance(m.created_time, datetime) 314 | 315 | def test_fetch_user_media(self): 316 | u = User.remote.fetch(USER_ID_2) 317 | media = u.fetch_media() 318 | 319 | self.assertGreater(media.count(), 210) 320 | self.assertEqual(media.count(), u.media_count) 321 | self.assertEqual(media.count(), u.media_feed.count()) 322 | 323 | after = media.order_by('-created_time')[50].created_time 324 | Media.objects.all().delete() 325 | 326 | self.assertEqual(u.media_feed.count(), 0) 327 | 328 | media = u.fetch_media(after=after) 329 | 330 | self.assertEqual(media.count(), 53) # not 50 for some strange reason 331 | self.assertEqual(media.count(), u.media_feed.count()) 332 | 333 | def test_fetch_media_with_location(self): 334 | 335 | media = Media.remote.fetch('1105137931436928268_1692711770') 336 | 337 | self.assertIsInstance(media.location, Location) 338 | self.assertEqual(media.location.name, 'Prague, Czech Republic') 339 | 340 | def test_fetch_comments(self): 341 | m = Media.remote.fetch(MEDIA_ID) 342 | comments = m.fetch_comments() 343 | 344 | self.assertGreater(m.comments_count, 0) 345 | # TODO: 84 != 80 strange bug of API, may be limit of comments to fetch 346 | # self.assertEqual(m.comments_count, len(comments)) 347 | 348 | c = comments[0] 349 | self.assertEqual(c.media, m) 350 | self.assertGreater(len(c.text), 0) 351 | self.assertGreater(c.fetched, self.time) 352 | self.assertIsInstance(c.created_time, datetime) 353 | 354 | def test_fetch_likes(self): 355 | m = Media.remote.fetch(MEDIA_ID) 356 | likes = m.fetch_likes() 357 | 358 | self.assertGreater(m.likes_count, 0) 359 | # TODO: 2515 != 117 how to get all likes? 360 | # self.assertEqual(m.likes_count, likes.count()) 361 | self.assertIsInstance(likes[0], User) 362 | 363 | 364 | class TagTest(InstagramApiTestCase): 365 | def test_fetch_tag(self): 366 | t = Tag.remote.fetch(TAG_NAME) 367 | 368 | self.assertEqual(t.name, TAG_NAME) 369 | self.assertGreater(t.media_count, 0) 370 | 371 | def test_search_tags(self): 372 | 373 | tags = Tag.remote.search(TAG_SEARCH_NAME) 374 | 375 | self.assertGreater(len(tags), 0) 376 | for tag in tags: 377 | self.assertIsInstance(tag, Tag) 378 | 379 | def test_fetch_tag_media(self): 380 | t = Tag.remote.fetch("merrittislandnwr") 381 | media = t.fetch_media() 382 | 383 | self.assertGreater(media.count(), 0) 384 | self.assertEqual(media.count(), t.media_feed.count()) 385 | 386 | 387 | class LocationTest(InstagramApiTestCase): 388 | def test_fetch_location(self): 389 | location = Location.remote.fetch(LOCATION_ID) 390 | 391 | self.assertEqual(location.id, LOCATION_ID) 392 | self.assertEqual(location.name, "Dog Patch Labs") 393 | self.assertEqual(location.latitude, 37.782492553) 394 | self.assertEqual(location.longitude, -122.387785235) 395 | self.assertEqual(location.media_count, None) 396 | 397 | def test_search_locations(self): 398 | locations = Location.remote.search(lat=37.782492553, lng=-122.387785235) 399 | 400 | self.assertGreater(len(locations), 0) 401 | for location in locations: 402 | self.assertIsInstance(location, Location) 403 | 404 | def test_fetch_location_media(self): 405 | location = LocationFactory(id=LOCATION_ID) 406 | media = location.fetch_media() 407 | 408 | self.assertGreater(media.count(), 0) 409 | self.assertEqual(media.count(), location.media_feed.count()) 410 | self.assertEqual(media.count(), location.media_count) 411 | 412 | 413 | # class InstagramApiTest(UserTest, MediaTest): 414 | # def call(api, *a, **kw): 415 | # raise InstagramAPIError(503, "Rate limited", "Your client is making too many request per second") 416 | # 417 | # @mock.patch('instagram.client.InstagramAPI.user', side_effect=call) 418 | # @mock.patch('instagram_api.api.InstagramApi.repeat_call', 419 | # side_effect=lambda *a, **kw: models.User.object_from_dictionary({'id': '205828054'})) 420 | # def test_client_rate_limit(self, call, repeat_call): 421 | # self.assertGreaterEqual(len(CLIENT_IDS), 2) 422 | # User.remote.fetch(USER_ID_2) 423 | # self.assertEqual(call.called, True) 424 | # self.assertEqual(repeat_call.called, True) 425 | -------------------------------------------------------------------------------- /instagram_api/models.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from datetime import datetime 3 | import calendar 4 | import logging 5 | import re 6 | import time 7 | import sys 8 | import six 9 | 10 | from django.db import models 11 | from django.db.models.fields import FieldDoesNotExist 12 | from django.db.utils import IntegrityError 13 | from django.utils import timezone 14 | from instagram.helper import timestamp_to_datetime 15 | from instagram.models import ApiModel 16 | from m2m_history.fields import ManyToManyHistoryField 17 | from social_api.utils import override_api_context 18 | 19 | from . import fields 20 | from .api import api_call, InstagramError 21 | from .decorators import atomic 22 | from .graphql import GraphQL 23 | 24 | try: 25 | from django.db.models.related import RelatedObject as ForeignObjectRel 26 | except ImportError: 27 | # django 1.8 + 28 | from django.db.models.fields.related import ForeignObjectRel 29 | 30 | __all__ = ['User', 'Media', 'Comment', 'InstagramContentError', 'InstagramModel', 'InstagramManager', 'UserManager' 31 | 'Tag', 'TagManager'] 32 | 33 | log = logging.getLogger('instagram_api') 34 | 35 | 36 | class InstagramContentError(Exception): 37 | pass 38 | 39 | 40 | class InstagramManager(models.Manager): 41 | """ 42 | Instagram Manager for RESTful CRUD operations 43 | """ 44 | def __init__(self, methods=None, remote_pk=None, *args, **kwargs): 45 | if methods and len(methods.items()) < 1: 46 | raise ValueError('Argument methods must contains at least 1 specified method') 47 | 48 | self.methods = methods or {} 49 | self.remote_pk = remote_pk or ('id',) 50 | if not isinstance(self.remote_pk, tuple): 51 | self.remote_pk = (self.remote_pk,) 52 | 53 | super(InstagramManager, self).__init__(*args, **kwargs) 54 | 55 | def bulk_create_from_instances(self, instances): 56 | ids = [instance.id for instance in instances] 57 | ids_exists = self.model.objects.filter(id__in=ids).values_list('id', flat=True) 58 | instances = [instance for instance in instances if instance.id not in ids_exists] 59 | self.model.objects.bulk_create(instances) 60 | 61 | def get_or_create_from_instance(self, instance): 62 | 63 | remote_pk_dict = {} 64 | for field_name in self.remote_pk: 65 | remote_pk_dict[field_name] = getattr(instance, field_name) 66 | 67 | try: 68 | old_instance = self.model.objects.get(**remote_pk_dict) 69 | instance._substitute(old_instance) 70 | instance.save() 71 | except self.model.DoesNotExist: 72 | instance.save() 73 | log.debug('Fetch and create new object %s with remote pk %s' % (self.model, remote_pk_dict)) 74 | 75 | return instance 76 | 77 | def api_call(self, method, *args, **kwargs): 78 | if method in self.methods: 79 | method = self.methods[method] 80 | return api_call(method, *args, **kwargs) 81 | 82 | def fetch(self, *args, **kwargs): 83 | """ 84 | Retrieve and save object to local DB 85 | """ 86 | result = self.get(*args, **kwargs) 87 | if isinstance(result, list): 88 | instances = self.model.objects.none() 89 | for instance in result: 90 | instance = self.get_or_create_from_instance(instance) 91 | instances |= instance.__class__.objects.filter(pk=instance.pk) 92 | return instances 93 | else: 94 | return self.get_or_create_from_instance(result) 95 | 96 | def get(self, *args, **kwargs): 97 | """ 98 | Retrieve objects from remote server 99 | """ 100 | response = self.api_call('get', *args, **kwargs) 101 | 102 | extra_fields = kwargs.pop('extra_fields', {}) 103 | extra_fields['fetched'] = timezone.now() 104 | return self.parse_response(response, extra_fields) 105 | 106 | def parse_response(self, response, extra_fields=None): 107 | if isinstance(response, (list, tuple)): 108 | return self.parse_response_list(response, extra_fields) 109 | elif isinstance(response, ApiModel): 110 | return self.parse_response_object(response, extra_fields) 111 | else: 112 | raise InstagramContentError('Instagram response should be list or ApiModel, not %s' % response) 113 | 114 | def parse_response_object(self, resource, extra_fields=None): 115 | 116 | instance = self.model() 117 | # important to do it before calling parse method 118 | if extra_fields: 119 | instance.__dict__.update(extra_fields) 120 | instance._response = resource.__dict__ if isinstance(resource, ApiModel) else resource 121 | instance.parse() 122 | 123 | return instance 124 | 125 | def parse_response_list(self, response_list, extra_fields=None): 126 | 127 | instances = [] 128 | for response in response_list: 129 | 130 | if not isinstance(response, ApiModel): 131 | log.error("Resource %s is not dictionary" % response) 132 | continue 133 | 134 | instance = self.parse_response_object(response, extra_fields) 135 | instances += [instance] 136 | 137 | return instances 138 | 139 | 140 | class InstagramModel(models.Model): 141 | 142 | class Meta: 143 | abstract = True 144 | 145 | def save(self, *args, **kwargs): 146 | # cut all CharFields to max allowed length 147 | cut = False 148 | for field in self._meta.fields: 149 | if isinstance(field, (models.CharField, models.TextField)): 150 | value = getattr(self, field.name) 151 | if isinstance(field, models.CharField) and value: 152 | if len(value) > field.max_length: 153 | value = value[:field.max_length] 154 | cut = True 155 | if isinstance(value, six.string_types): 156 | # check strings for bad symbols in string encoding 157 | # there is problems to save users with bad encoded strings 158 | while True: 159 | try: 160 | value.encode('utf-16').decode('utf-16') 161 | break 162 | except UnicodeDecodeError: 163 | if cut and len(value) > 2: 164 | value = value[:-1] 165 | else: 166 | value = '' 167 | break 168 | setattr(self, field.name, value) 169 | 170 | try: 171 | super(InstagramModel, self).save(*args, **kwargs) 172 | except Exception as e: 173 | six.reraise(type(e), '%s while saving %s' % (str(e), self.__dict__), sys.exc_info()[2]) 174 | 175 | 176 | class InstagramBaseModel(InstagramModel): 177 | _refresh_pk = 'id' 178 | 179 | fetched = models.DateTimeField(u'Fetched', null=True, blank=True) 180 | objects = models.Manager() 181 | 182 | class Meta: 183 | abstract = True 184 | 185 | def __init__(self, *args, **kwargs): 186 | super(InstagramBaseModel, self).__init__(*args, **kwargs) 187 | 188 | # different lists for saving related objects 189 | self._relations_post_save = {'fk': {}, 'm2m': {}} 190 | self._relations_pre_save = [] 191 | self._tweepy_model = None 192 | self._response = {} 193 | 194 | def _substitute(self, old_instance): 195 | """ 196 | Substitute new user with old one while updating in method Manager.get_or_create_from_instance() 197 | Can be overrided in child models 198 | """ 199 | self.pk = old_instance.pk 200 | 201 | def save(self, *args, **kwargs): 202 | """ 203 | Save all related instances before or after current instance 204 | """ 205 | for field, instance in self._relations_pre_save: 206 | instance = instance.__class__.remote.get_or_create_from_instance(instance) 207 | setattr(self, field, instance) 208 | self._relations_pre_save = [] 209 | 210 | try: 211 | super(InstagramBaseModel, self).save(*args, **kwargs) 212 | except Exception as e: 213 | six.reraise(type(e), '%s while saving %s' % (str(e), self.__dict__), sys.exc_info()[2]) 214 | 215 | def parse(self): 216 | """ 217 | Parse API response and define fields with values 218 | """ 219 | for key, value in self._response.items(): 220 | if key == '_api': 221 | continue 222 | 223 | try: 224 | field, model, direct, m2m = self._meta.get_field_by_name(key) 225 | except FieldDoesNotExist: 226 | log.debug('Field with name "%s" doesn\'t exist in the model %s' % (key, type(self))) 227 | continue 228 | 229 | if value: 230 | if isinstance(field, ForeignObjectRel): 231 | self._relations_post_save['fk'][key] = [field.model.remote.parse_response_object(item) 232 | for item in value] 233 | elif isinstance(field, models.ManyToManyField): 234 | self._relations_post_save['m2m'][key] = [field.rel.to.remote.parse_response_object(item) 235 | for item in value] 236 | else: 237 | if isinstance(field, models.BooleanField): 238 | value = bool(value) 239 | 240 | elif isinstance(field, (models.OneToOneField, models.ForeignKey)) and value: 241 | rel_instance = field.rel.to.remote.parse_response_object(value) 242 | value = rel_instance 243 | if isinstance(field, models.ForeignKey): 244 | self._relations_pre_save += [(key, rel_instance)] 245 | 246 | elif isinstance(field, (fields.CommaSeparatedCharField, 247 | models.CommaSeparatedIntegerField)) and isinstance(value, list): 248 | value = ','.join([unicode(v) for v in value]) 249 | 250 | elif isinstance(field, (models.CharField, models.TextField)) and value: 251 | if isinstance(value, (str, unicode)): 252 | value = value.strip() 253 | 254 | elif isinstance(field, models.IntegerField) and value: 255 | value = int(value) 256 | 257 | setattr(self, key, value) 258 | 259 | def get_url(self): 260 | return 'https://instagram.com/%s' % self.slug 261 | 262 | def refresh(self): 263 | """ 264 | Refresh current model with remote data 265 | """ 266 | instance = self.__class__.remote.fetch(getattr(self, self._refresh_pk)) 267 | self.__dict__.update(instance.__dict__) 268 | 269 | 270 | class InstagramSearchManager(InstagramManager): 271 | def search(self, q=None, **kwargs): 272 | if q: 273 | kwargs['q'] = q 274 | instances = self.api_call('search', **kwargs) 275 | 276 | if isinstance(instances[0], list) and (len(instances) > 1) and \ 277 | (isinstance(instances[1], basestring) or instances[1] is None): 278 | instances, _next = instances 279 | while _next: 280 | instances_new, _next = self.api_call('search', with_next_url=_next) 281 | [instances.append(i) for i in instances_new] 282 | 283 | extra_fields = kwargs.pop('extra_fields', {}) 284 | extra_fields['fetched'] = timezone.now() 285 | 286 | return self.parse_response_list(instances, extra_fields) 287 | 288 | 289 | class UserManager(InstagramSearchManager): 290 | 291 | def get(self, *args, **kwargs): 292 | if 'extra_fields' not in kwargs: 293 | kwargs['extra_fields'] = {} 294 | kwargs['extra_fields']['is_private'] = False 295 | try: 296 | return super(UserManager, self).get(*args, **kwargs) 297 | except InstagramError as e: 298 | if e.code == 400: 299 | if e.error_type == 'APINotAllowedError': 300 | # {'error_message': 'you cannot view this resource', 301 | # 'error_type': 'APINotAllowedError', 302 | # 'status_code': 400} 303 | try: 304 | instance = self.model.objects.get(pk=args[0]) 305 | instance.is_private = True 306 | instance.save() 307 | return instance 308 | except self.model.DoesNotExist: 309 | raise e 310 | elif e.error_type == 'APINotFoundError': 311 | # {'error_message': 'this user does not exist', 312 | # 'error_type': 'APINotFoundError', 313 | # 'status_code': 400} 314 | try: 315 | instance = self.model.objects.get(pk=args[0]) 316 | instance.delete() 317 | raise 318 | except self.model.DoesNotExist: 319 | raise e 320 | else: 321 | raise 322 | 323 | def fetch_by_slug(self, *args, **kwargs): 324 | result = self.get_by_slug(*args, **kwargs) 325 | return self.get_or_create_from_instance(result) 326 | 327 | def get_by_url(self, url): 328 | """ 329 | Return object by url 330 | :param url: 331 | """ 332 | m = re.findall(r'(?:https?://)?(?:www\.)?instagram\.com/([^/]+)/?', url) 333 | if not len(m): 334 | raise ValueError("Url should be started with https://instagram.com/") 335 | 336 | return self.get_by_slug(m[0]) 337 | 338 | def get_by_slug(self, slug): 339 | """ 340 | Return existed User by slug or new intance with empty pk 341 | :param slug: 342 | """ 343 | users = self.search(slug) 344 | for user in users: 345 | if user.username == slug: 346 | return self.get(user.id) 347 | raise ValueError("No users found for the name %s" % slug) 348 | 349 | def fetch_followers(self, user, **kwargs): 350 | return self.create_related_users('followed_by', user, **kwargs) 351 | 352 | def fetch_follows(self, user, **kwargs): 353 | return self.create_related_users('follows', user, **kwargs) 354 | 355 | def create_related_users(self, method, user, **kwargs): 356 | if kwargs.pop('source') == 'graphql': 357 | ids = self.create_related_users_graphql(method, user) 358 | else: 359 | ids = self.create_related_users_api(method, user) 360 | 361 | method = method.replace('followed_by', 'followers') 362 | m2m_relation = getattr(user, method) 363 | initial = m2m_relation.versions.count() == 0 364 | setattr(user, method, ids) # user.followers = ids 365 | 366 | if initial: 367 | m2m_relation.get_queryset_through().update(time_from=None) 368 | m2m_relation.versions.update(added_count=0) 369 | 370 | return m2m_relation.all() 371 | 372 | def create_related_users_api(self, method, user): 373 | ids = [] 374 | extra_fiels = {'fetched': timezone.now()} 375 | 376 | _next = True 377 | while _next: 378 | kwargs = {} if _next is True else {'with_next_url': _next} 379 | users = [] 380 | instances, _next = self.api_call(method, user.pk, **kwargs) 381 | for instance in instances: 382 | instance = self.parse_response_object(instance, extra_fiels) 383 | users += [instance] 384 | ids += [instance.id] 385 | self.bulk_create_from_instances(users) 386 | return ids 387 | 388 | def create_related_users_graphql(self, method, user): 389 | graphql = GraphQL() 390 | ids = [] 391 | extra_fiels = {'fetched': timezone.now()} 392 | for resources in graphql.related_users(method, user): 393 | users = [] 394 | for instance in resources: 395 | instance['profile_picture'] = instance['profile_pic_url'] 396 | instance = self.parse_response_object(instance, extra_fiels) 397 | users += [instance] 398 | ids += [instance.id] 399 | self.bulk_create_from_instances(users) 400 | return ids 401 | 402 | def fetch_media_likes(self, media): 403 | # TODO: get all likes 404 | # https://instagram.com/developer/endpoints/likes/#get_media_likes 405 | # no pagination to get all likes 406 | # http://stackoverflow.com/questions/20478485/get-a-list-of-users-who-have-liked-a-media-not-working-anymore 407 | 408 | extra_fields = {'fetched': timezone.now()} 409 | 410 | # users 411 | response = self.api_call('likes', media.remote_id) 412 | result = self.parse_response(response, extra_fields) 413 | 414 | instances = [] 415 | for instance in result: 416 | instance = self.get_or_create_from_instance(instance) 417 | instances.append(instance) 418 | 419 | media.likes_users = instances + list(media.likes_users.all()) 420 | 421 | return media.likes_users.all() 422 | 423 | 424 | class User(InstagramBaseModel): 425 | 426 | _followers_ids = [] 427 | _follows_ids = [] 428 | 429 | id = models.BigIntegerField(primary_key=True) 430 | username = models.CharField(max_length=30, db_index=True) 431 | full_name = models.CharField(max_length=80) # max_length=30 in interface 432 | bio = models.CharField(max_length=150) 433 | 434 | profile_picture = models.URLField(max_length=112) 435 | website = models.URLField(max_length=150) # found max_length=106 436 | 437 | followers_count = models.PositiveIntegerField(null=True, db_index=True) 438 | follows_count = models.PositiveIntegerField(null=True, db_index=True) 439 | media_count = models.PositiveIntegerField(null=True, db_index=True) 440 | 441 | followers = ManyToManyHistoryField('User', versions=True, related_name='follows') 442 | 443 | is_private = models.NullBooleanField('Account is private', db_index=True) 444 | 445 | objects = models.Manager() 446 | remote = UserManager(methods={ 447 | 'get': 'user', 448 | 'search': 'user_search', 449 | 'follows': 'user_follows', 450 | 'followed_by': 'user_followed_by', 451 | 'likes': 'media_likes', 452 | }) 453 | 454 | @property 455 | def slug(self): 456 | return self.username 457 | 458 | def __unicode__(self): 459 | return self.full_name or self.username 460 | 461 | @property 462 | def instagram_link(self): 463 | return u'https://instagram.com/%s/' % self.username 464 | 465 | def _substitute(self, old_instance): 466 | super(User, self)._substitute(old_instance) 467 | for field_name in ['followers_count', 'follows_count', 'media_count', 'is_private']: 468 | if getattr(self, field_name) is None and getattr(old_instance, field_name) is not None: 469 | setattr(self, field_name, getattr(old_instance, field_name)) 470 | 471 | def save(self, *args, **kwargs): 472 | 473 | try: 474 | with atomic(): 475 | super(InstagramBaseModel, self).save(*args, **kwargs) 476 | except IntegrityError as e: 477 | if 'username' in e.message: 478 | # duplicate key value violates unique constraint "instagram_api_user_username_key" 479 | # DETAIL: Key (username)=(...) already exists. 480 | user_local = User.objects.get(username=self.username) 481 | try: 482 | # check for recursive loop 483 | # get remote user 484 | user_remote = User.remote.get(user_local.pk) 485 | try: 486 | user_local2 = User.objects.get(username=user_remote.username) 487 | # if users excahnge usernames or user is dead (400 error) 488 | if user_local2.pk == self.pk or user_remote.is_private: 489 | user_local.username = 'temp%s' % time.time() 490 | user_local.save() 491 | except User.DoesNotExist: 492 | pass 493 | # fetch right user 494 | User.remote.fetch(user_local.pk) 495 | except InstagramError as e: 496 | if e.code == 400: 497 | user_local.delete() 498 | else: 499 | raise 500 | super(InstagramBaseModel, self).save(*args, **kwargs) 501 | else: 502 | raise 503 | 504 | def parse(self): 505 | if isinstance(self._response, dict) and 'counts' in self._response: 506 | count = self._response['counts'] 507 | self._response['followers_count'] = count.get('followed_by', 0) 508 | self._response['follows_count'] = count.get('follows', 0) 509 | self._response['media_count'] = count.get('media', 0) 510 | 511 | super(User, self).parse() 512 | 513 | def fetch_follows(self, **kwargs): 514 | return User.remote.fetch_follows(user=self, **kwargs) 515 | 516 | def fetch_followers(self, **kwargs): 517 | return User.remote.fetch_followers(user=self, **kwargs) 518 | 519 | def fetch_media(self, **kwargs): 520 | return Media.remote.fetch_user_media(user=self, **kwargs) 521 | 522 | def refresh(self): 523 | # do refresh via client_id, because is_private is dependent on access_token and relation with current user 524 | with override_api_context('instagram', use_client_id=True): 525 | super(User, self).refresh() 526 | 527 | 528 | class MediaManager(InstagramManager): 529 | 530 | def fetch_user_media(self, user, count=None, min_id=None, max_id=None, 531 | after=None, before=None): 532 | 533 | extra_fields = {'fetched': timezone.now(), 'user_id': user.pk} 534 | kwargs = {'user_id': user.pk} 535 | 536 | if count: 537 | kwargs['count'] = count 538 | if min_id: 539 | kwargs['min_id'] = min_id 540 | if max_id: 541 | kwargs['max_id'] = max_id 542 | if after: 543 | kwargs['min_timestamp'] = time.mktime(after.timetuple()) 544 | if before: 545 | kwargs['max_timestamp'] = time.mktime(before.timetuple()) 546 | 547 | instances, _next = self.api_call('user_recent_media', **kwargs) 548 | while _next: 549 | instances_new, _next = self.api_call('user_recent_media', with_next_url=_next) 550 | instances_new = sorted(instances_new, reverse=True, key=lambda j: j.created_time) 551 | for i in instances_new: 552 | instances.append(i) 553 | # strange, but API arguments doesn't work 554 | if count and len(instances) >= count or after and i.created_time.replace(tzinfo=timezone.utc) <= after: 555 | _next = False 556 | break 557 | 558 | for instance in instances: 559 | instance = self.parse_response_object(instance, extra_fields) 560 | self.get_or_create_from_instance(instance) 561 | 562 | return user.media_feed.all() 563 | 564 | def fetch_tag_media(self, tag, count=None, max_tag_id=None): 565 | 566 | extra_fields = {'fetched': timezone.now()} 567 | 568 | kwargs = {'tag_name': tag.name, 'count': count, 'max_tag_id': max_tag_id} 569 | instances, _next = self.api_call('tag_recent_media', **kwargs) 570 | while _next: 571 | instances_new, _next = self.api_call('tag_recent_media', with_next_url=_next, tag_name=tag.name) 572 | [instances.append(i) for i in instances_new] 573 | 574 | for instance in instances: 575 | extra_fields['user_id'] = instance.user.id 576 | instance = self.parse_response_object(instance, extra_fields) 577 | instance = self.get_or_create_from_instance(instance) 578 | instance.tags.add(tag) 579 | 580 | return tag.media_feed.all() 581 | 582 | def fetch_location_media(self, location, count=None, max_id=None): 583 | 584 | extra_fields = {'fetched': timezone.now()} 585 | 586 | kwargs = {'location_id': location.pk, 'count': count, 'max_id': max_id} 587 | instances, _next = self.api_call('location_recent_media', **kwargs) 588 | while _next: 589 | instances_new, _next = self.api_call('location_recent_media', with_next_url=_next, location_id=location.pk) 590 | [instances.append(i) for i in instances_new] 591 | 592 | for instance in instances: 593 | extra_fields['user_id'] = instance.user.id 594 | extra_fields['location_id'] = location.pk 595 | instance = self.parse_response_object(instance, extra_fields) 596 | self.get_or_create_from_instance(instance) 597 | 598 | if count is None: 599 | location.media_count = location.media_feed.count() 600 | location.save() 601 | 602 | return location.media_feed.all() 603 | 604 | 605 | class Media(InstagramBaseModel): 606 | remote_id = models.CharField(max_length=30, unique=True) 607 | caption = models.TextField(blank=True) 608 | link = models.URLField(max_length=68) 609 | 610 | type = models.CharField(max_length=5) 611 | filter = models.CharField(max_length=40) # TODO: tune max_length of this field 612 | 613 | image_low_resolution = models.URLField(max_length=200) 614 | image_standard_resolution = models.URLField(max_length=200) 615 | image_thumbnail = models.URLField(max_length=200) 616 | 617 | video_low_bandwidth = models.URLField(max_length=130) 618 | video_low_resolution = models.URLField(max_length=130) 619 | video_standard_resolution = models.URLField(max_length=130) 620 | 621 | created_time = models.DateTimeField() 622 | 623 | comments_count = models.PositiveIntegerField(null=True, default=0) 624 | likes_count = models.PositiveIntegerField(null=True, default=0) 625 | 626 | user = models.ForeignKey('User', related_name="media_feed") 627 | location = models.ForeignKey('Location', null=True, related_name="media_feed") 628 | likes_users = ManyToManyHistoryField('User', related_name="likes_media") 629 | tags = models.ManyToManyField('Tag', related_name='media_feed') 630 | 631 | remote = MediaManager(remote_pk=('remote_id',), methods={ 632 | 'get': 'media', 633 | 'user_recent_media': 'user_recent_media', 634 | 'tag_recent_media': 'tag_recent_media', 635 | 'location_recent_media': 'location_recent_media', 636 | }) 637 | 638 | def get_url(self): 639 | return self.link 640 | 641 | def __unicode__(self): 642 | return self.caption 643 | 644 | def parse(self): 645 | self._response['remote_id'] = self._response.pop('id') 646 | 647 | for prefix in ['video', 'image']: 648 | key = '%ss' % prefix 649 | if key in self._response: 650 | for k, v in self._response[key].items(): 651 | media = self._response[key][k] 652 | if isinstance(media, ApiModel): 653 | media = media.__dict__ 654 | self._response['%s_%s' % (prefix, k)] = media['url'] 655 | 656 | if not isinstance(self._response['created_time'], datetime): 657 | self._response['created_time'] = timestamp_to_datetime(self._response['created_time']) 658 | 659 | self._response['created_time'] = datetime.fromtimestamp(calendar.timegm(self._response['created_time'].timetuple())) 660 | 661 | if 'comment_count' in self._response: 662 | self._response['comments_count'] = self._response.pop('comment_count') 663 | elif 'comments' in self._response: 664 | self._response['comments_count'] = self._response.pop('comments')['count'] 665 | 666 | if 'like_count' in self._response: 667 | self._response['likes_count'] = self._response.pop('like_count') 668 | elif 'likes' in self._response: 669 | self._response['likes_count'] = self._response.pop('likes')['count'] 670 | 671 | if isinstance(self._response['caption'], ApiModel): 672 | self._response['caption'] = self._response['caption'].text 673 | elif isinstance(self._response['caption'], dict): 674 | self._response['caption'] = self._response['caption']['text'] 675 | 676 | # if 'likes' in self._response: 677 | # self._response['likes_users'] = self._response.pop('likes') 678 | 679 | super(Media, self).parse() 680 | 681 | def fetch_comments(self): 682 | return Comment.remote.fetch_media_comments(self) 683 | 684 | def fetch_likes(self): 685 | return User.remote.fetch_media_likes(self) 686 | 687 | def save(self, *args, **kwargs): 688 | if self.caption is None: 689 | self.caption = '' 690 | 691 | super(Media, self).save(*args, **kwargs) 692 | 693 | for field, relations in self._relations_post_save['fk'].items(): 694 | extra_fields = {'media_id': self.pk, 'owner_id': self.user_id} if field == 'comments' else {} 695 | for instance in relations: 696 | instance.__dict__.update(extra_fields) 697 | instance.__class__.remote.get_or_create_from_instance(instance) 698 | 699 | for field, relations in self._relations_post_save['m2m'].items(): 700 | for instance in relations: 701 | instance = instance.__class__.remote.get_or_create_from_instance(instance) 702 | getattr(self, field).add(instance) 703 | 704 | 705 | class CommentManager(InstagramManager): 706 | def fetch_media_comments(self, media): 707 | response = self.api_call('comments', media.remote_id) 708 | 709 | extra_fields = {'fetched': timezone.now(), 'media_id': media.pk, 'owner_id': media.user_id} 710 | result = self.parse_response(response, extra_fields) 711 | 712 | instances = self.model.objects.none() 713 | for instance in result: 714 | instance = self.get_or_create_from_instance(instance) 715 | instances |= instance.__class__.objects.filter(pk=instance.pk) 716 | 717 | return instances 718 | 719 | 720 | class Comment(InstagramBaseModel): 721 | owner = models.ForeignKey(User, related_name='media_comments') 722 | user = models.ForeignKey(User, related_name='comments') 723 | media = models.ForeignKey(Media, related_name="comments") 724 | 725 | id = models.BigIntegerField(primary_key=True) 726 | text = models.TextField() 727 | created_time = models.DateTimeField() 728 | 729 | remote = CommentManager(methods={ 730 | 'comments': 'media_comments', 731 | }) 732 | 733 | def get_url(self): 734 | return self.media.link 735 | 736 | def parse(self): 737 | self._response['created_time'] = self._response.pop('created_at') 738 | super(Comment, self).parse() 739 | 740 | 741 | class TagManager(InstagramSearchManager): 742 | pass 743 | 744 | 745 | class Tag(InstagramBaseModel): 746 | _refresh_pk = 'name' 747 | name = models.CharField(max_length=50, unique=True) 748 | media_count = models.PositiveIntegerField(null=True) 749 | 750 | remote = TagManager(remote_pk=('name',), methods={ 751 | 'get': 'tag', 752 | 'search': 'tag_search' 753 | }) 754 | 755 | def __unicode__(self): 756 | return '#%s' % self.name 757 | 758 | def fetch_media(self, **kwargs): 759 | return Media.remote.fetch_tag_media(tag=self, **kwargs) 760 | 761 | 762 | class LocationManager(InstagramSearchManager): 763 | pass 764 | 765 | 766 | class Location(InstagramBaseModel): 767 | id = models.BigIntegerField(primary_key=True) 768 | name = models.CharField(max_length=100) 769 | latitude = models.FloatField(null=True) 770 | longitude = models.FloatField(null=True) 771 | media_count = models.PositiveIntegerField(null=True) 772 | 773 | remote = LocationManager(methods={ 774 | 'get': 'location', 775 | 'search': 'location_search' 776 | }) 777 | 778 | def __unicode__(self): 779 | return self.name 780 | 781 | def fetch_media(self, **kwargs): 782 | return Media.remote.fetch_location_media(location=self, **kwargs) 783 | 784 | def parse(self): 785 | super(Location, self).parse() 786 | if self._response['point']: 787 | self.latitude = self._response['point'].latitude 788 | self.longitude = self._response['point'].longitude 789 | --------------------------------------------------------------------------------