# HG changeset patch # User raph # Date 1278662729 -7200 # Node ID fc5ed157ebfee8fe7a68ea70186fe51cb9e44f06 # Parent b5deb8e32219ddcb1bb8274fe4200133937512ed add api: basic auth / unit tests / online doc (based on django-piston) diff -r b5deb8e32219 -r fc5ed157ebfe buildout.cfg --- a/buildout.cfg Fri Jun 11 11:04:23 2010 +0200 +++ b/buildout.cfg Fri Jul 09 10:05:29 2010 +0200 @@ -4,6 +4,8 @@ django python django-extensions + django-piston + omelette develop = . [python] @@ -23,11 +25,13 @@ pythonpath = src src/cm ${django-extensions:location} + ${django-piston:location} eggs = django-flash django-tagging +# django-piston # django-css - chardet +# chardet feedparser PIL BeautifulSoup @@ -43,4 +47,12 @@ [django-extensions] recipe=zerokspot.recipe.git repository=git://github.com/django-extensions/django-extensions.git -#rev=7c73978b55fcadbe2cd6f2abbefbedb5a85c2c8c \ No newline at end of file +#rev=7c73978b55fcadbe2cd6f2abbefbedb5a85c2c8c + +[django-piston] +recipe = mercurialrecipe +repository = http://bitbucket.org/jespern/django-piston + +[omelette] +recipe = collective.recipe.omelette +eggs = ${django:eggs} \ No newline at end of file diff -r b5deb8e32219 -r fc5ed157ebfe src/cm/api/__init__.py diff -r b5deb8e32219 -r fc5ed157ebfe src/cm/api/handlers.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/cm/api/handlers.py Fri Jul 09 10:05:29 2010 +0200 @@ -0,0 +1,251 @@ +from piston.handler import AnonymousBaseHandler, BaseHandler +from piston.utils import rc + +from cm.models import Text,TextVersion, Role, UserRole +from cm.views import get_keys_from_dict, get_textversion_by_keys_or_404, get_text_by_keys_or_404, get_textversion_by_keys_or_404, redirect +from cm.security import get_texts_with_perm, has_perm, get_viewable_comments, \ + has_perm_on_text_api +from cm.security import get_viewable_comments +from cm.utils.embed import embed_html +from cm.views.create import CreateTextContentForm, create_text +from piston.utils import validate +from settings import SITE_URL + +URL_PREFIX = SITE_URL + '/api' + +class AnonymousTextHandler(AnonymousBaseHandler): + type = "Text methods" + title = "Read text info" + fields = ('key', 'title', 'format', 'content', 'created', 'modified', 'nb_comments', 'nb_versions', 'embed_html', ('last_text_version', ('created','modified', 'format', 'title', 'content'))) + allowed_methods = ('GET', ) + model = Text + desc = """ Read text identified by `key`.""" + args = None + + @staticmethod + def endpoint(): + return URL_PREFIX + '/text/{key}/' + + + @has_perm_on_text_api('can_view_text') + def read(self, request, key): + + text = get_text_by_keys_or_404(key) + setattr(text,'nb_comments',len(get_viewable_comments(request, text.last_text_version.comment_set.all(), text))) + setattr(text,'nb_versions',text.get_versions_number()) + setattr(text,'embed_html',embed_html(text.key)) + + return text + +class TextHandler(BaseHandler): + type = "Text methods" + anonymous = AnonymousTextHandler + allowed_methods = ('GET',) + no_display = True + +class AnonymousTextVersionHandler(AnonymousBaseHandler): + type = "Text methods" + title = "Read text version info" + fields = ('key', 'title', 'format', 'content', 'created', 'modified', 'nb_comments',) + allowed_methods = ('GET', ) + model = Text + desc = """ Read text version identified by `version_key` inside text identified by `key`.""" + args = None + + @staticmethod + def endpoint(): + return URL_PREFIX + '/text/{key}/{version_key}/' + + + @has_perm_on_text_api('can_view_text') + def read(self, request, key, version_key): + text_version = get_textversion_by_keys_or_404(version_key, key=key) + setattr(text_version,'nb_comments',len(get_viewable_comments(request, text_version.comment_set.all(), text_version.text))) + + return text_version + +class TextVersionHandler(BaseHandler): + type = "Text methods" + anonymous = AnonymousTextVersionHandler + allowed_methods = ('GET',) + no_display = True + +class AnonymousTextListHandler(AnonymousBaseHandler): + title = "List texts" + type = "Text methods" + fields = ('key', 'title', 'created', 'modified', 'nb_comments', 'nb_versions',) + allowed_methods = ('GET',) + model = Text + desc = """Lists texts on workspace.""" + + @staticmethod + def endpoint(): + return URL_PREFIX + '/text/' + + def read(self, request): + order_by = '-id' + texts = get_texts_with_perm(request, 'can_view_text').order_by(order_by) + return texts + +class TextListHandler(BaseHandler): + title = "Create text" + type = "Text methods" + allowed_methods = ('POST', ) + anonymous = AnonymousTextListHandler + desc = "Create a text with the provided parameters." + args = """
+`title`: title of the text
+`format`: format content ('markdown', 'html')
+`content`: content (in specified format)
+`anon_role`: role to give to anon users: null, 4: commentator, 5: observer
+ """ + + @staticmethod + def endpoint(): + return URL_PREFIX + '/text/' + + def create(self, request): + form = CreateTextContentForm(request.POST) + if form.is_valid(): + text = create_text(request.user, form.cleaned_data) + anon_role = request.POST.get('anon_role', None) + if anon_role: + userrole = UserRole.objects.create(user=None, role=Role.objects.get(id=anon_role), text=text) + return {'key' : text.key , 'version_key' : text.last_text_version.key, 'created': text.created} + else: + resp = rc.BAD_REQUEST + return resp + +from cm.exception import UnauthorizedException +from cm.views.texts import text_delete + +class TextDeleteHandler(BaseHandler): + type = "Text methods" + allowed_methods = ('POST', ) + title = "Delete text" + desc = "Delete the text identified by `key`." + + @staticmethod + def endpoint(): + return URL_PREFIX + '/text/{key}/delete/' + + def create(self, request): + """ + Delete text identified by `key`. + """ + try: + key = request.POST.get('key') + text_delete(request, key=key) + except UnauthorizedException: + return rc.FORBIDDEN + except KeyError: + return rc.BAD_REQUEST + return rc.DELETED + +from cm.views.texts import text_pre_edit + +class TextPreEditHandler(BaseHandler): + type = "Text methods" + allowed_methods = ('POST', ) + title = "Ask for edit impact" + desc = "Returns the number of impacted comments." + args = """
+`new_format`: new format content ('markdown', 'html')
+`new_content`: new content (in specified format)
+ """ + + @staticmethod + def endpoint(): + return URL_PREFIX + '/text/{key}/pre_edit/' + + def create(self, request, key): + return text_pre_edit(request, key=key) + +from cm.views.texts import text_edit + +class TextEditHandler(BaseHandler): + allowed_methods = ('POST', ) + type = "Text methods" + title = "Edit text" + desc = "Update text identified by `key`." + args = """
+`title`: new title of the text
+`format`: new format content ('markdown', 'html')
+`content`: new content (in specified format)
+`note`: note to add to edit
+`new_version`: boolean: should a new version of the text be created?
+`keep_comments`: boolean: should existing comments be keep (if possible)?
+ """ + + @staticmethod + def endpoint(): + return URL_PREFIX + '/text/{key}/edit/' + + + def create(self, request, key): + res = text_edit(request, key=key) + text = get_text_by_keys_or_404(key) + text_version = text.last_text_version + return {'version_key' : text_version.key , 'created': text_version.created} + +from django.contrib.auth import authenticate + +class SetUserHandler(AnonymousBaseHandler): + allowed_methods = ('POST',) + type = "User methods" + title = "Set username and email" + desc = "Set username and email to use when commenting." + args = """
+`user_name`: user's name
+`user_email`: user's email
+ """ + + @staticmethod + def endpoint(): + return URL_PREFIX + '/setuser/' + + def create(self, request): + user_name = request.POST.get('user_name', None) + user_email = request.POST.get('user_email', None) + if user_name and user_email: + response = rc.ALL_OK + response.set_cookie('user_name', user_name) + response.set_cookie('user_email', user_email) + return response + else: + return rc.BAD_REQUEST + + + +from piston.doc import documentation_view + +from piston.handler import handler_tracker +from django.template import RequestContext +from piston.doc import generate_doc +from django.shortcuts import render_to_response + +def documentation(request): + """ + Generic documentation view. Generates documentation + from the handlers you've defined. + """ + docs = [ ] + + for handler in handler_tracker: + doc = generate_doc(handler) + setattr(doc,'type', handler.type) + docs.append(doc) + + def _compare(doc1, doc2): + #handlers and their anonymous counterparts are put next to each other. + name1 = doc1.name.replace("Anonymous", "") + name2 = doc2.name.replace("Anonymous", "") + return cmp(name1, name2) + + #docs.sort(_compare) + + return render_to_response('api_doc.html', + { 'docs': docs }, RequestContext(request)) + +from piston.doc import generate_doc +DocHandler = generate_doc(TextPreEditHandler) diff -r b5deb8e32219 -r fc5ed157ebfe src/cm/api/urls.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/cm/api/urls.py Fri Jul 09 10:05:29 2010 +0200 @@ -0,0 +1,28 @@ +from django.conf.urls.defaults import * + +from piston.resource import Resource +from piston.authentication import HttpBasicAuthentication + +from cm.api.handlers import * +auth = HttpBasicAuthentication(realm='Comt API') + +text_handler = Resource(handler=TextHandler, authentication=auth) +textversion_handler = Resource(handler=TextVersionHandler, authentication=auth) +text_list_handler = Resource(handler=TextListHandler, authentication=auth) +text_delete_handler = Resource(handler=TextDeleteHandler, authentication=auth) +text_pre_edit_handler = Resource(handler=TextPreEditHandler, authentication=auth) +text_edit_handler = Resource(handler=TextEditHandler, authentication=auth) +setuser_handler = Resource(handler=SetUserHandler, authentication=None) + +#doc_handler = Resource(handler=DocHandler) + +urlpatterns = patterns('', + url(r'^text/(?P\w*)/$', text_handler), + url(r'^text/$', text_list_handler), + url(r'^text/(?P\w*)/delete/$', text_delete_handler), + url(r'^text/(?P\w*)/pre_edit/$', text_pre_edit_handler), + url(r'^text/(?P\w*)/edit/$', text_edit_handler), + url(r'^text/(?P\w*)/(?P\w*)/$', textversion_handler), + url(r'^setuser/$', setuser_handler), + url(r'^doc/$', documentation), +) diff -r b5deb8e32219 -r fc5ed157ebfe src/cm/fixtures/test_content.yaml --- a/src/cm/fixtures/test_content.yaml Fri Jun 11 11:04:23 2010 +0200 +++ b/src/cm/fixtures/test_content.yaml Fri Jul 09 10:05:29 2010 +0200 @@ -360,6 +360,58 @@ adminkey: "text_adminkey_3" user: 2 +# text 4 +- model : cm.textversion + pk: 4 + fields: + created: "2009-02-13 04:01:12" + modified: "2009-02-13 04:01:12" + title: 'title 4, public text' + format: 'markdown' + content: 'aaa bbb ccc ddd eee fff ggg' + text: 4 + mod_posteriori: True + key: "textversion_key_4" + adminkey: "tv_adminkey_4" + +- model : cm.text + pk: 4 + fields: + created: "2009-02-13 04:01:12" + modified: "2009-02-13 04:01:12" + last_text_version: 4 + title: 'title 4, public text' + state: "approved" + key: "text_key_4" + adminkey: "text_adminkey_4" + user: 1 + +# text 5 +- model : cm.textversion + pk: 5 + fields: + created: "2009-02-13 04:01:12" + modified: "2009-02-13 04:01:12" + title: 'title 5, public text' + format: 'markdown' + content: 'aaa bbb ccc ddd eee fff ggg' + text: 5 + mod_posteriori: True + key: "textversion_key_5" + adminkey: "tv_adminkey_5" + +- model : cm.text + pk: 5 + fields: + created: "2009-02-13 04:01:12" + modified: "2009-02-13 04:01:12" + last_text_version: 5 + title: 'title 5, public text' + state: "approved" + key: "text_key_5" + adminkey: "text_adminkey_5" + user: 1 + ############### userrole ############### # user 1 is global Manager @@ -426,6 +478,24 @@ user: 4 text: 2 +# user null (anon is Commentator on text 4) +# userrole 8 +- model : cm.userrole + pk: 8 + fields: + role: 4 + user: null + text: 4 + +# user null (anon is Commentator on text 5) +# userrole 9 +- model : cm.userrole + pk: 9 + fields: + role: 4 + user: null + text: 5 + ############### comment ############### # comment 1 (visible on text 2) diff -r b5deb8e32219 -r fc5ed157ebfe src/cm/security.py --- a/src/cm/security.py Fri Jun 11 11:04:23 2010 +0200 +++ b/src/cm/security.py Fri Jul 09 10:05:29 2010 +0200 @@ -7,7 +7,7 @@ from django.http import HttpResponseRedirect from django.utils.http import urlquote from django.db.models import Q - +from piston.utils import rc import logging from cm.models import * @@ -210,8 +210,14 @@ return _check_global_perm return _dec + +def has_perm_on_text_api(perm_name, must_be_logged_in=False, redirect_field_name=REDIRECT_FIELD_NAME): + return _has_perm_on_text(perm_name, must_be_logged_in, redirect_field_name, api=True) -def has_perm_on_text(perm_name, must_be_logged_in=False, redirect_field_name=REDIRECT_FIELD_NAME): +def has_perm_on_text(perm_name, must_be_logged_in=False, redirect_field_name=REDIRECT_FIELD_NAME, api=False): + return _has_perm_on_text(perm_name, must_be_logged_in, redirect_field_name, api) + +def _has_perm_on_text(perm_name, must_be_logged_in=False, redirect_field_name=REDIRECT_FIELD_NAME, api=False): """ decorator protection checking for perm for logged in user force logged in (i.e. redirect to connection screen if not if must_be_logged_in @@ -222,15 +228,24 @@ return view_func(request, *args, **kwargs) if must_be_logged_in and not is_authenticated(request): - login_url = reverse('login') - return HttpResponseRedirect('%s?%s=%s' % (login_url, redirect_field_name, urlquote(request.get_full_path()))) + if not api: + login_url = reverse('login') + return HttpResponseRedirect('%s?%s=%s' % (login_url, redirect_field_name, urlquote(request.get_full_path()))) + else: + return rc.FORBIDDEN + if 'key' in kwargs: text = get_object_or_404(Text, key=kwargs['key']) else: raise Exception('no security check possible') - - if has_perm(request, perm_name, text=text): + + # in api, the view has an object as first parameter, request is args[0] + if not api: + req = request + else: + req = args[0] + if has_perm(req, perm_name, text=text): return view_func(request, *args, **kwargs) #else: # TODO: (? useful ?) if some user have the perm and not logged-in : redirect to login @@ -238,7 +253,11 @@ # return HttpResponseRedirect('%s?%s=%s' % (login_url, redirect_field_name, urlquote(request.get_full_path()))) # else : unauthorized - raise UnauthorizedException('No perm %s' % perm_name) + if not api: + raise UnauthorizedException('No perm %s' % perm_name) + else: + return rc.FORBIDDEN + _check_local_perm.__doc__ = view_func.__doc__ _check_local_perm.__dict__ = view_func.__dict__ diff -r b5deb8e32219 -r fc5ed157ebfe src/cm/templates/api_doc.html --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/cm/templates/api_doc.html Fri Jul 09 10:05:29 2010 +0200 @@ -0,0 +1,75 @@ +{% extends "site/layout/base_workspace.html" %} +{% load com %} +{% load i18n %} + +{% block title %} +{% blocktrans %}API Documentation{% endblocktrans %} +{% endblock %} + +{% block head %} +{% endblock %} + +{% block content %} + + + + + + +

