forked from tschellenbach/Django-facebook
-
Notifications
You must be signed in to change notification settings - Fork 1
/
models.py
679 lines (547 loc) · 22.6 KB
/
models.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
from __future__ import unicode_literals
from django.utils.encoding import python_2_unicode_compatible
from django.conf import settings
from django.contrib.contenttypes import generic
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
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 self.get_profile()
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 = generic.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 = self.user.get_profile()
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 = self.user.get_profile()
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 = self.user.get_profile()
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