PageRenderTime 59ms CodeModel.GetById 15ms RepoModel.GetById 0ms app.codeStats 1ms

/kolibri/auth/models.py

https://gitlab.com/gregtyka/kolibri
Python | 867 lines | 848 code | 0 blank | 19 comment | 14 complexity | 5ca4829ad72eb6754c6c4cd90ad928e9 MD5 | raw file
  1. """
  2. We have four main abstractions: Users, Collections, Memberships, and Roles.
  3. Users represent people, like students in a school, teachers for a classroom, or volunteers setting up informal
  4. installations. There are two main user types, ``FacilityUser`` and ``DeviceOwner``. A ``FacilityUser`` belongs to a
  5. particular facility, and has permissions only with respect to other data that is associated with that facility. A
  6. ``DeviceOwner`` is not associated with a particular facility, and has global permissions for data on the local device.
  7. ``FacilityUser`` accounts (like other facility data) may be synced across multiple devices, whereas a DeviceOwner account
  8. is specific to a single installation of Kolibri.
  9. Collections form a hierarchy, with Collections able to belong to other Collections. Collections are subdivided
  10. into several pre-defined levels (``Facility`` > ``Classroom`` > ``LearnerGroup``).
  11. A ``FacilityUser`` (but not a ``DeviceOwner``) can be marked as a member of a ``Collection`` through a ``Membership``
  12. object. Being a member of a Collection also means being a member of all the Collections above that Collection in the
  13. hierarchy.
  14. Another way in which a ``FacilityUser`` can be associated with a particular ``Collection`` is through a ``Role``
  15. object, which grants the user a role with respect to the ``Collection`` and all the collections below it. A ``Role``
  16. object also stores the "kind" of the role (currently, one of "admin" or "coach"), which affects what permissions the
  17. user gains through the ``Role``.
  18. """
  19. from __future__ import absolute_import, print_function, unicode_literals
  20. from django.contrib.auth.models import AbstractBaseUser
  21. from django.core import validators
  22. from django.core.exceptions import ValidationError
  23. from django.db import models
  24. from django.db.models.query import F
  25. from django.db.utils import IntegrityError
  26. from django.utils import timezone
  27. from django.utils.encoding import python_2_unicode_compatible
  28. from django.utils.translation import ugettext_lazy as _
  29. from kolibri.core.errors import KolibriValidationError
  30. from mptt.models import MPTTModel, TreeForeignKey
  31. from six import string_types
  32. from .constants import collection_kinds, role_kinds
  33. from .errors import (
  34. InvalidRoleKind, UserDoesNotHaveRoleError,
  35. UserHasRoleOnlyIndirectlyThroughHierarchyError,
  36. UserIsMemberOnlyIndirectlyThroughHierarchyError, UserIsNotFacilityUser,
  37. UserIsNotMemberError
  38. )
  39. from .filters import HierarchyRelationsFilter
  40. from .permissions.auth import CollectionSpecificRoleBasedPermissions
  41. from .permissions.base import BasePermissions, RoleBasedPermissions
  42. from .permissions.general import (
  43. IsAdminForOwnFacility, IsFromSameFacility, IsOwn, IsSelf
  44. )
  45. class FacilityDataset(models.Model):
  46. """
  47. ``FacilityDataset`` stores high-level metadata and settings for a particular ``Facility``. It is also the
  48. model that all models storing facility data (data that is associated with a particular facility, and that inherits
  49. from ``AbstractFacilityDataModel``) foreign key onto, to indicate that they belong to this particular ``Facility``.
  50. """
  51. description = models.TextField(blank=True)
  52. location = models.CharField(max_length=200, blank=True)
  53. allow_signups = models.BooleanField(default=True)
  54. class AbstractFacilityDataModel(models.Model):
  55. """
  56. Base model for Kolibri "Facility Data", which is data that is specific to a particular ``Facility``,
  57. such as ``FacilityUsers``, ``Collections``, and other data associated with those users and collections.
  58. """
  59. dataset = models.ForeignKey("FacilityDataset")
  60. class Meta:
  61. abstract = True
  62. def clean_fields(self, *args, **kwargs):
  63. # ensure that we have, or can infer, a dataset for the model instance
  64. self.ensure_dataset()
  65. super(AbstractFacilityDataModel, self).clean_fields(*args, **kwargs)
  66. def save(self, *args, **kwargs):
  67. # before saving, ensure we have a dataset, and convert any validation errors into integrity errors,
  68. # since by this point the `clean_fields` method should already have prevented this situation from arising
  69. try:
  70. self.ensure_dataset()
  71. except KolibriValidationError as e:
  72. raise IntegrityError(str(e))
  73. super(AbstractFacilityDataModel, self).save(*args, **kwargs)
  74. def ensure_dataset(self):
  75. """
  76. If no dataset has yet been specified, try to infer it. If a dataset has already been specified, to prevent
  77. inconsistencies, make sure it matches the inferred dataset, otherwise raise a ``KolibriValidationError``.
  78. If we have no dataset and it can't be inferred, we raise a ``KolibriValidationError`` exception as well.
  79. """
  80. inferred_dataset = self.infer_dataset()
  81. if self.dataset_id:
  82. # make sure currently stored dataset matches inferred dataset, if any
  83. if inferred_dataset and inferred_dataset != self.dataset:
  84. raise KolibriValidationError("This model is not associated with the correct FacilityDataset.")
  85. else:
  86. # use the inferred dataset, if there is one, otherwise throw an error
  87. if inferred_dataset:
  88. self.dataset = inferred_dataset
  89. else:
  90. raise KolibriValidationError("FacilityDataset ('dataset') not provided, and could not be inferred.")
  91. def infer_dataset(self):
  92. """
  93. This method is used by `ensure_dataset` to "infer" which dataset should be associated with this instance.
  94. It should be overridden in any subclass of ``AbstractFacilityDataModel``, to define a model-specific inference.
  95. """
  96. raise NotImplementedError("Subclasses of AbstractFacilityDataModel must override the `infer_dataset` method.")
  97. class KolibriAbstractBaseUser(AbstractBaseUser):
  98. """
  99. Our custom user type, derived from ``AbstractBaseUser`` as described in the Django docs.
  100. Draws liberally from ``django.contrib.auth.AbstractUser``, except we exclude some fields
  101. we don't care about, like email.
  102. This model is an abstract model, and is inherited by both ``FacilityUser`` and ``DeviceOwner``.
  103. """
  104. class Meta:
  105. abstract = True
  106. USERNAME_FIELD = "username"
  107. username = models.CharField(
  108. _('username'),
  109. max_length=30,
  110. help_text=_('Required. 30 characters or fewer. Letters and digits only.'),
  111. validators=[
  112. validators.RegexValidator(
  113. r'^\w+$',
  114. _('Enter a valid username. This value may contain only letters and numbers.')
  115. ),
  116. ],
  117. )
  118. first_name = models.CharField(_('first name'), max_length=60, blank=True)
  119. last_name = models.CharField(_('last name'), max_length=60, blank=True)
  120. date_joined = models.DateTimeField(_('date joined'), default=timezone.now, editable=False)
  121. def get_full_name(self):
  122. return (self.first_name + " " + self.last_name).strip()
  123. def get_short_name(self):
  124. return self.first_name
  125. def is_member_of(self, coll):
  126. """
  127. Determine whether this user is a member of the specified ``Collection``.
  128. :param coll: The ``Collection`` for which we are checking this user's membership.
  129. :return: ``True`` if this user is a member of the specified ``Collection``, otherwise False.
  130. :rtype: bool
  131. """
  132. raise NotImplementedError("Subclasses of KolibriAbstractBaseUser must override the `is_member_of` method.")
  133. def get_roles_for_user(self, user):
  134. """
  135. Determine all the roles this user has in relation to the target user, and return a set containing the kinds of roles.
  136. :param user: The target user for which this user has the roles.
  137. :return: The kinds of roles this user has with respect to the target user.
  138. :rtype: set of ``kolibri.auth.constants.role_kinds.*`` strings
  139. """
  140. raise NotImplementedError("Subclasses of KolibriAbstractBaseUser must override the `get_roles_for_user` method.")
  141. def get_roles_for_collection(self, coll):
  142. """
  143. Determine all the roles this user has in relation to the specified ``Collection``, and return a set containing the kinds of roles.
  144. :param coll: The target ``Collection`` for which this user has the roles.
  145. :return: The kinds of roles this user has with respect to the specified ``Collection``.
  146. :rtype: set of ``kolibri.auth.constants.role_kinds.*`` strings
  147. """
  148. raise NotImplementedError("Subclasses of KolibriAbstractBaseUser must override the `get_roles_for_collection` method.")
  149. def has_role_for_user(self, kinds, user):
  150. """
  151. Determine whether this user has (at least one of) the specified role kind(s) in relation to the specified user.
  152. :param user: The user that is the target of the role (for which this user has the roles).
  153. :param kinds: The kind (or kinds) of role to check for, as a string or iterable.
  154. :type kinds: string from ``kolibri.auth.constants.role_kinds.*``
  155. :return: ``True`` if this user has the specified role kind with respect to the target user, otherwise ``False``.
  156. :rtype: bool
  157. """
  158. raise NotImplementedError("Subclasses of KolibriAbstractBaseUser must override the `has_role_for_user` method.")
  159. def has_role_for_collection(self, kinds, coll):
  160. """
  161. Determine whether this user has (at least one of) the specified role kind(s) in relation to the specified ``Collection``.
  162. :param kinds: The kind (or kinds) of role to check for, as a string or iterable.
  163. :type kinds: string from kolibri.auth.constants.role_kinds.*
  164. :param coll: The target ``Collection`` for which this user has the roles.
  165. :return: ``True`` if this user has the specified role kind with respect to the target ``Collection``, otherwise ``False``.
  166. :rtype: bool
  167. """
  168. raise NotImplementedError("Subclasses of KolibriAbstractBaseUser must override the `has_role_for_collection` method.")
  169. def can_create_instance(self, obj):
  170. """
  171. Checks whether this user (self) has permission to create a particular model instance (obj).
  172. This method should be overridden by classes that inherit from ``KolibriAbstractBaseUser``.
  173. In general, unless an instance has already been initialized, this method should not be called directly;
  174. instead, it should be preferred to call ``can_create``.
  175. :param obj: An (unsaved) instance of a Django model, to check permissions for.
  176. :return: ``True`` if this user should have permission to create the object, otherwise ``False``.
  177. :rtype: bool
  178. """
  179. raise NotImplementedError("Subclasses of KolibriAbstractBaseUser must override the `can_create_instance` method.")
  180. def can_create(self, Model, data):
  181. """
  182. Checks whether this user (self) has permission to create an instance of Model with the specified attributes (data).
  183. This method defers to the ``can_create_instance`` method, and in most cases should not itself be overridden.
  184. :param Model: A subclass of ``django.db.models.Model``
  185. :param data: A ``dict`` of data to be used in creating an instance of the Model
  186. :return: ``True`` if this user should have permission to create an instance of Model with the specified data, else ``False``.
  187. :rtype: bool
  188. """
  189. try:
  190. instance = Model(**data)
  191. instance.full_clean()
  192. except TypeError:
  193. return False # if the data provided does not fit the Model, don't continue checking
  194. except ValidationError:
  195. return False # if the data does not validate, don't continue checking
  196. # now that we have an instance, defer to the permission-checking method that works with instances
  197. return self.can_create_instance(instance)
  198. def can_read(self, obj):
  199. """
  200. Checks whether this user (self) has permission to read a particular model instance (obj).
  201. This method should be overridden by classes that inherit from ``KolibriAbstractBaseUser``.
  202. :param obj: An instance of a Django model, to check permissions for.
  203. :return: ``True`` if this user should have permission to read the object, otherwise ``False``.
  204. :rtype: bool
  205. """
  206. raise NotImplementedError("Subclasses of KolibriAbstractBaseUser must override the `can_read` method.")
  207. def can_update(self, obj):
  208. """
  209. Checks whether this user (self) has permission to update a particular model instance (obj).
  210. This method should be overridden by classes that inherit from KolibriAbstractBaseUser.
  211. :param obj: An instance of a Django model, to check permissions for.
  212. :return: ``True`` if this user should have permission to update the object, otherwise ``False``.
  213. :rtype: bool
  214. """
  215. raise NotImplementedError("Subclasses of KolibriAbstractBaseUser must override the `can_update` method.")
  216. def can_delete(self, obj):
  217. """
  218. Checks whether this user (self) has permission to delete a particular model instance (obj).
  219. This method should be overridden by classes that inherit from KolibriAbstractBaseUser.
  220. :param obj: An instance of a Django model, to check permissions for.
  221. :return: ``True`` if this user should have permission to delete the object, otherwise ``False``.
  222. :rtype: bool
  223. """
  224. raise NotImplementedError("Subclasses of KolibriAbstractBaseUser must override the `can_delete` method.")
  225. def get_roles_for(self, obj):
  226. """
  227. Helper function that defers to ``get_roles_for_user`` or ``get_roles_for_collection`` based on the type of object passed in.
  228. """
  229. if isinstance(obj, KolibriAbstractBaseUser):
  230. return self.get_roles_for_user(obj)
  231. elif isinstance(obj, Collection):
  232. return self.get_roles_for_collection(obj)
  233. else:
  234. raise ValueError("The `obj` argument to `get_roles_for` must be either an instance of KolibriAbstractBaseUser or Collection.")
  235. def has_role_for(self, kinds, obj):
  236. """
  237. Helper function that defers to ``has_role_for_user`` or ``has_role_for_collection`` based on the type of object passed in.
  238. """
  239. if isinstance(obj, KolibriAbstractBaseUser):
  240. return self.has_role_for_user(kinds, obj)
  241. elif isinstance(obj, Collection):
  242. return self.has_role_for_collection(kinds, obj)
  243. else:
  244. raise ValueError("The `obj` argument to `has_role_for` must be either an instance of KolibriAbstractBaseUser or Collection.")
  245. def filter_readable(self, queryset):
  246. """
  247. Filters a queryset down to only the elements that this user should have permission to read.
  248. :param queryset: A ``QuerySet`` instance that the filtering should be applied to.
  249. :return: Filtered ``QuerySet`` including only elements that are readable by this user.
  250. """
  251. raise NotImplementedError("Subclasses of KolibriAbstractBaseUser must override the `can_delete` method.")
  252. @python_2_unicode_compatible
  253. class FacilityUser(KolibriAbstractBaseUser, AbstractFacilityDataModel):
  254. """
  255. ``FacilityUser`` is the fundamental object of the auth app. These users represent the main users, and can be associated
  256. with a hierarchy of ``Collections`` through ``Memberships`` and ``Roles``, which then serve to help determine permissions.
  257. """
  258. permissions = (
  259. IsSelf() | # FacilityUser can be read and written by itself
  260. IsAdminForOwnFacility() | # FacilityUser can be read and written by a facility admin
  261. RoleBasedPermissions( # FacilityUser can be read by admin or coach, and updated by admin, but not created/deleted by non-facility admin
  262. target_field=".",
  263. can_be_created_by=(), # we can't check creation permissions by role, as user doesn't exist yet
  264. can_be_read_by=(role_kinds.ADMIN, role_kinds.COACH),
  265. can_be_updated_by=(role_kinds.ADMIN,),
  266. can_be_deleted_by=(), # don't want a classroom admin deleting a user completely, just removing them from the class
  267. )
  268. )
  269. facility = models.ForeignKey("Facility")
  270. # FacilityUsers can't access the Django admin interface
  271. is_staff = False
  272. is_superuser = False
  273. class Meta:
  274. unique_together = (("username", "facility"),)
  275. def infer_dataset(self):
  276. return self.facility.dataset
  277. def is_member_of(self, coll):
  278. if self.dataset_id != coll.dataset_id:
  279. return False
  280. if coll.kind == collection_kinds.FACILITY:
  281. return True # FacilityUser is always a member of her own facility
  282. return HierarchyRelationsFilter(FacilityUser.objects.all()).filter_by_hierarchy(
  283. target_user=F("id"),
  284. ancestor_collection=coll.id,
  285. ).filter(id=self.id).exists()
  286. def get_roles_for_user(self, user):
  287. if not hasattr(user, "dataset_id") or self.dataset_id != user.dataset_id:
  288. return set([])
  289. role_instances = HierarchyRelationsFilter(Role).filter_by_hierarchy(
  290. ancestor_collection=F("collection"),
  291. source_user=F("user"),
  292. target_user=user,
  293. ).filter(user=self)
  294. return set([instance["kind"] for instance in role_instances.values("kind").distinct()])
  295. def get_roles_for_collection(self, coll):
  296. if self.dataset_id != coll.dataset_id:
  297. return set([])
  298. role_instances = HierarchyRelationsFilter(Role).filter_by_hierarchy(
  299. ancestor_collection=F("collection"),
  300. source_user=F("user"),
  301. descendant_collection=coll,
  302. ).filter(user=self)
  303. return set([instance["kind"] for instance in role_instances.values("kind").distinct()])
  304. def has_role_for_user(self, kinds, user):
  305. if not kinds:
  306. return False
  307. if not hasattr(user, "dataset_id") or self.dataset_id != user.dataset_id:
  308. return False
  309. return HierarchyRelationsFilter(Role).filter_by_hierarchy(
  310. ancestor_collection=F("collection"),
  311. source_user=F("user"),
  312. role_kind=kinds,
  313. target_user=user,
  314. ).filter(user=self).exists()
  315. def has_role_for_collection(self, kinds, coll):
  316. if not kinds:
  317. return False
  318. if self.dataset_id != coll.dataset_id:
  319. return False
  320. return HierarchyRelationsFilter(Role).filter_by_hierarchy(
  321. ancestor_collection=F("collection"),
  322. source_user=F("user"),
  323. role_kind=kinds,
  324. descendant_collection=coll,
  325. ).filter(user=self).exists()
  326. def _has_permissions_class(self, obj):
  327. return hasattr(obj, "permissions") and isinstance(obj.permissions, BasePermissions)
  328. def can_create_instance(self, obj):
  329. # a FacilityUser's permissions are determined through the object's permission class
  330. if self._has_permissions_class(obj):
  331. return obj.permissions.user_can_create_object(self, obj)
  332. else:
  333. return False
  334. def can_read(self, obj):
  335. # a FacilityUser's permissions are determined through the object's permission class
  336. if self._has_permissions_class(obj):
  337. return obj.permissions.user_can_read_object(self, obj)
  338. else:
  339. return False
  340. def can_update(self, obj):
  341. # a FacilityUser's permissions are determined through the object's permission class
  342. if self._has_permissions_class(obj):
  343. return obj.permissions.user_can_update_object(self, obj)
  344. else:
  345. return False
  346. def can_delete(self, obj):
  347. # a FacilityUser's permissions are determined through the object's permission class
  348. if self._has_permissions_class(obj):
  349. return obj.permissions.user_can_delete_object(self, obj)
  350. else:
  351. return False
  352. def filter_readable(self, queryset):
  353. if self._has_permissions_class(queryset.model):
  354. return queryset.model.permissions.readable_by_user_filter(self, queryset).distinct()
  355. else:
  356. return queryset.none()
  357. def __str__(self):
  358. return '"{user}"@"{facility}"'.format(user=self.get_full_name() or self.username, facility=self.facility)
  359. @python_2_unicode_compatible
  360. class DeviceOwner(KolibriAbstractBaseUser):
  361. """
  362. When a user first installs Kolibri on a device, they will be prompted to create a ``DeviceOwner``, a special kind of
  363. user which is associated with that device only, and who must give permission to make broad changes to the Kolibri
  364. installation on that device (such as creating a ``Facility``, or changing configuration settings).
  365. Actions not relating to user data but specifically to a device -- like upgrading Kolibri, changing whether the
  366. device is a Classroom Server or Classroom Client, or determining manually which data should be synced -- must be
  367. performed by a ``DeviceOwner``.
  368. A ``DeviceOwner`` is a superuser, and has full access to do anything she wants with data on the device.
  369. """
  370. # DeviceOwners can access the Django admin interface
  371. is_staff = True
  372. is_superuser = True
  373. def is_member_of(self, coll):
  374. return False # a DeviceOwner is not a member of any Collection
  375. def get_roles_for_user(self, user):
  376. return set([role_kinds.ADMIN]) # a DeviceOwner has admin role for all users on the device
  377. def get_roles_for_collection(self, coll):
  378. return set([role_kinds.ADMIN]) # a DeviceOwner has admin role for all collections on the device
  379. def has_role_for_user(self, kinds, user):
  380. if isinstance(kinds, string_types):
  381. kinds = [kinds]
  382. return role_kinds.ADMIN in kinds # a DeviceOwner has admin role for all users on the device
  383. def has_role_for_collection(self, kinds, coll):
  384. if isinstance(kinds, string_types):
  385. kinds = [kinds]
  386. return role_kinds.ADMIN in kinds # a DeviceOwner has admin role for all collections on the device
  387. def can_create_instance(self, obj):
  388. # DeviceOwners are superusers, and can do anything
  389. return True
  390. def can_read(self, obj):
  391. # DeviceOwners are superusers, and can do anything
  392. return True
  393. def can_update(self, obj):
  394. # DeviceOwners are superusers, and can do anything
  395. return True
  396. def can_delete(self, obj):
  397. # DeviceOwners are superusers, and can do anything
  398. return True
  399. def filter_readable(self, queryset):
  400. return queryset
  401. def __str__(self):
  402. return self.get_full_name() or self.username
  403. @python_2_unicode_compatible
  404. class Collection(MPTTModel, AbstractFacilityDataModel):
  405. """
  406. ``Collections`` are hierarchical groups of ``FacilityUsers``, used for grouping users and making decisions about permissions.
  407. ``FacilityUsers`` can have roles for one or more ``Collections``, by way of obtaining ``Roles`` associated with those ``Collections``.
  408. ``Collections`` can belong to other ``Collections``, and user membership in a ``Collection`` is conferred through ``Memberships``.
  409. ``Collections`` are subdivided into several pre-defined levels.
  410. """
  411. # Collection can be read by anybody from the facility; writing is only allowed by an admin for the collection.
  412. # Furthermore, no FacilityUser can create or delete a Facility. Permission to create a collection is governed
  413. # by roles in relation to the new collection's parent collection (see CollectionSpecificRoleBasedPermissions).
  414. permissions = IsFromSameFacility(read_only=True) | CollectionSpecificRoleBasedPermissions()
  415. _KIND = None # Should be overridden in subclasses to specify what "kind" they are
  416. name = models.CharField(max_length=100)
  417. parent = TreeForeignKey('self', null=True, blank=True, related_name='children', db_index=True)
  418. kind = models.CharField(max_length=20, choices=collection_kinds.choices)
  419. def clean_fields(self, *args, **kwargs):
  420. self._ensure_kind()
  421. super(Collection, self).clean_fields(*args, **kwargs)
  422. def save(self, *args, **kwargs):
  423. self._ensure_kind()
  424. super(Collection, self).save(*args, **kwargs)
  425. def _ensure_kind(self):
  426. """
  427. Make sure the "kind" is set correctly on the model, corresponding to the appropriate subclass of ``Collection``.
  428. """
  429. if self._KIND:
  430. self.kind = self._KIND
  431. def get_members(self):
  432. if self.kind == collection_kinds.FACILITY:
  433. return FacilityUser.objects.filter(dataset=self.dataset) # FacilityUser is always a member of her own facility
  434. return HierarchyRelationsFilter(FacilityUser).filter_by_hierarchy(
  435. target_user=F("id"),
  436. ancestor_collection=self,
  437. )
  438. def add_role(self, user, role_kind):
  439. """
  440. Create a ``Role`` associating the provided user with this collection, with the specified kind of role.
  441. If the Role object already exists, just return that, without changing anything.
  442. :param user: The ``FacilityUser`` to associate with this ``Collection``.
  443. :param role_kind: The kind of role to give the user with respect to this ``Collection``.
  444. :return: The ``Role`` object (possibly new) that associates the user with the ``Collection``.
  445. """
  446. # ensure the specified role kind is valid
  447. if role_kind not in (kind[0] for kind in role_kinds.choices):
  448. raise InvalidRoleKind("'{role_kind}' is not a valid role kind.".format(role_kind=role_kind))
  449. # ensure the provided user is a FacilityUser
  450. if not isinstance(user, FacilityUser):
  451. raise UserIsNotFacilityUser("You can only add roles for FacilityUsers.")
  452. # create the necessary role, if it doesn't already exist
  453. role, created = Role.objects.get_or_create(user=user, collection=self, kind=role_kind)
  454. return role
  455. def remove_role(self, user, role_kind):
  456. """
  457. Remove any ``Role`` objects associating the provided user with this ``Collection``, with the specified kind of role.
  458. :param user: The ``FacilityUser`` to dissociate from this ``Collection`` (for the specific role kind).
  459. :param role_kind: The kind of role to remove from the user with respect to this ``Collection``.
  460. """
  461. # ensure the specified role kind is valid
  462. if role_kind not in (kind[0] for kind in role_kinds.choices):
  463. raise InvalidRoleKind("'{role_kind}' is not a valid role kind.".format(role_kind=role_kind))
  464. # ensure the provided user is a FacilityUser
  465. if not isinstance(user, FacilityUser):
  466. raise UserIsNotFacilityUser("You can only remove roles for FacilityUsers.")
  467. # make sure the user has the role to begin with
  468. if not user.has_role_for_collection(role_kind, self):
  469. raise UserDoesNotHaveRoleError("User does not have this role for this collection.")
  470. # delete the appropriate role, if it exists
  471. results = Role.objects.filter(user=user, collection=self, kind=role_kind).delete()
  472. # if no Roles were deleted, the user's role must have been indirect (via the collection hierarchy)
  473. if results[0] == 0:
  474. raise UserHasRoleOnlyIndirectlyThroughHierarchyError(
  475. "Role cannot be removed, as user has it only indirectly, through the collection hierarchy.")
  476. def add_member(self, user):
  477. """
  478. Create a ``Membership`` associating the provided user with this ``Collection``.
  479. If the ``Membership`` object already exists, just return that, without changing anything.
  480. :param user: The ``FacilityUser`` to add to this ``Collection``.
  481. :return: The ``Membership`` object (possibly new) that associates the user with the ``Collection``.
  482. """
  483. # ensure the provided user is a FacilityUser
  484. if not isinstance(user, FacilityUser):
  485. raise UserIsNotFacilityUser("You can only add memberships for FacilityUsers.")
  486. # create the necessary membership, if it doesn't already exist
  487. membership, created = Membership.objects.get_or_create(user=user, collection=self)
  488. return membership
  489. def remove_member(self, user):
  490. """
  491. Remove any ``Membership`` objects associating the provided user with this ``Collection``.
  492. :param user: The ``FacilityUser`` to remove from this ``Collection``.
  493. :return: ``True`` if a ``Membership`` was removed, ``False`` if there was no matching ``Membership`` to remove.
  494. """
  495. # ensure the provided user is a FacilityUser
  496. if not isinstance(user, FacilityUser):
  497. raise UserIsNotFacilityUser("You can only remove memberships for FacilityUsers.")
  498. if not user.is_member_of(self):
  499. raise UserIsNotMemberError("The user is not a member of the collection, and cannot be removed.")
  500. # delete the appropriate membership, if it exists
  501. results = Membership.objects.filter(user=user, collection=self).delete()
  502. # if no Memberships were deleted, the user's membership must have been indirect (via the collection hierarchy)
  503. if results[0] == 0:
  504. raise UserIsMemberOnlyIndirectlyThroughHierarchyError(
  505. "Membership cannot be removed, as user is a member only indirectly, through the collection hierarchy.")
  506. def infer_dataset(self):
  507. if self.parent:
  508. # subcollections inherit dataset from root of their tree
  509. # (we can't call `get_root` directly on self, as it won't work if self hasn't yet been saved)
  510. return self.parent.get_root().dataset
  511. else:
  512. return None # the root node (i.e. Facility) must be explicitly tied to a dataset
  513. def __str__(self):
  514. return '"{name}" ({kind})'.format(name=self.name, kind=self.kind)
  515. @python_2_unicode_compatible
  516. class Membership(AbstractFacilityDataModel):
  517. """
  518. A ``FacilityUser`` can be marked as a member of a ``Collection`` through a ``Membership`` object. Being a member of a
  519. ``Collection`` also means being a member of all the ``Collections`` above that ``Collection`` in the tree (i.e. if you
  520. are a member of a ``LearnerGroup``, you are also a member of the ``Classroom`` that contains that ``LearnerGroup``,
  521. and of the ``Facility`` that contains that ``Classroom``).
  522. """
  523. permissions = (
  524. IsOwn(read_only=True) | # users can read their own Memberships
  525. RoleBasedPermissions( # Memberships can be read and written by admins, and read by coaches, for the member user
  526. target_field="user",
  527. can_be_created_by=(role_kinds.ADMIN,),
  528. can_be_read_by=(role_kinds.ADMIN, role_kinds.COACH),
  529. can_be_updated_by=(), # Membership objects shouldn't be updated; they should be deleted and recreated as needed
  530. can_be_deleted_by=(role_kinds.ADMIN,),
  531. )
  532. )
  533. user = models.ForeignKey('FacilityUser', blank=False, null=False)
  534. # Note: "It's recommended you use mptt.fields.TreeForeignKey wherever you have a foreign key to an MPTT model.
  535. # https://django-mptt.github.io/django-mptt/models.html#treeforeignkey-treeonetoonefield-treemanytomanyfield
  536. collection = TreeForeignKey("Collection")
  537. class Meta:
  538. unique_together = (("user", "collection"),)
  539. def infer_dataset(self):
  540. user_dataset = self.user.dataset
  541. collection_dataset = self.collection.dataset
  542. if user_dataset != collection_dataset:
  543. raise KolibriValidationError("Collection and user for a Membership object must be in same dataset.")
  544. return user_dataset
  545. def __str__(self):
  546. return "{user}'s membership in {collection}".format(user=self.user, collection=self.collection)
  547. @python_2_unicode_compatible
  548. class Role(AbstractFacilityDataModel):
  549. """
  550. A ``FacilityUser`` can have a role for a particular ``Collection`` through a ``Role`` object, which also stores
  551. the "kind" of the ``Role`` (currently, one of "admin" or "coach"). Having a role for a ``Collection`` also
  552. implies having that role for all sub-collections of that ``Collection`` (i.e. all the ``Collections`` below it
  553. in the tree).
  554. """
  555. permissions = (
  556. IsOwn(read_only=True) | # users can read their own Roles
  557. RoleBasedPermissions( # Memberships can be read and written by admins, and read by coaches, for the role collection
  558. target_field="collection",
  559. can_be_created_by=(role_kinds.ADMIN,),
  560. can_be_read_by=(role_kinds.ADMIN, role_kinds.COACH),
  561. can_be_updated_by=(), # Role objects shouldn't be updated; they should be deleted and recreated as needed
  562. can_be_deleted_by=(role_kinds.ADMIN,),
  563. )
  564. )
  565. user = models.ForeignKey('FacilityUser', blank=False, null=False)
  566. # Note: "It's recommended you use mptt.fields.TreeForeignKey wherever you have a foreign key to an MPTT model.
  567. # https://django-mptt.github.io/django-mptt/models.html#treeforeignkey-treeonetoonefield-treemanytomanyfield
  568. collection = TreeForeignKey("Collection")
  569. kind = models.CharField(max_length=20, choices=role_kinds.choices)
  570. class Meta:
  571. unique_together = (("user", "collection", "kind"),)
  572. def infer_dataset(self):
  573. user_dataset = self.user.dataset
  574. collection_dataset = self.collection.dataset
  575. if user_dataset != collection_dataset:
  576. raise KolibriValidationError("The collection and user for a Role object must be in the same dataset.")
  577. return user_dataset
  578. def __str__(self):
  579. return "{user}'s {kind} role for {collection}".format(user=self.user, kind=self.kind, collection=self.collection)
  580. class CollectionProxyManager(models.Manager):
  581. def get_queryset(self):
  582. return super(CollectionProxyManager, self).get_queryset().filter(kind=self.model._KIND)
  583. @python_2_unicode_compatible
  584. class Facility(Collection):
  585. _KIND = collection_kinds.FACILITY
  586. objects = CollectionProxyManager()
  587. class Meta:
  588. proxy = True
  589. def save(self, *args, **kwargs):
  590. if self.parent:
  591. raise IntegrityError("Facility must be the root of a collection tree, and cannot have a parent.")
  592. super(Facility, self).save(*args, **kwargs)
  593. def infer_dataset(self):
  594. # if we don't yet have a dataset, create a new one for this facility
  595. if not self.dataset_id:
  596. self.dataset = FacilityDataset.objects.create()
  597. return self.dataset
  598. def get_classrooms(self):
  599. """
  600. Returns a QuerySet of Classrooms under this Facility.
  601. :return: A Classroom QuerySet.
  602. """
  603. return Classroom.objects.filter(parent=self)
  604. def add_admin(self, user):
  605. return self.add_role(user, role_kinds.ADMIN)
  606. def add_admins(self, users):
  607. return [self.add_admin(user) for user in users]
  608. def remove_admin(self, user):
  609. self.remove_role(user, role_kinds.ADMIN)
  610. def add_coach(self, user):
  611. return self.add_role(user, role_kinds.COACH)
  612. def add_coaches(self, users):
  613. return [self.add_coach(user) for user in users]
  614. def remove_coach(self, user):
  615. self.remove_role(user, role_kinds.COACH)
  616. def __str__(self):
  617. return self.name
  618. @python_2_unicode_compatible
  619. class Classroom(Collection):
  620. _KIND = collection_kinds.CLASSROOM
  621. objects = CollectionProxyManager()
  622. class Meta:
  623. proxy = True
  624. def save(self, *args, **kwargs):
  625. if not self.parent:
  626. raise IntegrityError("Classroom cannot be the root of a collection tree, and must have a parent.")
  627. super(Classroom, self).save(*args, **kwargs)
  628. def get_facility(self):
  629. """
  630. Gets the ``Classroom``'s parent ``Facility``.
  631. :return: A ``Facility`` instance.
  632. """
  633. return Facility.objects.get(id=self.parent_id)
  634. def get_learner_groups(self):
  635. """
  636. Returns a ``QuerySet`` of ``LearnerGroups`` associated with this ``Classroom``.
  637. :return: A ``LearnerGroup`` ``QuerySet``.
  638. """
  639. return LearnerGroup.objects.filter(parent=self)
  640. def add_admin(self, user):
  641. return self.add_role(user, role_kinds.ADMIN)
  642. def add_admins(self, users):
  643. return [self.add_admin(user) for user in users]
  644. def remove_admin(self, user):
  645. self.remove_role(user, role_kinds.ADMIN)
  646. def add_coach(self, user):
  647. return self.add_role(user, role_kinds.COACH)
  648. def add_coaches(self, users):
  649. return [self.add_coach(user) for user in users]
  650. def remove_coach(self, user):
  651. self.remove_role(user, role_kinds.COACH)
  652. def __str__(self):
  653. return self.name
  654. @python_2_unicode_compatible
  655. class LearnerGroup(Collection):
  656. _KIND = collection_kinds.LEARNERGROUP
  657. objects = CollectionProxyManager()
  658. class Meta:
  659. proxy = True
  660. def save(self, *args, **kwargs):
  661. if not self.parent:
  662. raise IntegrityError("LearnerGroup cannot be the root of a collection tree, and must have a parent.")
  663. super(LearnerGroup, self).save(*args, **kwargs)
  664. def get_classroom(self):
  665. """
  666. Gets the ``LearnerGroup``'s parent ``Classroom``.
  667. :return: A ``Classroom`` instance.
  668. """
  669. return Classroom.objects.get(id=self.parent_id)
  670. def add_learner(self, user):
  671. return self.add_member(user)
  672. def add_learners(self, users):
  673. return [self.add_learner(user) for user in users]
  674. def remove_learner(self, user):
  675. return self.remove_member(user)
  676. def __str__(self):
  677. return self.name