source: main/trunk/openPLM/plmapp/navigate.py @ 980

Revision 980, 24.0 KB checked in by pcosquer, 11 years ago (diff)

navigate: reduces the size of generated data

Line 
1############################################################################
2# openPLM - open source PLM
3# Copyright 2010 Philippe Joulaud, Pierre Cosquer
4#
5# This file is part of openPLM.
6#
7#    openPLM is free software: you can redistribute it and/or modify
8#    it under the terms of the GNU General Public License as published by
9#    the Free Software Foundation, either version 3 of the License, or
10#    (at your option) any later version.
11#
12#    openPLM is distributed in the hope that it will be useful,
13#    but WITHOUT ANY WARRANTY; without even the implied warranty of
14#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15#    GNU General Public License for more details.
16#
17#    You should have received a copy of the GNU General Public License
18#    along with openPLM.  If not, see <http://www.gnu.org/licenses/>.
19#
20# Contact :
21#    Philippe Joulaud : ninoo.fr@gmail.com
22#    Pierre Cosquer : pierre.cosquer@insa-rennes.fr
23################################################################################
24
25"""
26This module provides :class:`NavigationGraph` which is used to generate
27the navigation's graph in :func:`~plmapp.views.navigate`.
28"""
29
30import re
31import warnings
32import cStringIO as StringIO
33import xml.etree.cElementTree as ET
34from collections import defaultdict
35
36from django.contrib.auth.models import User, Group
37from django.template.loader import render_to_string
38from django.utils.html import linebreaks
39from django.utils.encoding import iri_to_uri
40
41import pygraphviz as pgv
42
43from openPLM.plmapp import models
44from openPLM.plmapp.controllers import PLMObjectController, PartController,\
45                                       GroupController
46from openPLM.plmapp.controllers.user import UserController
47
48# just a shortcut
49OSR = "only_search_results"
50class FrozenAGraph(pgv.AGraph):
51    '''
52    A frozen AGraph
53
54    :param data: representation of the graph in dot format
55    '''
56
57    def __init__(self, data):
58        pgv.AGraph.__init__(self, data)
59        self.data = data
60
61    def write(self, path):
62        if hasattr(path, "write"):
63            path.write(self.data.encode("utf-8"))
64        else:
65            with file(path, "w") as f:
66                f.write(self.data)
67
68def get_path(obj):
69    if hasattr(obj, "type"):
70        return u"/".join((obj.type, obj.reference, obj.revision))
71    elif hasattr(obj, 'name'):
72        return u"Group/%s/-/" % obj.name
73    else:
74        return u"User/%s/-/" % obj.username
75
76_attrs = ("id", "type", "reference", "revision", "name")
77_plmobjects_attrs = ["plmobject__" + x for x in _attrs]
78_parts_attrs = ["part__" + x for x in _attrs]
79_documents_attrs = ["document__" + x for x in _attrs]
80
81def is_part(plmobject):
82    return plmobject["type"] in models.get_all_parts()
83
84class NavigationGraph(object):
85    """
86    This object can be used to generate a naviation's graph from an
87    object.
88
89    By default, the graph contains one node: the object given as argument.
90    You can change this behaviour with :meth`set_options`
91
92    Usage::
93
94        graph = NavigationGraph(a_part_controller)
95        graph.set_options({'child' : True, "parents" : True })
96        graph.create_edges()
97        map_str, picture_url = graph.render()
98
99    :param obj: root of the graph
100    :type obj: :class:`.PLMObjectController` or :class:`.UserController`
101    :param results: if the option *only_search_results* is set, only objects in
102                    results are displayed
103
104    .. warning::
105        *results* must not be a QuerySet if it contains users.
106    """
107
108    GRAPH_ATTRIBUTES = dict(dpi='96.0',
109                            mindist=".5",
110                            center='true',
111                            pad='0.1',
112                            mode="ipsep",
113                            overlap="false",
114                            splines="false",
115                            sep="+.1,.1",
116                            outputorder="edgesfirst",
117                            bgcolor="transparent")
118    NODE_ATTRIBUTES = dict(shape='none',
119                           fixedsize='true',
120                           bgcolor="transparent",
121                           width=100./96,
122                           height=70./96)
123    EDGE_ATTRIBUTES = dict(color='#373434',
124                           minlen="1.5",
125                           len="1.5",
126                           arrowhead='normal',
127                           fontname="Sans bold",
128                           fontcolor="transparent",
129                           fontsize="9")
130
131    def __init__(self, obj, results=()):
132        self.object = obj
133        self.results = [r.id for r in results]
134        # a PLMObject and an user may have the same id, so we add a variable
135        # which tells if results contains users
136        self.users_result = self.groups_result = False
137        if results:
138            self.users_result = hasattr(results[0], "username")
139            self.groups_result = isinstance(results[0], Group)
140        self.plmobjects_result = not (self.groups_result or self.users_result)
141        options = ("child", "parents", "doc", "owner", "signer",
142                   "notified", "part", "owned", "to_sign",
143                   "request_notification_from", OSR)
144        self.options = dict.fromkeys(options, False)
145        self.options["prog"] = "dot"
146        self.options["doc_parts"] = []
147        self.nodes = defaultdict(dict)
148        self.edges = set()
149        self.graph = pgv.AGraph(directed=True)
150        self.graph.graph_attr.update(self.GRAPH_ATTRIBUTES)
151        self.graph.node_attr.update(self.NODE_ATTRIBUTES)
152        self.graph.edge_attr.update(self.EDGE_ATTRIBUTES)
153        self._title_to_node = {}
154        self._part_to_node = {}
155
156    def set_options(self, options):
157        """
158        Sets which kind of edges should be inserted.
159
160        Options is a dictionary(*option_name* -> boolean)
161
162        The option *only_search_results* enables results filtering.
163
164        If the root is a :class:`.PartController`, valid options are:
165
166            ========== ======================================================
167             Name       Description
168            ========== ======================================================
169             child      If True, adds recursively all children of the root
170             parents    If True, adds recursively all parents of the root
171             doc        If True, adds documents attached to the parts
172             owner      If True, adds the owner of the root
173             signer     If True, adds the signers of the root
174             notified   If True, adds the notified of the root
175            ========== ======================================================
176
177        If the root is a :class:`.DocumentController`, valid options are:
178
179            ========== ======================================================
180             Name       Description
181            ========== ======================================================
182             parts      If True, adds parts attached to the root
183             owner      If True, adds the owner of the root
184             signer     If True, adds the signers of the root
185             notified   If True, adds the notified of the root
186            ========== ======================================================
187
188        If the root is a :class:`.UserController`, valid options are:
189
190            ========================== ======================================
191             Name                          Description
192            ========================== ======================================
193             owned                     If True, adds all plmobjects owned by
194                                       the root
195             to_sign                   If True, adds all plmobjects signed by
196                                       the root
197             request_notification_from If True, adds all plmobjects which
198                                       notifies the root
199            ========================== ======================================
200
201        """
202        self.options.update(options)
203        if self.options["prog"] == "twopi":
204            self.graph.graph_attr["ranksep"] = "1.2"
205       
206    def _create_child_edges(self, obj, *args):
207        if self.options[OSR] and not self.plmobjects_result:
208            return
209        for child_l in obj.get_children(max_level=-1, related=("child",)):
210            link = child_l.link
211            if self.options[OSR] and link.child.id not in self.results:
212                continue
213            if link.parent_id not in self._part_to_node:
214                continue
215            child = link.child
216            label = "Qty: %.2f %s\\nOrder: %d" % (link.quantity,
217                    link.get_shortened_unit(), link.order)
218            self.edges.add((link.parent_id, child.id, label))
219            self._set_node_attributes(link.child)
220   
221    def _create_parents_edges(self, obj, *args):
222        if self.options[OSR] and not self.plmobjects_result:
223            return
224        for parent_l in obj.get_parents(max_level=-1, related=("parent",)):
225            link = parent_l.link
226            if self.options[OSR] and link.parent.id not in self.results:
227                continue
228            if link.child_id not in self._part_to_node:
229                continue
230            parent = link.parent
231            label = "Qty: %.2f %s\\nOrder: %d" % (link.quantity,
232                    link.get_shortened_unit(), link.order)
233            self.edges.add((parent.id, link.child_id, label))
234            self._set_node_attributes(parent)
235   
236    def _create_part_edges(self, obj, *args):
237        if self.options[OSR] and not self.plmobjects_result:
238            return
239        if isinstance(obj, GroupController):
240            node = "Group%d" % obj.id
241            parts = obj.get_attached_parts().only("type", "reference", "revision", "name")
242            for part in parts:
243                if self.options[OSR] and part.id not in self.results:
244                    continue
245                self.edges.add((node, part.id, " "))
246                self._set_node_attributes(part)
247        else:
248            for link in obj.get_attached_parts().select_related("part").only(*_parts_attrs):
249                if self.options[OSR] and link.part.id not in self.results:
250                    continue
251                # create a link part -> document:
252                # if layout is dot, the part is on top of the document
253                # cf. tickets #82 and #83
254                self.edges.add((link.part_id, obj.id, " "))
255                self._set_node_attributes(link.part)
256   
257    def _create_doc_edges(self, obj, obj_id=None, *args):
258        if self.options[OSR] and not self.plmobjects_result:
259            return
260        if isinstance(obj, GroupController):
261            node = "Group%d" % obj.id
262            docs = obj.get_attached_documents().only("type", "reference", "revision", "name")
263            for doc in docs:
264                if self.options[OSR] and doc.id not in self.results:
265                    continue
266                self.edges.add((node, doc.id, " "))
267                self._set_node_attributes(doc)
268        else:
269            # obj is the part id
270            links = models.DocumentPartLink.objects.filter(part__id=obj).select_related("document")
271            for link in links.only(*_documents_attrs):
272                if self.options[OSR] and link.document_id not in self.results:
273                    continue
274                self.edges.add((obj_id or obj, link.document_id, " "))
275                self._set_node_attributes(link.document)
276
277    def _create_user_edges(self, obj, role):
278        if self.options[OSR] and not self.users_result:
279            return
280        if hasattr(obj, 'user_set'):
281            if role == "owner":
282                users = ((obj.owner, role),)
283            else:
284                users = ((u, role) for u in obj.user_set.all())
285        else:
286            users = obj.plmobjectuserlink_plmobject.filter(role__istartswith=role)
287            users = ((u.user, u.role) for u in users.all())
288        node = "Group%d" % obj.id if isinstance(obj, GroupController) else obj.id
289        for user, role in users:
290            if self.options[OSR] and user.id not in self.results:
291                continue
292            user.plmobject_url = "/user/%s/" % user.username
293            user_id = role + str(user.id)
294            self.edges.add((user_id, node, role.replace("_", "\\n")))
295            self._set_node_attributes(user, user_id, role)
296
297    def _create_object_edges(self, obj, role):
298        if self.options[OSR] and not self.plmobjects_result:
299            return
300        node = "User%d" % obj.id
301        if role in ("owner", "notified"):
302            if role == "owner":
303                qs = obj.plmobject_owner
304            else:
305                qs = obj.plmobjectuserlink_user.filter(role=role)
306                qs = qs.values_list("plmobject_id", flat=True).order_by()
307                qs = models.PLMObject.objects.filter(id__in=qs)
308            links = qs.values("id", "type", "reference", "revision", "name").order_by()
309            for plmobject in links:
310                if self.options[OSR] and plmobject["id"] not in self.results:
311                    continue
312                part_doc_id = role + str(plmobject["id"])
313                self.edges.add((node, part_doc_id, role))
314                if is_part(plmobject):
315                    if plmobject["id"] in self.options["doc_parts"]:
316                        self._create_doc_edges(plmobject["id"], part_doc_id)
317                self._set_node_attributes(plmobject, part_doc_id, type_="plmobject")
318
319        else:
320            # signer roles
321            qs = obj.plmobjectuserlink_user.filter(role__istartswith=role)
322            for link in qs.select_related("plmobject").only("role", *_plmobjects_attrs):
323                if self.options[OSR] and link.plmobject_id not in self.results:
324                    continue
325                part_doc_id = link.role + str(link.plmobject_id)
326                self.edges.add((node, part_doc_id, link.role.replace("_", "\\n")))
327                part_doc = link.plmobject
328                if part_doc.is_part:
329                    if part_doc.id in self.options["doc_parts"]:
330                        self._create_doc_edges(part_doc.id, part_doc_id)
331                self._set_node_attributes(part_doc, part_doc_id)
332
333    def create_edges(self):
334        """
335        Builds the graph (adds all edges and nodes that respected the options)
336        """
337        self.options["doc_parts"] = frozenset(self.options["doc_parts"])
338        self.doc_parts = "#".join(str(o) for o in self.options["doc_parts"])
339        if isinstance(self.object, UserController):
340            id_ = "User%d" % self.object.id
341        elif isinstance(self.object, GroupController):
342            id_ = "Group%d" % self.object.id
343        else:
344            id_ = self.object.id
345        node = self.nodes[id_]
346        self._set_node_attributes(self.object, id_)
347        self.main_node = node["id"]
348        node["width"] = 110. / 96
349        node["height"] = 80. / 96
350        opt_to_meth = {
351            'child' : (self._create_child_edges, None),
352            'parents' : (self._create_parents_edges, None),
353            'owner' : (self._create_user_edges, 'owner'),
354            'signer' : (self._create_user_edges, 'sign'),
355            'notified' : (self._create_user_edges, 'notified'),
356            'user' : (self._create_user_edges, 'member'),
357            'part' : (self._create_part_edges, None),
358            'owned' : (self._create_object_edges, 'owner'),
359            'to_sign' : (self._create_object_edges, 'sign'),
360            'request_notification_from' : (self._create_object_edges, 'notified'),
361        }
362        for field, value in self.options.iteritems():
363            if value and field in opt_to_meth:
364                function, argument = opt_to_meth[field]
365                function(self.object, argument)
366        # now that all parts have been added, we can add the documents
367        if self.options["doc"]:
368            if not (self.options[OSR] and not self.plmobjects_result):
369                if isinstance(self.object, GroupController):
370                    self._create_doc_edges(self.object, None)
371                links = models.DocumentPartLink.objects.\
372                        filter(part__in=self._part_to_node.keys())
373                for link in links.select_related("document"):
374                    if self.options[OSR] and link.document_id not in self.results:
375                        continue
376                   
377                    self.edges.add((link.part_id, link.document_id, " "))
378                    self._set_node_attributes(link.document)
379
380        elif not isinstance(self.object, UserController):
381            if not (self.options[OSR] and not self.plmobjects_result):
382                ids = self.options["doc_parts"].intersection(self._part_to_node.keys())
383                links = models.DocumentPartLink.objects.filter(part__in=ids)
384                for link in links.select_related("document"):
385                    if self.options[OSR] and link.document_id not in self.results:
386                        continue
387                   
388                    self.edges.add((link.part_id, link.document_id, " "))
389                    self._set_node_attributes(link.document)
390
391        # treats the parts to see if they have an attached document
392        if not self.options["doc"]:
393            parts = models.DocumentPartLink.objects.\
394                    filter(part__in=self._part_to_node.keys()).\
395                    values_list("part_id", flat=True)
396            for id_ in parts:
397                data = self._part_to_node[id_]
398                data["show_documents"] = True
399                if id_ not in self.options["doc_parts"]:
400                    data["parts"] = self.doc_parts + "#" + str(id_)
401                    data["doc_img_add"] = True
402                else:
403                    data["doc_img_add"] = False
404                    data["parts"] = "#".join(str(x) for x in self.options["doc_parts"] if x != id_)
405
406    def _set_node_attributes(self, obj, obj_id=None, extra_label="", type_=None):
407        if isinstance(obj, dict):
408            id_ = obj["id"]
409        else:
410            id_ = obj.id
411        obj_id = obj_id or id_
412       
413        if "id" in self.nodes[obj_id]:
414            # already treated
415            return
416        # data and _title_to_node are used to retrieve usefull data (url, tooltip)
417        # in _convert_map
418        data = {}
419        url = None
420        # set node attributes according to its type
421        if type_ == "plmobject":
422            ref = (obj["type"], obj["reference"], obj["revision"])
423            label = obj["name"].strip() or u"\n".join(ref)
424            url = iri_to_uri(u"/object/%s/%s/%s/" % ref)
425            data["title_"] = u" - ".join(ref)
426            # add data to show/hide thumbnails and attached documents
427            if is_part(obj):
428                type_ = "part"
429                # this will be used later to see if it has an attached document
430                self._part_to_node[id_] = data
431            else:
432                data["path"] = url
433                data["thumbnails"] = True
434                type_ = "document"
435
436        elif isinstance(obj, (PLMObjectController, models.PLMObject)):
437            # display the object's name if it is not empty
438            ref = (obj.type, obj.reference, obj.revision)
439            label = obj.name.strip() or u"\n".join(ref)
440            data["title_"] = u" - ".join(ref)
441            # add data to show/hide thumbnails and attached documents
442            if obj.is_document:
443                data["path"] = get_path(obj)
444                data["thumbnails"] = True
445                type_ = "document"
446            else:
447                type_ = "part"
448                # this will be used later to see if it has an attached document
449                self._part_to_node[id_] = data
450        elif isinstance(obj, (User, UserController)):
451            full_name = u'%s\n%s' % (obj.first_name, obj.last_name)
452            label = full_name.strip() or obj.username
453            data["title_"] = obj.username
454            type_ = "user"
455        else:
456            label = obj.name
457            type_ = "group"
458        id_ = "%s_%s_%d" % (obj_id, type_.capitalize(), id_)
459
460        data["label"] = label + "\n" + extra_label if extra_label else label
461        data["type"] = type_
462        self.nodes[obj_id].update(
463                URL=(url or obj.plmobject_url) + "navigate/",
464                id=id_,
465                )
466        self._title_to_node[id_] = data
467
468    def _convert_map(self, map_string):
469        elements = []
470        ajax_navigate = "/ajax/navigate/" + get_path(self.object)
471        for area in ET.fromstring(map_string).findall("area"):
472            if area.get("href") == ".":
473                if area.get("shape") == "rect":
474                    title = area.get("title")
475                    if title:
476                        id_ =  area.get("id")
477                        left, top, x2, y2 = map(int, area.get("coords").split(","))
478                        s = "top:%dpx;left:%dpx;" % (top, left)
479                        title = linebreaks(title.replace("\\n", "\n"))
480                        div = "<div id='%s' class='edge' style='%s'>%s</div>" % (id_, s, title)
481                        elements.append(div)
482                continue
483
484            data = self._title_to_node.get(area.get("id"), {})
485           
486            # compute css position of the div
487            left, top, x2, y2 = map(int, area.get("coords").split(","))
488            style = "top:%dpx;left:%dpx;" % (top, left)
489
490            # create a div with a title, and an <a> element
491            id_ = "Nav-%s" % area.get("id")
492            ctx = data.copy()
493            ctx["style"] = style
494            ctx["id"] = id_
495            main = self.main_node == area.get("id")
496            ctx["main"] = main
497            ctx["href"] = area.get("href")
498            ctx["documents_url"] = ajax_navigate
499            div = render_to_string("navigate/node.html", ctx)
500            if main:
501                # the main node must be the first item, since it is
502                # used to center the graph
503                elements.insert(0, div)
504            else:
505                elements.append(div)
506        return u"\n".join(elements)
507
508    def _parse_svg(self, svg):
509        # TODO: optimize this function
510        edges = []
511        root = ET.fromstring(svg)
512        graph = root.getchildren()[0]
513        transform = graph.get("transform")
514       
515        m = re.search(r"scale\((.*?)\)", transform)
516        if m:
517            scale = map(float, m.group(1).split(" ", 1))
518        else:
519            scale = (1, 1)
520       
521        m = re.search(r"translate\((.*?)\)", transform)
522        if m:
523            translate = map(float, m.group(1).split(" ", 1))
524        else:
525            translate = (0, 0)
526
527        width = root.get("width").replace("pt", "")
528        height = root.get("height").replace("pt", "")
529       
530        for grp in graph:
531            if grp.get("class") != "edge":
532                continue
533            e = {"id" : grp.get("id")}
534            for path in grp.findall("./{http://www.w3.org/2000/svg}a/{http://www.w3.org/2000/svg}path"):
535                e["p"] = path.get("d")
536               
537            for poly in grp.findall("./{http://www.w3.org/2000/svg}a/{http://www.w3.org/2000/svg}polygon"):
538                e["a"] = poly.get("points")
539            edges.append(e)
540        return dict(width=width, height=height, scale=scale, translate=translate,
541                edges=edges)
542
543    def render(self):
544        """
545        Renders an image of the graph.
546
547        :returns: a tuple (html content, javascript content)
548        """
549        warnings.simplefilter('ignore', RuntimeWarning)
550        # builds the graph
551        for key, attrs in self.nodes.iteritems():
552            self.graph.add_node(key, label="", **attrs)
553        s = unicode(self.graph)
554        s = s[:s.rfind("}")]
555        s += "\n".join(u'%s -> %s [label="%s", href="."];' % edge for edge in sorted(self.edges)) + "}\n"
556        self.graph.close()
557        self.graph = FrozenAGraph(s)
558
559        prog = self.options.get("prog", "dot")
560        self.graph.layout(prog=prog)
561        s = StringIO.StringIO()
562        svg = StringIO.StringIO()
563        self.graph.draw(svg, format='svg', prog=prog)
564        svg.seek(0)
565        self.graph.draw(s, format='cmapx', prog=prog)
566        s.seek(0)
567        map_string = s.read()
568        self.graph.clear()
569        warnings.simplefilter('default', RuntimeWarning)
570        return self._convert_map(map_string), self._parse_svg(svg.read())
571
Note: See TracBrowser for help on using the repository browser.