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

Revision 833, 22.6 KB checked in by pcosquer, 10 years ago (diff)

navigate: fix parent->child edges

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