API Documentation

+ +

Presentation

+ +The API exposes method for external application to deal with content store in COMT. + +The authentification is done using HTTP Authentification. + +The default return format is 'json', add '?format=other_format' where other_format is 'json', 'xml', 'yaml' to change the results' format. +{% load markup %} + + {% regroup docs by type as grouped_docs %} + + + {% for dd in grouped_docs %} +

{{ dd.grouper }}

+ {% for doc in dd.list %} + {% if not doc.handler.no_display %} +

{{ doc.handler.title }} ΒΆ

+ +

{{ doc.handler.desc }}

+

+ {{ doc.doc|default:""|restructuredtext }} +

+ +

+ Endpoint: {{ doc.handler.endpoint }} +

+ +

+ Method: {% for meth in doc.allowed_methods %}{{ meth }}{% if not forloop.last %}, {% endif %}{% endfor %} +

+ +

+ Args: {% if doc.handler.args %}{{ doc.handler.args|safe }}{% else %}None{% endif %} +

+ + +
+ {% endif %} + {% endfor %} + {% endfor %} + + +{% endblock %} diff -r b5deb8e32219 -r fc5ed157ebfe src/cm/tests/__init__.py --- a/src/cm/tests/__init__.py Fri Jun 11 11:04:23 2010 +0200 +++ b/src/cm/tests/__init__.py Fri Jul 09 10:05:29 2010 +0200 @@ -6,3 +6,4 @@ from cm.tests.test_history import * from cm.tests.test_security import * from cm.tests.test_activity import * +from cm.tests.test_api import * diff -r b5deb8e32219 -r fc5ed157ebfe src/cm/tests/test_api.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/cm/tests/test_api.py Fri Jul 09 10:05:29 2010 +0200 @@ -0,0 +1,207 @@ +from django.test import TestCase +from django.test.client import Client +from django.core import management +from datetime import datetime +from cm.activity import * +from cm.models import * +from cm.security import * + +from django.http import HttpRequest, HttpResponse +from django.utils import simplejson + +from cm.api.handlers import * + +#from piston.test import TestCase +from piston.models import Consumer +from piston.handler import BaseHandler +from piston.utils import rc +from piston.resource import Resource + +class FalseRequest(object): + def __init__(self, user): + self.user = user + +class APITest(TestCase): + fixtures = ['roles_generic', 'test_content', ] + + def test_text_get(self): + """ + Anonymous api call + """ + + resource = Resource(AnonymousTextHandler) + request = HttpRequest() + setattr(request, 'user' , None) + request.method = 'GET' + + # get public text + response = resource(request, key='text_key_4', emitter_format='json') + self.assertEquals(200, response.status_code) # 401: forbidden + response_data = simplejson.loads(response.content) + self.assertEquals(response_data.get('created'), '2009-02-13 04:01:12') + + # error: private text + response = resource(request, key='text_key_3', emitter_format='json') + self.assertEquals(401, response.status_code) + + + def test_text_get_logged_in(self): + """ + Logged in as manager api call + """ + + resource = Resource(AnonymousTextHandler) + request = HttpRequest() + user = User.objects.get(pk=1) + setattr(request, 'user' , user) + request.method = 'GET' + + response = resource(request, key='text_key_3', emitter_format='json') + self.assertEquals(200, response.status_code) + + + def test_text_create(self): + request = FalseRequest(None) + nb_anon_texts = get_texts_with_perm(request, 'can_view_text').count() + nb_texts = Text.objects.count() + + resource = Resource(TextListHandler) + + # create one private text + request = HttpRequest() + user = User.objects.get(pk=1) + setattr(request, 'user' , user) + request.method = 'POST' + setattr(request, 'POST' , {'content':'test content', 'format':"markdown", 'title': 'my title'}) + response = resource(request,) + + self.assertEquals(200, response.status_code) + self.assertTrue('key' in simplejson.loads(response.content).keys()) + + request = FalseRequest(None) + self.assertEqual(get_texts_with_perm(request, 'can_view_text').count(), nb_anon_texts) # NO more anon text + + # create one text with anon observer + request = HttpRequest() + user = User.objects.get(pk=1) + setattr(request, 'user' , user) + request.method = 'POST' + setattr(request, 'POST' , {'content':'test content', 'format':"markdown", 'title': 'my title', 'anon_role' : 4}) + response = resource(request,) + + self.assertEquals(200, response.status_code) + self.assertTrue('key' in simplejson.loads(response.content).keys()) + + self.assertEquals(nb_texts + 2, Text.objects.count()) # 2 more texts should have been created + + request = FalseRequest(None) + self.assertEqual(get_texts_with_perm(request, 'can_view_text').count(), nb_anon_texts + 1) # one more anon accessible text available + + def test_list_text_get(self): + """ + List texts anon + """ + resource = Resource(AnonymousTextListHandler) + request = HttpRequest() + setattr(request, 'user' , None) + request.method = 'GET' + + response = resource(request, emitter_format='json') + self.assertEquals(200, response.status_code) + self.assertEquals(2, len(simplejson.loads(response.content))) + + def test_list_text_logged_in(self): + """ + List texts manager + """ + resource = Resource(AnonymousTextListHandler) + request = HttpRequest() + user = User.objects.get(pk=1) + setattr(request, 'user' , user) + request.method = 'GET' + + response = resource(request, emitter_format='json') + self.assertEquals(200, response.status_code) + self.assertEquals(5, len(simplejson.loads(response.content))) + + def test_delete_text_logged_in_works(self): + """ + Delete text + """ + nb_texts = Text.objects.count() + + resource = Resource(TextDeleteHandler) + request = HttpRequest() + user = User.objects.get(pk=1) + setattr(request, 'user' , user) + request.method = 'POST' + setattr(request, 'POST' , {'key':'text_key_3'}) + setattr(request, 'flash' , {}) + + response = resource(request, emitter_format='json') + self.assertEquals(204, response.status_code) + + # one text deleted + self.assertEquals(nb_texts - 1, Text.objects.count()) + + def test_delete_text_logged_in_fail(self): + """ + Delete text (and fail: insufficient rights) + """ + nb_texts = Text.objects.count() + + resource = Resource(TextDeleteHandler) + request = HttpRequest() + user = User.objects.get(pk=3) + setattr(request, 'user' , user) + request.method = 'POST' + setattr(request, 'POST' , {'key':'text_key_3'}) + setattr(request, 'flash' , {}) + + response = resource(request, emitter_format='json') + self.assertEquals(401, response.status_code) + + # no text deleted + self.assertEquals(nb_texts, Text.objects.count()) + + + def test_pre_edit(self): + """ + Pre edit text: should return number of comments to remove + """ + resource = Resource(TextPreEditHandler) + request = HttpRequest() + user = User.objects.get(pk=1) + setattr(request, 'user' , user) + request.method = 'POST' + setattr(request, 'POST' , {'new_format' : 'markdown', 'new_content' : u'ggg'}) + setattr(request, 'flash' , {}) + + response = resource(request, key='text_key_2', emitter_format='json') + self.assertEquals(response.content, '{"nb_removed": 3}') + + def xtest_edit(self): + """ + Edit text + """ + resource = Resource(TextEditHandler) + request = HttpRequest() + setattr(request,'session',None) + user = User.objects.get(pk=1) + setattr(request, 'user' , user) + request.method = 'POST' + setattr(request, 'POST' , {'new_format' : 'markdown', 'new_content' : u'ggg'}) + setattr(request, 'flash' , {}) + + response = resource(request, key='text_key_2', emitter_format='json') + + self.assertEquals(Text.objects.get(pk=2).last_text_version.content , u'ggg') + + + def test_setuser(self): + """ + Set username/email for commenting + """ + from django.test.client import Client + c = Client() + response = c.post('/setuser/', {'username': 'my_username', 'email': 'my_email'}) diff -r b5deb8e32219 -r fc5ed157ebfe src/cm/tests/test_security.py --- a/src/cm/tests/test_security.py Fri Jun 11 11:04:23 2010 +0200 +++ b/src/cm/tests/test_security.py Fri Jul 09 10:05:29 2010 +0200 @@ -17,19 +17,19 @@ def test_access_rights(self): # anon user sees no text request = FalseRequest(None) - self.assertEqual(get_texts_with_perm(request, 'can_view_text').count(), 0) + self.assertEqual(get_texts_with_perm(request, 'can_view_text').count(), 2) # user 1 sees all texts user1 = UserProfile.objects.get(id=1).user request = FalseRequest(user1) - self.assertEqual(get_texts_with_perm(request, 'can_view_text').count(), 3) + self.assertEqual(get_texts_with_perm(request, 'can_view_text').count(), 5) - # user 2 sees only 2 texts + # user 2 sees only 4 texts user2 = UserProfile.objects.get(id=2).user request = FalseRequest(user2) - self.assertEqual(get_texts_with_perm(request, 'can_view_text').count(), 2) + self.assertEqual(get_texts_with_perm(request, 'can_view_text').count(), 4) - # user 4 sees only 2 texts (global manager but commentator on text 4 + # user 4 manages only 2 texts (global manager but commentator on text 4 user4 = UserProfile.objects.get(id=4).user request = FalseRequest(user4) self.assertEqual(get_texts_with_perm(request, 'can_manage_text').count(), 2) diff -r b5deb8e32219 -r fc5ed157ebfe src/cm/urls.py --- a/src/cm/urls.py Fri Jun 11 11:04:23 2010 +0200 +++ b/src/cm/urls.py Fri Jul 09 10:05:29 2010 +0200 @@ -157,3 +157,7 @@ urlpatterns += patterns('', (r'^jsi18n/$', 'django.views.i18n.javascript_catalog', js_info_dict), ) + +urlpatterns += patterns('', + (r'^api/', include('cm.api.urls')), +)