diff --git a/docs/installation.rst b/docs/installation.rst index c4dfd5d..3b302ac 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -54,7 +54,7 @@ entry for every app in your project. It is recommended to use different app 'CANVAS-PAGE': 'https://apps.facebook.com/yourapp', 'CANVAS-URL': '', 'SECURE-CANVAS-URL': '', - 'REDIRECT-URL': '', + 'REDIRECT-URL': 'mydomain.com/facebook/redirect/?next=%2F%2Fwww.facebook.com%2Fpages%2F', 'DOMAIN' : 'localhost.local:8000', 'NAMESPACE': 'mynamespace', } @@ -75,7 +75,7 @@ Add this to the header section of your base template:: {% load fb_tags %} @@ -87,7 +87,7 @@ Facebook applications in one installation:: {% load fb_tags %} diff --git a/docs/reference.rst b/docs/reference.rst index f278763..ee9a86f 100644 --- a/docs/reference.rst +++ b/docs/reference.rst @@ -8,3 +8,25 @@ Django-facebook-graph reference installation clientside + +Deauthorization callback +------------------------ + +There is a default url that can be called for the deauthorization callback:: + + http:///facebook/deauthorize// + +The app name parameter is optional but needed if you have multiple apps to +decrypt the signed request. The default action is to delete the user model and +all related entries. + + +Testing the deauthorization callback +------------------------------------ + +If you are logged in to django you can test the deauthorization callback by calling this url:: + + http://localhost.local:8000/facebook/deauthorize//?userid= + +You will be shown a page like the one in the django admin +that shows you which entries would be deleted on a deauthorization callback. \ No newline at end of file diff --git a/facebook/feincms/middleware.py b/facebook/feincms/middleware.py index 4c32dc3..e0dab12 100644 --- a/facebook/feincms/middleware.py +++ b/facebook/feincms/middleware.py @@ -9,6 +9,9 @@ class PreventForeignApp(object): associated page """ def process_request(self, request): + if 'deauthorize' in request.path: + return None + if 'facebook' in request.session and 'signed_request' in request.session['facebook']: facebook_page = get_page_from_request(request) signed_request = request.session['facebook']['signed_request'] diff --git a/facebook/feincms/views.py b/facebook/feincms/views.py index dc288ae..bd8fafe 100644 --- a/facebook/feincms/views.py +++ b/facebook/feincms/views.py @@ -1,4 +1,5 @@ from django.conf import settings +from django.http import HttpResponse from django.shortcuts import redirect from feincms.module.page.models import Page @@ -9,8 +10,9 @@ def redirect_to_slug(request): try: facebook_page = request.session['facebook']['signed_request']['page'] - except e: - return '' % e + except KeyError as e: + return HttpResponse('' % e) + page = Page.objects.from_request(request, best_match=True) if facebook_page['admin'] and facebook_page['liked']: @@ -32,4 +34,4 @@ def redirect_to_slug(request): try: return redirect(page.get_children().filter(slug='unliked')[0]) except IndexError: - return '' \ No newline at end of file + return HttpResponse('') \ No newline at end of file diff --git a/facebook/graph.py b/facebook/graph.py index cc862d7..25a046b 100644 --- a/facebook/graph.py +++ b/facebook/graph.py @@ -84,9 +84,19 @@ def get_objects(self, ids, **args): return self.request("", args) def get_connections(self, id, connection_name, **args): - """Fetchs the connections for given object.""" + """Fetches the connections for given object.""" return self.request(id + "/" + connection_name, args) + def batch_request(self, batch): + """ Combines multiple requests into one. + Batch must be a List of Dicts in the format: + [{"method": "GET", "relative_url": "me"}, + {"method": "GET", "relative_url": "me/friends?limit=50"}] + It returns a list of response dicts: + http://developers.facebook.com/docs/reference/api/batch/ + """ + return self.request('', None, {'batch': json.dumps(batch)}) + def put_object(self, parent_object, connection_name, **data): """Writes the given object to the graph, connected to the given parent. diff --git a/facebook/middleware.py b/facebook/middleware.py index e8e5bbd..6f67389 100644 --- a/facebook/middleware.py +++ b/facebook/middleware.py @@ -31,6 +31,10 @@ def process_request(self, request): logger.debug('Request Method = %s\n, AccessToken=%s' % (request.method, fb.access_token)) + if 'deauthorize' in request.path: + logger.debug('deauthorize call, SignedRequestMiddleware returning...') + return None + if 'feincms' in settings.INSTALLED_APPS: # if feincms is installed, try to get the application from the page from facebook.feincms.utils import get_application_from_request diff --git a/facebook/modules/base.py b/facebook/modules/base.py index 54ce763..3d86648 100644 --- a/facebook/modules/base.py +++ b/facebook/modules/base.py @@ -120,6 +120,9 @@ def to_django(self, response, graph=None, save_related=True): obj.get_from_facebook(graph=graph, save=True) elif created and not save_related and 'name' in val: obj._name = val['name'] + elif not created and 'name' in val: + # make sure name is defined: + obj._name = val['name'] elif isinstance(fieldclass, models.DateField): # Check for Birthday: setattr(self, field, datetime.strptime(val, "%m/%d/%Y").date()) @@ -133,8 +136,8 @@ def to_django(self, response, graph=None, save_related=True): setattr(self, '_%s_id' % prop, val['id']) - def save_from_facebook(self, response, update_slug=False, save_related=True): - self.to_django(response, save_related) + def save_from_facebook(self, response, update_slug=False, save_related=True, graph=None): + self.to_django(response, graph=graph, save_related=save_related) if update_slug or not self.slug: self.generate_slug() self.save() diff --git a/facebook/modules/connections/milestone/__init__.py b/facebook/modules/connections/milestone/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/facebook/modules/connections/milestone/models.py b/facebook/modules/connections/milestone/models.py new file mode 100644 index 0000000..689cd36 --- /dev/null +++ b/facebook/modules/connections/milestone/models.py @@ -0,0 +1,26 @@ +from django.db import models +from django.contrib.contenttypes.models import ContentType +from django.contrib.contenttypes import generic +from facebook.modules.base import Base + +class Milestone(Base): + id = models.BigIntegerField(primary_key=True) + # from is a Generic FK to User or Page. + _from = generic.GenericForeignKey('__profile_type', '__profile_id') + __profile_id = models.BigIntegerField() + __profile_type = models.ForeignKey(ContentType) + _created_time = models.DateTimeField(blank=True, null=True) + _updated_time = models.DateTimeField(blank=True, null=True) + _start_time = models.DateTimeField(blank=True, null=True) + _end_time = models.DateTimeField(blank=True, null=True) + _title = models.CharField(max_lenght=255, blank=True, null=True) + _description = models.TextField() + + class Meta: + ordering = ['-_start_time'] + get_latest_by = '_start_time' + verbose_name = _('Milestone') + verbose_name_plural = _('Milestones') + + def __unicode__(self): + return self.title \ No newline at end of file diff --git a/facebook/modules/connections/post/admin.py b/facebook/modules/connections/post/admin.py index 317b31d..cdd351c 100644 --- a/facebook/modules/connections/post/admin.py +++ b/facebook/modules/connections/post/admin.py @@ -1,5 +1,8 @@ +from django.contrib.admin.options import InlineModelAdmin from facebook.modules.base import AdminBase +from django.contrib.contenttypes.models import ContentType + class PostAdmin(AdminBase): def picture_link(self, obj): return '' % (obj._picture) @@ -17,3 +20,4 @@ def icon_link(self, obj): '_properties', '_actions', '_privacy', '_likes', '_comments', '_targeting') date_hierarchy = '_updated_time' list_filter = ('_type',) + diff --git a/facebook/modules/connections/post/models.py b/facebook/modules/connections/post/models.py index 027c72c..2add096 100644 --- a/facebook/modules/connections/post/models.py +++ b/facebook/modules/connections/post/models.py @@ -1,5 +1,7 @@ from django.db import models from django.utils.translation import ugettext_lazy as _ +from django.contrib.contenttypes.models import ContentType +from django.contrib.contenttypes import generic from facebook.fields import JSONField from facebook.modules.base import Base @@ -55,7 +57,7 @@ class Facebook: def __unicode__(self): - return u'%s, %s %s' % (self.id, self._message[:50], self._picture) + return u'%s, %s' % (self.id, self._message[:50]) # Deal with changes from Facebook @property @@ -70,6 +72,8 @@ def _subject(self): # Note has no type attribute. def guess_type(self): + if self._type: + return self._type if self._subject: self._type = 'note' elif self._story and self._picture: @@ -126,9 +130,26 @@ def status(self): class Post(PostBase): + class Meta(PostBase.Meta): app_label = 'facebook' verbose_name = _('Post') verbose_name_plural = _('Posts') abstract = False + +class PagePost(models.Model): + """ This is a generic intermediate model to attach posts to Pages or Profiles + """ + post = models.ForeignKey(Post) + content_type = models.ForeignKey(ContentType) + object_id = models.BigIntegerField() + to = generic.GenericForeignKey() + created = models.DateTimeField(auto_now_add=True) + + class Meta: + app_label = 'facebook' + unique_together = (('post', 'content_type', 'object_id'),) + + def __unicode__(self): + return u'%s to %s' % (self.post, self.to) diff --git a/facebook/modules/profile/models.py b/facebook/modules/profile/models.py index 12cc8cc..56a1c28 100644 --- a/facebook/modules/profile/models.py +++ b/facebook/modules/profile/models.py @@ -1,7 +1,8 @@ +from django.contrib.contenttypes import generic from django.db import models from django.template.defaultfilters import slugify from django.utils.translation import ugettext_lazy as _ -from django import forms +from django.conf import settings from facebook.modules.base import Base, AdminBase @@ -18,6 +19,12 @@ class Profile(Base): _pic_large = models.URLField(max_length=500, blank=True, null=True, verify_exists=False, editable=False) _pic_crop = models.URLField(max_length=500, blank=True, null=True, verify_exists=False, editable=False) + # get all posts for the selected profile + # posts = [p.post for p in page.post_throughs.select_related('post').all()] + if 'facebook.modules.connections.post' in settings.INSTALLED_APPS: + post_throughs = generic.GenericRelation('PagePost', + related_name="%(app_label)s_%(class)s_related") + class Meta(Base.Meta): abstract = True app_label = 'facebook' @@ -45,4 +52,4 @@ class ProfileAdmin(AdminBase): def pic_img(self, obj): return '' % obj._picture if obj._picture else '' pic_img.allow_tags = True - pic_img.short_description = _('Picture') \ No newline at end of file + pic_img.short_description = _('Picture') diff --git a/facebook/modules/profile/page/admin.py b/facebook/modules/profile/page/admin.py index c95d1c8..6b6d5b4 100644 --- a/facebook/modules/profile/page/admin.py +++ b/facebook/modules/profile/page/admin.py @@ -13,7 +13,6 @@ from .models import Page - class PageAdmin(ProfileAdmin): def has_access(self, obj): if obj.updated + datetime.timedelta(days=60) < datetime.datetime.now(): @@ -23,7 +22,30 @@ def has_access(self, obj): has_access.short_description = _('Access Token') has_access.boolean = True - list_display = ('id', 'profile_link', 'slug', '_name', 'pic_img', '_likes', 'has_access') + def token_expires_in(self, obj): + if not obj._access_token_expires: + return '' + expires_in = (obj._access_token_expires - datetime.datetime.now()).days + if expires_in > 10: + return _('%s days' % expires_in) + elif expires_in < 0: + return _('expired') + else: + return _('%s days' % expires_in) + + token_expires_in.short_description = _('expires in') + token_expires_in.allow_tags = True + + def insight_link(self, obj): + if '?' in obj.facebook_link: + return u'%s' % (obj.facebook_link, obj._name) + else: + return u'%s' % (obj.facebook_link, obj._name) + insight_link.allow_tags = True + insight_link.short_description = _('Name') + + + list_display = ('id', 'profile_link', 'slug', 'insight_link', 'pic_img', '_likes', 'has_access', 'token_expires_in') readonly_fields = ('_name', '_picture', '_likes', '_graph', '_link', '_location', '_phone', '_checkins', '_website', '_talking_about_count','_username', '_category') actions = ['get_page_access_token'] @@ -34,14 +56,17 @@ def get_page_access_token(self, request, queryset): app_dict = get_app_dict(default_post_app) token_exchange = do_exchange_token(app_dict, graph.access_token) logger.debug('exchanged token: %s' % token_exchange) + token_expires_in = datetime.timedelta(minutes=60) if 'access_token' in token_exchange: graph.access_token = token_exchange['access_token'] + token_expires_in = datetime.timedelta(days=60) try: response = graph.request('me/accounts/') except GraphAPIError as e: self.message_user(request, 'There was an error: %s' % e.message ) return False #logger.debug(response) + token_expires_in = datetime.datetime.now() + token_expires_in if response and response.get('data', False): data = response['data'] message = {'count': 0, 'message': u''} @@ -51,7 +76,8 @@ def get_page_access_token(self, request, queryset): for page in queryset: if accounts.get(page._id, None): if accounts[page._id].get('access_token', False): - queryset.filter(id=page._id).update(_access_token=accounts[page._id]['access_token']) + queryset.filter(id=page._id).update(_access_token=accounts[page._id]['access_token'], + _access_token_expires=token_expires_in) message['message'] = u'%sSet access token for page %s\n' % (message['message'], page._name) else: message['message'] = u'%sDid not get access token for page %s\n' % (message['message'], page._name) @@ -63,5 +89,4 @@ def get_page_access_token(self, request, queryset): get_page_access_token.short_description = _('Get an access token for the selected page(s)') - admin.site.register(Page, PageAdmin) \ No newline at end of file diff --git a/facebook/modules/profile/page/models.py b/facebook/modules/profile/page/models.py index 777f820..2423a72 100644 --- a/facebook/modules/profile/page/models.py +++ b/facebook/modules/profile/page/models.py @@ -13,6 +13,7 @@ class PageBase(Profile): # Cached Facebook Graph fields for db lookup _likes = models.IntegerField(blank=True, null=True, help_text=_('Cached fancount of the page')) _access_token = models.CharField(max_length=255, blank=True, null=True) + _access_token_expires = models.DateTimeField(blank=True, null=True) _category = models.CharField(_('category'), max_length=255, blank=True, null=True) _location = JSONField(_('location'), blank=True, null=True) _phone = models.CharField(_('phone'), max_length=255, blank=True, null=True) diff --git a/facebook/modules/profile/user/models.py b/facebook/modules/profile/user/models.py index a7662b4..3bfb4ba 100644 --- a/facebook/modules/profile/user/models.py +++ b/facebook/modules/profile/user/models.py @@ -21,6 +21,8 @@ class UserBase(Profile): _location = models.CharField(max_length=70, blank=True, null=True) _gender = models.CharField(max_length=10, blank=True, null=True) _locale = models.CharField(max_length=6, blank=True, null=True) + _timezone = models.IntegerField(blank=True, null=True) + _verified = models.BooleanField(blank=True) friends = models.ManyToManyField('self') diff --git a/facebook/templates/admin/facebook/change_form.html b/facebook/templates/admin/facebook/change_form.html index 22fa7f4..bff4fb8 100644 --- a/facebook/templates/admin/facebook/change_form.html +++ b/facebook/templates/admin/facebook/change_form.html @@ -14,7 +14,7 @@ {% endfor %} Application: {% fb_first_app %}   - +

