Skip to content
683 lines (549 sloc) 22.7 KB
from __future__ import unicode_literals
from django.utils.encoding import python_2_unicode_compatible
from django.conf import settings
try:
from django.contrib.contenttypes.fields import GenericForeignKey
except ImportError:
from django.contrib.contenttypes.generic import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.db.models.base import ModelBase
from django_facebook import model_managers, settings as facebook_settings
from open_facebook.utils import json, camel_to_underscore
from datetime import timedelta
from django_facebook.utils import compatible_datetime as datetime, \
get_model_for_attribute, get_user_attribute, get_instance_for_attribute, \
try_get_profile, update_user_attributes
from django_facebook.utils import get_user_model, get_profile
from open_facebook.exceptions import OAuthException
import logging
import os
logger = logging.getLogger(__name__)
def get_user_model_setting():
from django.conf import settings
default = 'auth.User'
user_model_setting = getattr(settings, 'AUTH_USER_MODEL', default)
return user_model_setting
def validate_settings():
'''
Checks our Facebook and Django settings and looks for common errors
'''
from django.conf import settings
from django_facebook import settings as facebook_settings
if facebook_settings.FACEBOOK_SKIP_VALIDATE:
return
# check for required settings
if not facebook_settings.FACEBOOK_APP_ID:
logger.warn('Warning FACEBOOK_APP_ID isnt specified')
if not facebook_settings.FACEBOOK_APP_SECRET:
logger.warn('Warning FACEBOOK_APP_SECRET isnt specified')
# warn on things which will cause bad performance
if facebook_settings.FACEBOOK_STORE_LIKES or facebook_settings.FACEBOOK_STORE_FRIENDS:
if not facebook_settings.FACEBOOK_CELERY_STORE:
msg = '''Storing friends or likes without using Celery will significantly slow down your login
Its recommended to enable FACEBOOK_CELERY_STORE or disable FACEBOOK_STORE_FRIENDS and FACEBOOK_STORE_LIKES'''
logger.warn(msg)
# make sure the context processors are present
required = ['django_facebook.context_processors.facebook',
'django.core.context_processors.request']
context_processors = settings.TEMPLATE_CONTEXT_PROCESSORS
for context_processor in required:
if context_processor not in context_processors:
logger.warn(
'Required context processor %s wasnt found', context_processor)
backends = settings.AUTHENTICATION_BACKENDS
required = 'django_facebook.auth_backends.FacebookBackend'
if required not in backends:
logger.warn('Required auth backend %s wasnt found', required)
validate_settings()
if facebook_settings.FACEBOOK_PROFILE_IMAGE_PATH:
PROFILE_IMAGE_PATH = settings.FACEBOOK_PROFILE_IMAGE_PATH
else:
PROFILE_IMAGE_PATH = os.path.join('images', 'facebook_profiles/%Y/%m/%d')
class FACEBOOK_OG_STATE:
class NOT_CONNECTED:
'''
The user has not connected their profile with Facebook
'''
pass
class CONNECTED:
'''
The user has connected their profile with Facebook, but isn't
setup for Facebook sharing
- sharing is either disabled
- or we have no valid access token
'''
pass
class SHARING(CONNECTED):
'''
The user is connected to Facebook and sharing is enabled
'''
pass
@python_2_unicode_compatible
class BaseFacebookModel(models.Model):
'''
Abstract class to add to your profile or user model.
NOTE: If you don't use this this abstract class, make sure you copy/paste
the fields in.
'''
about_me = models.TextField(blank=True, null=True)
facebook_id = models.BigIntegerField(blank=True, unique=True, null=True)
access_token = models.TextField(
blank=True, help_text='Facebook token for offline access', null=True)
facebook_name = models.CharField(max_length=255, blank=True, null=True)
facebook_profile_url = models.TextField(blank=True, null=True)
website_url = models.TextField(blank=True, null=True)
blog_url = models.TextField(blank=True, null=True)
date_of_birth = models.DateField(blank=True, null=True)
gender = models.CharField(max_length=1, choices=(
('m', 'Male'), ('f', 'Female')), blank=True, null=True)
raw_data = models.TextField(blank=True, null=True)
# the field which controls if we are sharing to facebook
facebook_open_graph = models.NullBooleanField(
help_text='Determines if this user want to share via open graph')
# set to true if we require a new access token
new_token_required = models.BooleanField(default=False,
help_text='Set to true if the access token is outdated or lacks permissions')
@property
def open_graph_new_token_required(self):
'''
Shows if we need to (re)authenticate the user for open graph sharing
'''
reauthentication = False
if self.facebook_open_graph and self.new_token_required:
reauthentication = True
elif self.facebook_open_graph is None:
reauthentication = True
return reauthentication
def __str__(self):
return self.get_user().username
class Meta:
abstract = True
def refresh(self):
'''
Get the latest version of this object from the db
'''
return self.__class__.objects.get(id=self.id)
def get_user(self):
'''
Since this mixin can be used both for profile and user models
'''
if hasattr(self, 'user'):
user = self.user
else:
user = self
return user
def get_user_id(self):
'''
Since this mixin can be used both for profile and user_id models
'''
if hasattr(self, 'user_id'):
user_id = self.user_id
else:
user_id = self.id
return user_id
@property
def facebook_og_state(self):
if not self.facebook_id:
state = FACEBOOK_OG_STATE.NOT_CONNECTED
elif self.access_token and self.facebook_open_graph:
state = FACEBOOK_OG_STATE.SHARING
else:
state = FACEBOOK_OG_STATE.CONNECTED
return state
def likes(self):
likes = FacebookLike.objects.filter(user_id=self.get_user_id())
return likes
def friends(self):
friends = FacebookUser.objects.filter(user_id=self.get_user_id())
return friends
def disconnect_facebook(self):
self.access_token = None
self.new_token_required = False
self.facebook_id = None
def clear_access_token(self):
self.access_token = None
self.new_token_required = False
self.save()
def update_access_token(self, new_value):
'''
Updates the access token
**Example**::
# updates to 123 and sets new_token_required to False
profile.update_access_token(123)
:param new_value:
The new value for access_token
'''
self.access_token = new_value
self.new_token_required = False
def extend_access_token(self):
'''
https://developers.facebook.com/roadmap/offline-access-removal/
We can extend the token only once per day
Normal short lived tokens last 1-2 hours
Long lived tokens (given by extending) last 60 days
The token can be extended multiple times, supposedly on every visit
'''
logger.info('extending access token for user %s', self.get_user())
results = None
if facebook_settings.FACEBOOK_CELERY_TOKEN_EXTEND:
from django_facebook import tasks
tasks.extend_access_token.delay(self, self.access_token)
else:
results = self._extend_access_token(self.access_token)
return results
def _extend_access_token(self, access_token):
from open_facebook.api import FacebookAuthorization
results = FacebookAuthorization.extend_access_token(access_token)
access_token = results['access_token']
old_token = self.access_token
token_changed = access_token != old_token
message = 'a new' if token_changed else 'the same'
log_format = 'Facebook provided %s token, which expires at %s'
expires_delta = timedelta(days=60)
logger.info(log_format, message, expires_delta)
if token_changed:
logger.info('Saving the new access token')
self.update_access_token(access_token)
self.save()
from django_facebook.signals import facebook_token_extend_finished
facebook_token_extend_finished.send(
sender=get_user_model(), user=self.get_user(), profile=self,
token_changed=token_changed, old_token=old_token
)
return results
def get_offline_graph(self):
'''
Returns a open facebook graph client based on the access token stored
in the user's profile
'''
from open_facebook.api import OpenFacebook
if self.access_token:
graph = OpenFacebook(access_token=self.access_token)
graph.current_user_id = self.facebook_id
return graph
BaseFacebookProfileModel = BaseFacebookModel
class FacebookModel(BaseFacebookModel):
'''
the image field really destroys the subclassability of an abstract model
you always need to customize the upload settings and storage settings
thats why we stick it in a separate class
override the BaseFacebookProfile if you want to change the image
'''
image = models.ImageField(blank=True, null=True,
upload_to=PROFILE_IMAGE_PATH, max_length=255)
def profile_or_self(self):
user_or_profile_model = get_model_for_attribute('facebook_id')
user_model = get_user_model()
if user_or_profile_model == user_model:
return self
else:
return get_profile(self)
class Meta:
abstract = True
# better name for the mixin now that it can also be used for user models
FacebookProfileModel = FacebookModel
@python_2_unicode_compatible
class FacebookUser(models.Model):
'''
Model for storing a users friends
'''
# in order to be able to easily move these to an another db,
# use a user_id and no foreign key
user_id = models.IntegerField()
facebook_id = models.BigIntegerField()
name = models.TextField(blank=True, null=True)
gender = models.CharField(choices=(
('F', 'female'), ('M', 'male')), blank=True, null=True, max_length=1)
objects = model_managers.FacebookUserManager()
class Meta:
unique_together = ['user_id', 'facebook_id']
def __str__(self):
return u'Facebook user %s' % self.name
class FacebookLike(models.Model):
'''
Model for storing all of a users fb likes
'''
# in order to be able to easily move these to an another db,
# use a user_id and no foreign key
user_id = models.IntegerField()
facebook_id = models.BigIntegerField()
name = models.TextField(blank=True, null=True)
category = models.TextField(blank=True, null=True)
created_time = models.DateTimeField(blank=True, null=True)
class Meta:
unique_together = ['user_id', 'facebook_id']
class FacebookProfile(FacebookProfileModel):
'''
Not abstract version of the facebook profile model
Use this by setting
AUTH_PROFILE_MODULE = 'django_facebook.FacebookProfile'
'''
user = models.OneToOneField(get_user_model_setting())
if getattr(settings, 'AUTH_USER_MODEL', None) == 'django_facebook.FacebookCustomUser':
try:
from django.contrib.auth.models import AbstractUser, UserManager
class FacebookCustomUser(AbstractUser, FacebookModel):
'''
The django 1.5 approach to adding the facebook related fields
'''
objects = UserManager()
# add any customizations you like
state = models.CharField(max_length=255, blank=True, null=True)
except ImportError as e:
logger.info('Couldnt setup FacebookUser, got error %s', e)
class BaseModelMetaclass(ModelBase):
'''
Cleaning up the table naming conventions
'''
def __new__(cls, name, bases, attrs):
super_new = ModelBase.__new__(cls, name, bases, attrs)
module_name = camel_to_underscore(name)
app_label = super_new.__module__.split('.')[-2]
db_table = '%s_%s' % (app_label, module_name)
django_default = '%s_%s' % (app_label, name.lower())
if not getattr(super_new._meta, 'proxy', False):
db_table_is_default = django_default == super_new._meta.db_table
# Don't overwrite when people customize the db_table
if db_table_is_default:
super_new._meta.db_table = db_table
return super_new
@python_2_unicode_compatible
class BaseModel(models.Model):
'''
Stores the fields common to all incentive models
'''
__metaclass__ = BaseModelMetaclass
def __str__(self):
'''
Looks at some common ORM naming standards and tries to display those before
default to the django default
'''
attributes = ['name', 'title', 'slug']
name = None
for a in attributes:
if hasattr(self, a):
name = getattr(self, a)
if not name:
name = repr(self.__class__)
return name
class Meta:
abstract = True
@python_2_unicode_compatible
class CreatedAtAbstractBase(BaseModel):
'''
Stores the fields common to all incentive models
'''
updated_at = models.DateTimeField(auto_now=True)
created_at = models.DateTimeField(auto_now_add=True)
# determine if we should clean this model
auto_clean = False
def save(self, *args, **kwargs):
'''
Allow for auto clean support
'''
if self.auto_clean:
self.clean()
saved = models.Model.save(self, *args, **kwargs)
return saved
def __str__(self):
'''
Looks at some common ORM naming standards and tries to display those before
default to the django default
'''
attributes = ['name', 'title', 'slug']
name = None
for a in attributes:
if hasattr(self, a):
name = getattr(self, a)
if not name:
name = repr(self.__class__)
return name
def __repr__(self):
return '<%s[%s]>' % (self.__class__.__name__, self.pk)
class Meta:
abstract = True
class OpenGraphShare(BaseModel):
'''
Object for tracking all shares to Facebook
Used for statistics and evaluating how things are going
I recommend running this in a task
**Example usage**::
from user.models import OpenGraphShare
user = UserObject
url = 'http://www.fashiolista.com/'
kwargs = dict(list=url)
share = OpenGraphShare.objects.create(
user = user,
action_domain='fashiolista:create',
content_object=self,
)
share.set_share_dict(kwargs)
share.save()
result = share.send()
**Advanced usage**::
share.send()
share.update(message='Hello world')
share.remove()
share.retry()
Using this model has the advantage that it allows us to
- remove open graph shares (since we store the Facebook id)
- retry open graph shares, which is handy in case of
- updated access tokens (retry all shares from this user in the last facebook_settings.FACEBOOK_OG_SHARE_RETRY_DAYS)
- Facebook outages (Facebook often has minor interruptions, retry in 15m, for max facebook_settings.FACEBOOK_OG_SHARE_RETRIES)
'''
objects = model_managers.OpenGraphShareManager()
user = models.ForeignKey(get_user_model_setting())
# domain stores
action_domain = models.CharField(max_length=255)
facebook_user_id = models.BigIntegerField()
# what we are sharing, dict and object
share_dict = models.TextField(blank=True, null=True)
content_type = models.ForeignKey(ContentType, blank=True, null=True)
object_id = models.PositiveIntegerField(blank=True, null=True)
content_object = GenericForeignKey('content_type', 'object_id')
# completion data
error_message = models.TextField(blank=True, null=True)
last_attempt = models.DateTimeField(
blank=True, null=True, auto_now_add=True)
retry_count = models.IntegerField(blank=True, null=True)
# only written if we actually succeed
share_id = models.CharField(blank=True, null=True, max_length=255)
completed_at = models.DateTimeField(blank=True, null=True)
# tracking removals
removed_at = models.DateTimeField(blank=True, null=True)
# updated at and created at, last one needs an index
updated_at = models.DateTimeField(auto_now=True)
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
class Meta:
db_table = facebook_settings.FACEBOOK_OG_SHARE_DB_TABLE
def save(self, *args, **kwargs):
if self.user and not self.facebook_user_id:
profile = get_profile(self.user)
self.facebook_user_id = get_user_attribute(
self.user, profile, 'facebook_id')
return BaseModel.save(self, *args, **kwargs)
def send(self, graph=None, shared_explicitly=False):
result = None
# update the last attempt
self.last_attempt = datetime.now()
self.save()
# see if the graph is enabled
profile = try_get_profile(self.user)
user_or_profile = get_instance_for_attribute(
self.user, profile, 'access_token')
graph = graph or user_or_profile.get_offline_graph()
user_enabled = shared_explicitly or \
(user_or_profile.facebook_open_graph and self.facebook_user_id)
# start sharing
if graph and user_enabled:
graph_location = '%s/%s' % (
self.facebook_user_id, self.action_domain)
share_dict = self.get_share_dict()
from open_facebook.exceptions import OpenFacebookException
try:
result = graph.set(graph_location, **share_dict)
share_id = result.get('id')
if not share_id:
error_message = 'No id in Facebook response, found %s for url %s with data %s' % (
result, graph_location, share_dict)
logger.error(error_message)
raise OpenFacebookException(error_message)
self.share_id = share_id
self.error_message = None
self.completed_at = datetime.now()
self.save()
except OpenFacebookException as e:
logger.warn(
'Open graph share failed, writing message %s' % str(e))
self.error_message = repr(e)
self.save()
# maybe we need a new access token
new_token_required = self.exception_requires_new_token(
e, graph)
# verify that the token didnt change in the mean time
user_or_profile = user_or_profile.__class__.objects.get(
id=user_or_profile.id)
token_changed = graph.access_token != user_or_profile.access_token
logger.info('new token required is %s and token_changed is %s',
new_token_required, token_changed)
if new_token_required and not token_changed:
logger.info(
'a new token is required, setting the flag on the user or profile')
# time to ask the user for a new token
update_user_attributes(self.user, profile, dict(
new_token_required=True), save=True)
elif not graph:
self.error_message = 'no graph available'
self.save()
elif not user_enabled:
self.error_message = 'user not enabled'
self.save()
return result
def exception_requires_new_token(self, e, graph):
'''
Determines if the exceptions is something which requires us to
ask for a new token. Examples are:
Error validating access token: Session has expired at unix time
1350669826. The current unix time is 1369657666.
(#200) Requires extended permission: publish_actions (error code 200)
'''
new_token = False
if isinstance(e, OAuthException):
new_token = True
# if we have publish actions than our token is ok
# we get in this flow if Facebook mistakenly marks exceptions
# as oAuthExceptions
publish_actions = graph.has_permissions(['publish_actions'])
if publish_actions:
new_token = False
return new_token
def update(self, data, graph=None):
'''
Update the share with the given data
'''
result = None
profile = get_profile(self.user)
graph = graph or profile.get_offline_graph()
# update the share dict so a retry will do the right thing
# just in case we fail the first time
shared = self.update_share_dict(data)
self.save()
# broadcast the change to facebook
if self.share_id:
result = graph.set(self.share_id, **shared)
return result
def remove(self, graph=None):
if not self.share_id:
raise ValueError('Can only delete shares which have an id')
# see if the graph is enabled
profile = get_profile(self.user)
graph = graph or profile.get_offline_graph()
response = None
if graph:
response = graph.delete(self.share_id)
self.removed_at = datetime.now()
self.save()
return response
def retry(self, graph=None, reset_retries=False):
if self.completed_at:
raise ValueError('You can\'t retry completed shares')
if reset_retries:
self.retry_count = 0
# handle the case where self.retry_count = None
self.retry_count = self.retry_count + 1 if self.retry_count else 1
# actually retry now
result = self.send(graph=graph)
return result
def set_share_dict(self, share_dict):
share_dict_string = json.dumps(share_dict)
self.share_dict = share_dict_string
def get_share_dict(self):
share_dict_string = self.share_dict
share_dict = json.loads(share_dict_string)
return share_dict
def update_share_dict(self, share_dict):
old_share_dict = self.get_share_dict()
old_share_dict.update(share_dict)
self.set_share_dict(old_share_dict)
return old_share_dict
Something went wrong with that request. Please try again.