services.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383
  1. # Copyright (C) 2014 Andrey Antukh <niwi@niwi.be>
  2. # This program is free software: you can redistribute it and/or modify
  3. # it under the terms of the GNU Affero General Public License as
  4. # published by the Free Software Foundation, either version 3 of the
  5. # License, or (at your option) any later version.
  6. #
  7. # This program is distributed in the hope that it will be useful,
  8. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  9. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  10. # GNU Affero General Public License for more details.
  11. #
  12. # You should have received a copy of the GNU Affero General Public License
  13. # along with this program. If not, see <http://www.gnu.org/licenses/>.
  14. """
  15. This module contains a main domain logic for object history management.
  16. This is possible example:
  17. from taiga.projects import history
  18. class ViewSet(restfw.ViewSet):
  19. def create(request):
  20. object = get_some_object()
  21. history.freeze(object)
  22. # Do something...
  23. history.persist_history(object, user=request.user)
  24. """
  25. import logging
  26. from collections import namedtuple
  27. from copy import deepcopy
  28. from functools import partial
  29. from functools import wraps
  30. from functools import lru_cache
  31. from django.conf import settings
  32. from django.contrib.contenttypes.models import ContentType
  33. from django.core.paginator import Paginator, InvalidPage
  34. from django.apps import apps
  35. from django.db import transaction as tx
  36. from django_pglocks import advisory_lock
  37. from taiga.mdrender.service import render as mdrender
  38. from taiga.base.utils.db import get_typename_for_model_class
  39. from taiga.base.utils.diff import make_diff as make_diff_from_dicts
  40. from .models import HistoryType
  41. # Type that represents a freezed object
  42. FrozenObj = namedtuple("FrozenObj", ["key", "snapshot"])
  43. FrozenDiff = namedtuple("FrozenDiff", ["key", "diff", "snapshot"])
  44. # Dict containing registred contentypes with their freeze implementation.
  45. _freeze_impl_map = {}
  46. # Dict containing registred containing with their values implementation.
  47. _values_impl_map = {}
  48. # Not important fields for models (history entries with only
  49. # this fields are marked as hidden).
  50. _not_important_fields = {
  51. "userstories.userstory": frozenset(["backlog_order", "sprint_order", "kanban_order"]),
  52. "tasks.task": frozenset(["us_order", "taskboard_order"]),
  53. }
  54. log = logging.getLogger("taiga.history")
  55. def make_key_from_model_object(obj:object) -> str:
  56. """
  57. Create unique key from model instance.
  58. """
  59. tn = get_typename_for_model_class(obj.__class__)
  60. return "{0}:{1}".format(tn, obj.pk)
  61. def get_model_from_key(key:str) -> object:
  62. """
  63. Get model from key
  64. """
  65. class_name, pk = key.split(":", 1)
  66. return apps.get_model(class_name)
  67. def get_pk_from_key(key:str) -> object:
  68. """
  69. Get pk from key
  70. """
  71. class_name, pk = key.split(":", 1)
  72. return pk
  73. def register_values_implementation(typename:str, fn=None):
  74. """
  75. Register values implementation for specified typename.
  76. This function can be used as decorator.
  77. """
  78. assert isinstance(typename, str), "typename must be specied"
  79. if fn is None:
  80. return partial(register_values_implementation, typename)
  81. @wraps(fn)
  82. def _wrapper(*args, **kwargs):
  83. return fn(*args, **kwargs)
  84. _values_impl_map[typename] = _wrapper
  85. return _wrapper
  86. def register_freeze_implementation(typename:str, fn=None):
  87. """
  88. Register freeze implementation for specified typename.
  89. This function can be used as decorator.
  90. """
  91. assert isinstance(typename, str), "typename must be specied"
  92. if fn is None:
  93. return partial(register_freeze_implementation, typename)
  94. @wraps(fn)
  95. def _wrapper(*args, **kwargs):
  96. return fn(*args, **kwargs)
  97. _freeze_impl_map[typename] = _wrapper
  98. return _wrapper
  99. # Low level api
  100. def freeze_model_instance(obj:object) -> FrozenObj:
  101. """
  102. Creates a new frozen object from model instance.
  103. The freeze process consists on converting model
  104. instances to hashable plain python objects and
  105. wrapped into FrozenObj.
  106. """
  107. model_cls = obj.__class__
  108. # Additional query for test if object is really exists
  109. # on the database or it is removed.
  110. try:
  111. obj = model_cls.objects.get(pk=obj.pk)
  112. except model_cls.DoesNotExist:
  113. return None
  114. typename = get_typename_for_model_class(model_cls)
  115. if typename not in _freeze_impl_map:
  116. raise RuntimeError("No implementation found for {}".format(typename))
  117. key = make_key_from_model_object(obj)
  118. impl_fn = _freeze_impl_map[typename]
  119. snapshot = impl_fn(obj)
  120. assert isinstance(snapshot, dict), "freeze handlers should return always a dict"
  121. return FrozenObj(key, snapshot)
  122. def is_hidden_snapshot(obj:FrozenDiff) -> bool:
  123. """
  124. Check if frozen object is considered
  125. hidden or not.
  126. """
  127. content_type, pk = obj.key.rsplit(":", 1)
  128. snapshot_fields = frozenset(obj.diff.keys())
  129. if content_type not in _not_important_fields:
  130. return False
  131. nfields = _not_important_fields[content_type]
  132. result = snapshot_fields - nfields
  133. if snapshot_fields and len(result) == 0:
  134. return True
  135. return False
  136. def make_diff(oldobj:FrozenObj, newobj:FrozenObj) -> FrozenDiff:
  137. """
  138. Compute a diff between two frozen objects.
  139. """
  140. assert isinstance(newobj, FrozenObj), "newobj parameter should be instance of FrozenObj"
  141. if oldobj is None:
  142. return FrozenDiff(newobj.key, {}, newobj.snapshot)
  143. first = oldobj.snapshot
  144. second = newobj.snapshot
  145. diff = make_diff_from_dicts(first, second)
  146. return FrozenDiff(newobj.key, diff, newobj.snapshot)
  147. def make_diff_values(typename:str, fdiff:FrozenDiff) -> dict:
  148. """
  149. Given a typename and diff, build a values dict for it.
  150. If no implementation found for typename, warnig is raised in
  151. logging and returns empty dict.
  152. """
  153. if typename not in _values_impl_map:
  154. log.warning("No implementation found of '{}' for values.".format(typename))
  155. return {}
  156. impl_fn = _values_impl_map[typename]
  157. return impl_fn(fdiff.diff)
  158. def _rebuild_snapshot_from_diffs(keysnapshot, partials):
  159. result = deepcopy(keysnapshot)
  160. for part in partials:
  161. for key, value in part.diff.items():
  162. result[key] = value[1]
  163. return result
  164. def get_last_snapshot_for_key(key:str) -> FrozenObj:
  165. entry_model = apps.get_model("history", "HistoryEntry")
  166. # Search last snapshot
  167. qs = (entry_model.objects
  168. .filter(key=key, is_snapshot=True)
  169. .order_by("-created_at"))
  170. keysnapshot = qs.first()
  171. if keysnapshot is None:
  172. return None, True
  173. # Get all partial snapshots
  174. entries = tuple(entry_model.objects
  175. .filter(key=key, is_snapshot=False)
  176. .filter(created_at__gte=keysnapshot.created_at)
  177. .order_by("created_at"))
  178. snapshot = _rebuild_snapshot_from_diffs(keysnapshot.snapshot, entries)
  179. max_partial_diffs = getattr(settings, "MAX_PARTIAL_DIFFS", 60)
  180. if len(entries) >= max_partial_diffs:
  181. return FrozenObj(keysnapshot.key, snapshot), True
  182. return FrozenObj(keysnapshot.key, snapshot), False
  183. # Public api
  184. def get_modified_fields(obj:object, last_modifications):
  185. """
  186. Get the modified fields for an object through his last modifications
  187. """
  188. key = make_key_from_model_object(obj)
  189. entry_model = apps.get_model("history", "HistoryEntry")
  190. history_entries = (entry_model.objects
  191. .filter(key=key)
  192. .order_by("-created_at")
  193. .values_list("diff", flat=True)
  194. [0:last_modifications])
  195. modified_fields = []
  196. for history_entry in history_entries:
  197. modified_fields += history_entry.keys()
  198. return modified_fields
  199. @tx.atomic
  200. def take_snapshot(obj:object, *, comment:str="", user=None, delete:bool=False):
  201. """
  202. Given any model instance with registred content type,
  203. create new history entry of "change" type.
  204. This raises exception in case of object wasn't
  205. previously freezed.
  206. """
  207. key = make_key_from_model_object(obj)
  208. with advisory_lock(key) as acquired_key_lock:
  209. typename = get_typename_for_model_class(obj.__class__)
  210. new_fobj = freeze_model_instance(obj)
  211. old_fobj, need_real_snapshot = get_last_snapshot_for_key(key)
  212. entry_model = apps.get_model("history", "HistoryEntry")
  213. user_id = None if user is None else user.id
  214. user_name = "" if user is None else user.get_full_name()
  215. # Determine history type
  216. if delete:
  217. entry_type = HistoryType.delete
  218. elif new_fobj and not old_fobj:
  219. entry_type = HistoryType.create
  220. elif new_fobj and old_fobj:
  221. entry_type = HistoryType.change
  222. else:
  223. raise RuntimeError("Unexpected condition")
  224. fdiff = make_diff(old_fobj, new_fobj)
  225. # If diff and comment are empty, do
  226. # not create empty history entry
  227. if (not fdiff.diff and not comment
  228. and old_fobj is not None
  229. and entry_type != HistoryType.delete):
  230. return None
  231. fvals = make_diff_values(typename, fdiff)
  232. if len(comment) > 0:
  233. is_hidden = False
  234. else:
  235. is_hidden = is_hidden_snapshot(fdiff)
  236. kwargs = {
  237. "user": {"pk": user_id, "name": user_name},
  238. "key": key,
  239. "type": entry_type,
  240. "snapshot": fdiff.snapshot if need_real_snapshot else None,
  241. "diff": fdiff.diff,
  242. "values": fvals,
  243. "comment": comment,
  244. "comment_html": mdrender(obj.project, comment),
  245. "is_hidden": is_hidden,
  246. "is_snapshot": need_real_snapshot,
  247. }
  248. return entry_model.objects.create(**kwargs)
  249. # High level query api
  250. def get_history_queryset_by_model_instance(obj:object, types=(HistoryType.change,),
  251. include_hidden=False):
  252. """
  253. Get one page of history for specified object.
  254. """
  255. key = make_key_from_model_object(obj)
  256. history_entry_model = apps.get_model("history", "HistoryEntry")
  257. qs = history_entry_model.objects.filter(key=key, type__in=types)
  258. if not include_hidden:
  259. qs = qs.filter(is_hidden=False)
  260. return qs.order_by("created_at")
  261. # Freeze implementatitions
  262. from .freeze_impl import project_freezer
  263. from .freeze_impl import milestone_freezer
  264. from .freeze_impl import userstory_freezer
  265. from .freeze_impl import issue_freezer
  266. from .freeze_impl import task_freezer
  267. from .freeze_impl import wikipage_freezer
  268. register_freeze_implementation("projects.project", project_freezer)
  269. register_freeze_implementation("milestones.milestone", milestone_freezer,)
  270. register_freeze_implementation("userstories.userstory", userstory_freezer)
  271. register_freeze_implementation("issues.issue", issue_freezer)
  272. register_freeze_implementation("tasks.task", task_freezer)
  273. register_freeze_implementation("wiki.wikipage", wikipage_freezer)
  274. from .freeze_impl import project_values
  275. from .freeze_impl import milestone_values
  276. from .freeze_impl import userstory_values
  277. from .freeze_impl import issue_values
  278. from .freeze_impl import task_values
  279. from .freeze_impl import wikipage_values
  280. register_values_implementation("projects.project", project_values)
  281. register_values_implementation("milestones.milestone", milestone_values)
  282. register_values_implementation("userstories.userstory", userstory_values)
  283. register_values_implementation("issues.issue", issue_values)
  284. register_values_implementation("tasks.task", task_values)
  285. register_values_implementation("wiki.wikipage", wikipage_values)