Source code for annotator_store.models

from collections import OrderedDict
import json
import logging
import uuid
from django.apps import apps
from django.db import models
from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist
from django.core.urlresolvers import reverse
from django.contrib.auth.models import Group, User
from django.utils.html import format_html
from jsonfield import JSONField
try:
    import guardian
    from guardian.shortcuts import assign_perm, get_objects_for_user, \
        get_objects_for_group, get_perms_for_model, get_perms
    from guardian.models import UserObjectPermission, GroupObjectPermission
except ImportError:
    guardian = None
import six


logger = logging.getLogger(__name__)


ANNOTATION_MODEL_NAME = getattr(settings, 'ANNOTATOR_ANNOTATION_MODEL',
    "annotator_store.Annotation")

ANNOTATION_OBJECT_PERMISSIONS = getattr(settings, 'ANNOTATION_OBJECT_PERMISSIONS',
    False)


[docs]class AnnotationQuerySet(models.QuerySet): 'Custom :class:`~django.models.QuerySet` for :class:`Annotation`'
[docs] def visible_to(self, user): """ Return annotations the specified user is allowed to view. Objects are found based on view_annotation permission and annotations; users can access only their own annotations or those where permissions have been granted to a group they belong to. .. Note:: Due to the use of :meth:`guardian.shortcuts.get_objects_for_user`, it is recommended to use this method first; it does combine the existing queryset query, but it does not chain as querysets normally do. """ # if per-object permissions are enabled, use guardian to find # annotations the current user can view if ANNOTATION_OBJECT_PERMISSIONS: qs = get_objects_for_user(user, 'view_annotation', get_annotation_model()) # combine the current queryset query, if any, with the newly # created queryset from django guardian qs.query.combine(self.query, 'AND') return qs else: # otherwise, return everything or nothing based on django perms if user.has_perm('annotator_store.view_annotation'): return self.filter() else: # empty queryset if user doesn't have view permission return self.none()
[docs] def visible_to_group(self, group): """ Return annotations the specified group is allowed to view. Objects are found based on view_annotation permission and per-object permissions. .. Note:: Due to the use of :meth:`guardian.shortcuts.get_objects_for_user`, it is recommended to use this method first; it does combine the existing queryset query, but it does not chain as querysets normally do. """ # group permissions are only enabled when per-object permissions # are turned on if ANNOTATION_OBJECT_PERMISSIONS: qs = get_objects_for_group(group, 'view_annotation', get_annotation_model()) # combine current queryset query, if any, with the newly # created queryset from django guardian qs.query.combine(self.query, 'AND') return qs else: return self.filter()
[docs] def last_created_time(self): '''Creation time of the most recently created annotation. If queryset is empty, returns None.''' try: return self.values_list('created', flat=True).latest('created') except ObjectDoesNotExist: pass
[docs] def last_updated_time(self): '''Update time of the most recently created annotation. If queryset is empty, returns None.''' try: return self.values_list('updated', flat=True).latest('updated') except ObjectDoesNotExist: pass
[docs]class AnnotationManager(models.Manager): '''Custom :class:`~django.models.Manager` for :class:`Annotation`. Returns :class:`AnnotationQuerySet` as default queryset, and exposes :meth:`visible_to` for convenience.''' def get_queryset(self): return AnnotationQuerySet(self.model, using=self._db)
[docs] def visible_to(self, user): 'Convenience access to :meth:`AnnotationQuerySet.visible_to`' return self.get_queryset().visible_to(user)
[docs] def visible_to_group(self, group): 'Convenience access to :meth:`AnnotationQuerySet.visible_to_group`' return self.get_queryset().visible_to_group(group)
@six.python_2_unicode_compatible
[docs]class BaseAnnotation(models.Model): '''Django database model to store Annotator.js annotation data, based on the `annotation format documentation <http://docs.annotatorjs.org/en/v1.2.x/annotation-format.html>`_.''' #: regex for recognizing valid UUID, for use in site urls UUID_REGEX = r'[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}' #: annotation schema version: default v1.0 schema_version = "v1.0" # for now, hard-coding until or unless we need to support more than # one version of annotation #: unique id for the annotation; uses :meth:`uuid.uuid4` id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) # data model includes version, do we need to set that in the db? # "annotator_schema_version": "v1.0", # schema version: default v1.0 #: datetime annotation was created; automatically set when added created = models.DateTimeField(auto_now_add=True) #: datetime annotation was last updated; automatically updated on save updated = models.DateTimeField(auto_now=True) #: content of the annotation text = models.TextField(blank=True) #: the annotated text quote = models.TextField(blank=True) #: URI of the annotated document uri = models.URLField() #: user who owns the annotation #: when serialized, id of annotation owner OR an object with an 'id' property # Make user optional for now user = models.ForeignKey(settings.AUTH_USER_MODEL, null=True) # tags still todo # "tags": [ "review", "error" ], # list of tags (from Tags plugin) #: any additional data included in the annotation not parsed into #: specific model fields; this includes ranges, permissions, #: annotation data, etc extra_data = JSONField(default=json.dumps({})) # example range from the documentation # "ranges": [ # { # "start": "/p[69]/span/span", # (relative) XPath to start element # "end": "/p[70]/span/span", # (relative) XPath to end element # "startOffset": 0, # character offset within start element # "endOffset": 120 # character offset within end element # } # ] # NOTE: according to the documentation, the basic schema is # extensible, can be added to by plugins, and any fields added by the # frontend should be preserved by the backend. Store any of that # additional information in the extra_data field. #: fields in the db model that are provided by annotation json #: when creating or updating an annotation common_fields = ['text', 'quote', 'uri', 'user'] #: internal fields that are not set from values provided by #: annotation json when creating or updating internal_fields = ['updated', 'created', 'id', 'user', 'annotator_schema_version'] objects = AnnotationManager() class Meta: abstract = True # extend default permissions to add a view option # change_annotation and delete_annotation provided by django permissions = ( ('view_annotation', 'View annotation'), ('admin_annotation', 'Manage annotation'), ) def __str__(self): return self.text def __repr__(self): return '<Annotation: %s>' % self.text
[docs] def get_absolute_url(self): 'URL to view this annotation within the annotation API.' return reverse('annotation-api:view', kwargs={'id': self.id})
[docs] def text_preview(self): 'Short preview of annotation text content' if self.text: return self.text[:100] + ('...' if len(self.text) > 100 else '') # provide indicator for annotations with no text return '[no text]'
text_preview.short_description = 'Text' uri_link.short_description = 'URI' @property def related_pages(self): 'convenience access to list of related pages in extra data' if 'related_pages' in self.extra_data: return self.extra_data['related_pages'] @classmethod def filter_data(cls, data, internal_only=False): if internal_only: filter_fields = cls.internal_fields else: filter_fields = cls.common_fields + cls.internal_fields return {key: val for key, val in data.items() if key not in filter_fields} @classmethod
[docs] def create_from_request(cls, request): '''Initialize a new :class:`Annotation` based on data from a :class:`django.http.HttpRequest`. Expects request body content to be JSON; sets annotation user based on the request user. ''' data = json.loads(request.body.decode()) model_data = {} extra_data = {} for key, val in six.iteritems(data): if key in BaseAnnotation.common_fields: model_data[key] = val else: extra_data[key] = val if not request.user.is_anonymous(): model_data['user'] = request.user # remove any common and internal fields from extra data so they # don't get duplicated in the json field extra_data = cls.filter_data(extra_data) annotation = cls(extra_data=json.dumps(extra_data), **model_data) # if extra data is present, handle any extra processing # exactly the same as when updating an annotation if annotation.extra_data: annotation.extra_data = annotation.handle_extra_data(extra_data, request) annotation.save() return annotation
[docs] def update_from_request(self, request): '''Update attributes from data in a :class:`django.http.HttpRequest`. Expects request body content to be JSON. Currently does *not* modify user.''' data = json.loads(request.body.decode()) # NOTE: could keep a list of modified fields and # and allow Django to do a more efficient db update # ignore backend-generated fields and remove so they are # not duplicated in extra data # NOTE: current implementation assumes that user should # NOT be changed after annotation is created data = self.filter_data(data, internal_only=True) # set database fields from data in the request for field in self.common_fields: try: setattr(self, field, data[field]) # remove from data so it is not duplicated del data[field] except KeyError: pass if data: # any other data included in the request and not yet # processed should be stored as extra data. # Allow subclasses to process any extra data they care about # and then store whatever is left. # NOTE: replacing existing extra data rather than updating; # any extra data should have been included in the annotation # that was loaded for editing; using update would make it # impossible to delete extra data fields. self.extra_data = self.handle_extra_data(data, request) self.save()
[docs] def handle_extra_data(self, data, request): '''Handle any "extra" data that is not part of the stock annotation data model. Use this method to customize the logic for creating and updating annotations from request data. NOTE: request is passed in to support permissions handling when object-level permissions are enabled. ''' return data
[docs] def info(self): '''Return a :class:`collections.OrderedDict` of fields to be included in serialized JSON version of the current annotation.''' info = OrderedDict([ ('id', str(self.id)), ('annotator_schema_version', self.schema_version), # iso8601 formatted dates ('created', self.created.isoformat() if self.created else ''), ('updated', self.updated.isoformat() if self.updated else ''), ('text', self.text), ('quote', self.quote), ('uri', self.uri), ('user', self.user.username if self.user else ''), # tags handled as part of extra data ]) # There shouldn't be collisions between extra data and db # fields, but in case there are, none of the extra data shoudl # override core fields info.update({k: v for k, v in six.iteritems(self.extra_data) if k not in info}) return info
# generic django permission checks; methods are provided for consistency # with optional per-object permissions def user_can_view(self, user): return user.has_perm('annotator_store.view_annotation') def user_can_update(self, user): return user.has_perm('annotator_store.change_annotation') def user_can_delete(self, user): return user.has_perm('annotator_store.delete_annotation')
# if per-object permissions are requested and guardian is installed # define annotation permission functionality if ANNOTATION_OBJECT_PERMISSIONS and guardian:
[docs] class AnnotationWithPermissions(BaseAnnotation): '''Mix-in annotation class to provide object-level permissions handling via django-guardian''' class Meta: abstract = True permissions = ( ('view_annotation', 'View annotation'), ('admin_annotation', 'Manage annotation'), ) # example permissions from the documentation # "permissions": { # "read": ["group:__world__"], # "admin": [], # "update": [], # "delete": [] # } #: map annotator permissions to django annotation permission codenames permission_to_codename = { 'read': 'view_annotation', 'update': 'change_annotation', 'delete': 'delete_annotation', 'admin': 'admin_annotation' } #: lookup annotation permission mode by django permission codename codename_to_permission = dict([(codename, mode) for mode, codename in six.iteritems(permission_to_codename)])
[docs] def info(self): '''Update default annotation info to include permissions''' info = super(AnnotationWithPermissions, self).info() # annotation permissions dict based on database permissions permissions = self.permissions_dict() # only include if at least one permission is not empty if any(permissions.values()): info['permissions'] = permissions return info
[docs] def save(self, *args, **kwargs): """Extend default save method to ensure annotation user has access to edit and update their own annotation.""" super(AnnotationWithPermissions, self).save(*args, **kwargs) # NOTE: currently annotation model assumes user is not modified; # if it is changed, previous owner will still have permissions self.grant_user_access()
[docs] def handle_extra_data(self, data, request): '''Handle any "extra" data that is not part of the stock annotation data model. Use this method to customize the logic for updating an annotation from request data.''' data = super(AnnotationWithPermissions, self).handle_extra_data(data, request) if 'permissions' in data: # only change permissions if user has admin annotation # permission; otherwise, ignore any changes if self.user_has_perm(request.user, 'admin_annotation'): logger.debug('user has admin perms, updating db permissions') self.db_permissions(data['permissions']) else: logger.debug('user does not have admin perms, ignoring permissions') # remove permissions from extra data so it does not # get stored in the catch-all json field del data['permissions'] return data
[docs] def user_permissions(self): '''Queryset of :class:`guardian.model.UserObjectPermission` objects associated with this annotation.''' return UserObjectPermission.objects.filter(object_pk=self.pk)
[docs] def group_permissions(self): '''Queryset of :class:`guardian.model.GroupObjectPermission` objects associated with this annotation.''' return GroupObjectPermission.objects.filter(object_pk=self.pk)
def get_group_or_user(self, ident): # look up annotation group or user based on username # or group id in annotation permissions list if ident.startswith('group:'): group_id = ident[len('group:'):] try: return AnnotationGroup.objects.get(id=int(group_id)) except ValueError: # non-integer identifier found logger.warn("Invalid group id '%s' in annotation %s permissions", group_id, self.pk) except AnnotationGroup.DoesNotExist: logger.warn("Error finding group '%s' in annotation %s permissions", group_id, self.pk) else: try: return User.objects.get(username=ident) except User.DoesNotExist: logger.warn("Error finding user '%s' in annotation %s permissions", ident, self.pk)
[docs] def assign_permission(self, permission, entity): """Wrapper around :meth:`guardian.shortcuts.assign_perm`. Gives the specified permission to the specified user or group on the current object. """ assign_perm(permission, entity, self)
[docs] def db_permissions(self, permissions): '''Convert annotation permission data into actionable django permissions using :mod:`guardian` per-object permissions. ''' # since there is no way to know what permissions were # previously in place and need to be removed, remove all permissions self.user_permissions().delete() self.group_permissions().delete() # NOTE: should eventually handle special case # group:__world__, but setting that is currently not supported # by the readux annotator permissions module # then re-assign permissions based on annotation permissions for mode, users in six.iteritems(permissions): for ident in users: entity = self.get_group_or_user(ident) if entity is not None: # give user/group the appropriate permission on this object self.assign_permission(self.permission_to_codename[mode], entity)
def grant_user_access(self): if self.user is not None: # possibly also check anonymous? for perm in get_perms_for_model(self): # skip default django add permission - not relevant # on an individual object if perm.codename == 'add_annotation': continue self.assign_permission(perm.codename, self.user)
[docs] def permissions_dict(self): '''Convert stored :mod:`guardian` per-object permissions into annotation permission dictionary format''' # convert db permissions into annotator style permissions # construct base permissions dict, empty list for each mode permissions = dict([(mode, []) for mode in self.permission_to_codename.keys()]) for user_perm in self.user_permissions(): # convert db codename to annotation mode mode = self.codename_to_permission[user_perm.permission.codename] # store by username permissions[mode].append(user_perm.user.username) for group_perm in self.group_permissions(): mode = self.codename_to_permission[group_perm.permission.codename] permissions[mode].append(group_perm.group.annotationgroup.annotation_id) return permissions
[docs] def user_has_perm(self, user, permission): 'Check if a user has a specific permission on this object.' # NOTE: according to guardian docs, it should work to use this # user.has_perm(permission, self) # but that check fails when it should not. return permission in get_perms(user, self)
def user_can_view(self, user): return self.user_has_perm(user, 'view_annotation') def user_can_update(self, user): return self.user_has_perm(user, 'change_annotation') def user_can_delete(self, user): return self.user_has_perm(user, 'delete_annotation')
[docs] class AnnotationGroup(Group): """Annotation Group; extends :class:`django.contrib.auth.models.Group`. Intended to facilitate group permissions on annotations. """ # inherits name from Group #: optional notes field notes = models.TextField(blank=True) #: datetime annotation was created; automatically set when added created = models.DateTimeField(auto_now_add=True) #: datetime annotation was last updated; automatically updated on save updated = models.DateTimeField(auto_now=True) def num_members(self): return self.user_set.count() num_members.short_description = '# members' def __repr__(self): return '<Annotation Group: %s>' % self.name @property def annotation_id(self): return 'group:%d' % self.pk
# if default annotation model is requested, define it here # otherwise, custom annotation model will be used if ANNOTATION_MODEL_NAME == "annotator_store.Annotation": if ANNOTATION_OBJECT_PERMISSIONS and guardian: class Annotation(AnnotationWithPermissions): pass else:
[docs] class Annotation(BaseAnnotation): pass
def get_annotation_model(): app_name, model_name = ANNOTATION_MODEL_NAME.split(".") app = apps.get_app_config(app_name) return app.get_model(model_name)