App Access Token: {{ graph.access_token }}, via {{ graph.via }}

User Access Token: None

diff --git a/facebook/templates/admin/facebook/change_list.html b/facebook/templates/admin/facebook/change_list.html index e11b532..a70a990 100644 --- a/facebook/templates/admin/facebook/change_list.html +++ b/facebook/templates/admin/facebook/change_list.html @@ -9,7 +9,7 @@

Make sure your page has an access token if you would like to post a message in the future. (Select 'get access token' from the menu above.)

The access token is only for the application {% fb_first_app %}.

- +

App Access Token: {% access_token request %}

User Access Token: None

diff --git a/facebook/urls.py b/facebook/urls.py index ce1d1ce..2d6e0cf 100644 --- a/facebook/urls.py +++ b/facebook/urls.py @@ -10,7 +10,7 @@ urlpatterns = patterns('', - url(r'^deauthorize/$', 'facebook.views.deauthorize_and_delete', name='deauthorize'), + url(r'^deauthorize/(?:(?P[A-Za-z0-9_-]+)/)?$', 'facebook.views.deauthorize_and_delete', name='deauthorize'), url(r'^fql/$', 'facebook.views.fql_console', name="fql_console"), url(r'^log_error/$', 'facebook.views.log_error', name="log_error"), url(r'^channel.html$', 'facebook.views.channel', name='channel'), diff --git a/facebook/views.py b/facebook/views.py index f01c23a..46c86a9 100644 --- a/facebook/views.py +++ b/facebook/views.py @@ -1,15 +1,17 @@ import sys, logging, urllib2 from datetime import datetime, timedelta -import urllib -import urlparse from django.conf import settings +from django.contrib import admin +from django.contrib.admin.util import get_deleted_objects +from django.db import router from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseForbidden,\ Http404 from django.views.decorators.csrf import csrf_exempt from django.shortcuts import render_to_response, get_object_or_404, render from django.template import loader, RequestContext from django.views.decorators.http import require_POST +from django.contrib.admin import helpers from facebook.utils import validate_redirect, do_exchange_token from facebook.graph import get_graph @@ -17,6 +19,8 @@ from facebook.session import get_session from facebook.modules.profile.application.utils import get_app_dict from facebook.modules.profile.user.models import User +from facebook.modules.profile.user.admin import UserAdmin +from django.utils.translation import ugettext_lazy as _ logger = logging.getLogger(__name__) @@ -94,14 +98,50 @@ def channel(request): # Deauthorize callback, signed request: {u'issued_at': 1305126336, u'user_id': u'xxxx', u'user': {u'locale': u'de_DE', u'country': u'ch'}, u'algorithm': u'HMAC-SHA256'} @csrf_exempt -def deauthorize_and_delete(request): +def deauthorize_and_delete(request, app_name=None): """ Deletes a user on a deauthorize callback. """ if request.method == 'GET': - raise Http404 + if request.user.is_superuser: + application = get_app_dict(app_name) + # Preview callback + if 'userid' in request.GET: + queryset = User.objects.filter(id=int(request.GET.get('userid'), 0)) + opts = User._meta + modeladmin = UserAdmin(User, admin.site) + using = router.db_for_write(User) + # Populate deletable_objects, a data structure of all related objects that + # will also be deleted. + deletable_objects, perms_needed, protected = get_deleted_objects( + queryset, opts, request.user, modeladmin.admin_site, using) + + context = { + "title": _("Preview deauthorization callback"), + "objects_name": 'User', + "deletable_objects": [deletable_objects], + 'queryset': queryset, + "perms_lacking": perms_needed, + "protected": protected, + "opts": opts, + "root_path": modeladmin.admin_site.root_path, + "app_label": 'facebook', + 'action_checkbox_name': helpers.ACTION_CHECKBOX_NAME, + } + + return render(request, [ + "admin/facebook/delete_selected_confirmation.html", + "admin/delete_selected_confirmation.html" + ], context) + else: + return HttpResponse(u"""Deauthorize callback for app %s + with id %s called with GET. Call with userid= as + parameter to preview cascade.""" % (app_name, application['ID'])) + else: + raise Http404 if 'signed_request' in request.POST: - application = get_app_dict() - parsed_request = parseSignedRequest(request.REQUEST['signed_request'], application['SECRET']) - user = get_object_or_404(User, id=parsed_request['user_id']) + application = get_app_dict(app_name) + parsed_request = parseSignedRequest(request.POST.get('signed_request'), application['SECRET']) + user = get_object_or_404(User, id=int(parsed_request.get('user_id'))) + if settings.DEBUG == False: user.delete() logger.info('Deleting User: %s' % user) @@ -139,8 +179,8 @@ def internal_redirect(request): return HttpResponseForbidden('The next= paramater is not an allowed redirect url.') -""" Allows to register client-side errors. """ def log_error(request): + """ Allows to register client-side errors. """ if not request.is_ajax() or not request.method == 'POST': raise Http404 logger.error(request.POST.get('message'))