1# base.py - the base classes etc. for a Python interface to bugzilla
2#
3# Copyright (C) 2007, 2008, 2009, 2010 Red Hat Inc.
4# Author: Will Woods <wwoods@redhat.com>
5#
6# This work is licensed under the GNU GPLv2 or later.
7# See the COPYING file in the top-level directory.
8
9import copy
10from logging import getLogger
11
12from ._util import to_encoding
13
14
15log = getLogger(__name__)
16
17
18class Bug(object):
19    """
20    A container object for a bug report. Requires a Bugzilla instance -
21    every Bug is on a Bugzilla, obviously.
22    Optional keyword args:
23        dict=DICT   - populate attributes with the result of a getBug() call
24        bug_id=ID   - if dict does not contain bug_id, this is required before
25                      you can read any attributes or make modifications to this
26                      bug.
27    """
28    def __init__(self, bugzilla, bug_id=None, dict=None, autorefresh=False):
29        # pylint: disable=redefined-builtin
30        # API had pre-existing issue that we can't change ('dict' usage)
31
32        self.bugzilla = bugzilla
33        self._rawdata = {}
34        self.autorefresh = autorefresh
35
36        # pylint: disable=protected-access
37        self._aliases = self.bugzilla._get_bug_aliases()
38        # pylint: enable=protected-access
39
40        if not dict:
41            dict = {}
42        if bug_id:
43            dict["id"] = bug_id
44
45        self._update_dict(dict)
46        self.weburl = bugzilla.url.replace('xmlrpc.cgi',
47                                           'show_bug.cgi?id=%i' % self.bug_id)
48
49    def __str__(self):
50        """
51        Return a simple string representation of this bug
52
53        This is available only for compatibility. Using 'str(bug)' and
54        'print(bug)' is not recommended because of potential encoding issues.
55        Please use unicode(bug) where possible.
56        """
57        return to_encoding(self.__unicode__())
58
59    def __unicode__(self):
60        """
61        Return a simple unicode string representation of this bug
62    """
63        return "#%-6s %-10s - %s - %s" % (self.bug_id, self.bug_status,
64                                          self.assigned_to, self.summary)
65
66    def __repr__(self):
67        url = ""
68        if self.bugzilla:
69            url = self.bugzilla.url
70        return '<Bug #%i on %s at %#x>' % (self.bug_id, url, id(self))
71
72    def __getattr__(self, name):
73        refreshed = False
74        while True:
75            if refreshed and name in self.__dict__:
76                # If name was in __dict__ to begin with, __getattr__ would
77                # have never been called.
78                return self.__dict__[name]
79
80            for newname, oldname in self._aliases:
81                if name == oldname and newname in self.__dict__:
82                    return self.__dict__[newname]
83
84            # Doing dir(bugobj) does getattr __members__/__methods__,
85            # don't refresh for those
86            if name.startswith("__") and name.endswith("__"):
87                break
88
89            if refreshed or not self.autorefresh:
90                break
91
92            log.info("Bug %i missing attribute '%s' - doing implicit "
93                "refresh(). This will be slow, if you want to avoid "
94                "this, properly use query/getbug include_fields, and "
95                "set bugzilla.bug_autorefresh = False to force failure.",
96                self.bug_id, name)
97
98            # We pass the attribute name to getbug, since for something like
99            # 'attachments' which downloads lots of data we really want the
100            # user to opt in.
101            self.refresh(extra_fields=[name])
102            refreshed = True
103
104        msg = ("Bug object has no attribute '%s'." % name)
105        if not self.autorefresh:
106            msg += ("\nIf '%s' is a bugzilla attribute, it may not have "
107                    "been cached when the bug was fetched. You may want "
108                    "to adjust your include_fields for getbug/query." % name)
109        raise AttributeError(msg)
110
111    def get_raw_data(self):
112        """
113        Return the raw API dictionary data that has been used to
114        populate this bug
115        """
116        return copy.deepcopy(self._rawdata)
117
118    def refresh(self, include_fields=None, exclude_fields=None,
119        extra_fields=None):
120        """
121        Refresh the bug with the latest data from bugzilla
122        """
123        # pylint: disable=protected-access
124        extra_fields = list(self._rawdata.keys()) + (extra_fields or [])
125        r = self.bugzilla._getbug(self.bug_id,
126            include_fields=include_fields, exclude_fields=exclude_fields,
127            extra_fields=extra_fields)
128        # pylint: enable=protected-access
129        self._update_dict(r)
130    reload = refresh
131
132    def _translate_dict(self, newdict):
133        if self.bugzilla:
134            self.bugzilla.post_translation({}, newdict)
135
136        for newname, oldname in self._aliases:
137            if oldname not in newdict:
138                continue
139
140            if newname not in newdict:
141                newdict[newname] = newdict[oldname]
142            elif newdict[newname] != newdict[oldname]:
143                log.debug("Update dict contained differing alias values "
144                          "d[%s]=%s and d[%s]=%s , dropping the value "
145                          "d[%s]", newname, newdict[newname], oldname,
146                        newdict[oldname], oldname)
147            del(newdict[oldname])
148
149
150    def _update_dict(self, newdict):
151        """
152        Update internal dictionary, in a way that ensures no duplicate
153        entries are stored WRT field aliases
154        """
155        self._translate_dict(newdict)
156        self._rawdata.update(newdict)
157        self.__dict__.update(newdict)
158
159        if 'id' not in self.__dict__ and 'bug_id' not in self.__dict__:
160            raise TypeError("Bug object needs a bug_id")
161
162
163    ##################
164    # pickle helpers #
165    ##################
166
167    def __getstate__(self):
168        ret = self._rawdata.copy()
169        ret["_aliases"] = self._aliases
170        return ret
171
172    def __setstate__(self, vals):
173        self._rawdata = {}
174        self.bugzilla = None
175        self._aliases = vals.get("_aliases", [])
176        self.autorefresh = False
177        self._update_dict(vals)
178
179
180    #####################
181    # Modify bug status #
182    #####################
183
184    def setstatus(self, status, comment=None, private=False):
185        """
186        Update the status for this bug report.
187        Commonly-used values are ASSIGNED, MODIFIED, and NEEDINFO.
188
189        To change bugs to CLOSED, use .close() instead.
190        """
191        # Note: fedora bodhi uses this function
192        vals = self.bugzilla.build_update(status=status,
193                                          comment=comment,
194                                          comment_private=private)
195        log.debug("setstatus: update=%s", vals)
196
197        return self.bugzilla.update_bugs(self.bug_id, vals)
198
199    def close(self, resolution, dupeid=None, fixedin=None,
200              comment=None, isprivate=False):
201        """
202        Close this bug.
203        Valid values for resolution are in bz.querydefaults['resolution_list']
204        For bugzilla.redhat.com that's:
205        ['NOTABUG', 'WONTFIX', 'DEFERRED', 'WORKSFORME', 'CURRENTRELEASE',
206         'RAWHIDE', 'ERRATA', 'DUPLICATE', 'UPSTREAM', 'NEXTRELEASE',
207         'CANTFIX', 'INSUFFICIENT_DATA']
208        If using DUPLICATE, you need to set dupeid to the ID of the other bug.
209        If using WORKSFORME/CURRENTRELEASE/RAWHIDE/ERRATA/UPSTREAM/NEXTRELEASE
210          you can (and should) set 'new_fixed_in' to a string representing the
211          version that fixes the bug.
212        You can optionally add a comment while closing the bug. Set 'isprivate'
213          to True if you want that comment to be private.
214        """
215        # Note: fedora bodhi uses this function
216        vals = self.bugzilla.build_update(comment=comment,
217                                          comment_private=isprivate,
218                                          resolution=resolution,
219                                          dupe_of=dupeid,
220                                          fixed_in=fixedin,
221                                          status=str("CLOSED"))
222        log.debug("close: update=%s", vals)
223
224        return self.bugzilla.update_bugs(self.bug_id, vals)
225
226
227    #####################
228    # Modify bug emails #
229    #####################
230
231    def setassignee(self, assigned_to=None,
232                    qa_contact=None, comment=None):
233        """
234        Set any of the assigned_to or qa_contact fields to a new
235        bugzilla account, with an optional comment, e.g.
236        setassignee(assigned_to='wwoods@redhat.com')
237        setassignee(qa_contact='wwoods@redhat.com', comment='wwoods QA ftw')
238
239        You must set at least one of the two assignee fields, or this method
240        will throw a ValueError.
241
242        Returns [bug_id, mailresults].
243        """
244        if not (assigned_to or qa_contact):
245            raise ValueError("You must set one of assigned_to "
246                             " or qa_contact")
247
248        vals = self.bugzilla.build_update(assigned_to=assigned_to,
249                                          qa_contact=qa_contact,
250                                          comment=comment)
251        log.debug("setassignee: update=%s", vals)
252
253        return self.bugzilla.update_bugs(self.bug_id, vals)
254
255    def addcc(self, cclist, comment=None):
256        """
257        Adds the given email addresses to the CC list for this bug.
258        cclist: list of email addresses (strings)
259        comment: optional comment to add to the bug
260        """
261        vals = self.bugzilla.build_update(comment=comment,
262                                          cc_add=cclist)
263        log.debug("addcc: update=%s", vals)
264
265        return self.bugzilla.update_bugs(self.bug_id, vals)
266
267    def deletecc(self, cclist, comment=None):
268        """
269        Removes the given email addresses from the CC list for this bug.
270        """
271        vals = self.bugzilla.build_update(comment=comment,
272                                          cc_remove=cclist)
273        log.debug("deletecc: update=%s", vals)
274
275        return self.bugzilla.update_bugs(self.bug_id, vals)
276
277
278    ####################
279    # comment handling #
280    ####################
281
282    def addcomment(self, comment, private=False):
283        """
284        Add the given comment to this bug. Set private to True to mark this
285        comment as private.
286        """
287        # Note: fedora bodhi uses this function
288        vals = self.bugzilla.build_update(comment=comment,
289                                          comment_private=private)
290        log.debug("addcomment: update=%s", vals)
291
292        return self.bugzilla.update_bugs(self.bug_id, vals)
293
294    def getcomments(self):
295        """
296        Returns an array of comment dictionaries for this bug
297        """
298        comment_list = self.bugzilla.get_comments([self.bug_id])
299        return comment_list['bugs'][str(self.bug_id)]['comments']
300
301
302    #####################
303    # Get/Set bug flags #
304    #####################
305
306    def get_flag_type(self, name):
307        """
308        Return flag_type information for a specific flag
309
310        Older RHBugzilla returned a lot more info here, but it was
311        non-upstream and is now gone.
312        """
313        for t in self.flags:
314            if t['name'] == name:
315                return t
316        return None
317
318    def get_flags(self, name):
319        """
320        Return flag value information for a specific flag
321        """
322        ft = self.get_flag_type(name)
323        if not ft:
324            return None
325
326        return [ft]
327
328    def get_flag_status(self, name):
329        """
330        Return a flag 'status' field
331
332        This method works only for simple flags that have only a 'status' field
333        with no "requestee" info, and no multiple values. For more complex
334        flags, use get_flags() to get extended flag value information.
335        """
336        f = self.get_flags(name)
337        if not f:
338            return None
339
340        # This method works only for simple flags that have only one
341        # value set.
342        assert len(f) <= 1
343
344        return f[0]['status']
345
346    def updateflags(self, flags):
347        """
348        Thin wrapper around build_update(flags=X). This only handles simple
349        status changes, anything like needinfo requestee needs to call
350        build_update + update_bugs directly
351
352        :param flags: Dictionary of the form {"flagname": "status"}, example
353            {"needinfo": "?", "devel_ack": "+"}
354        """
355        flaglist = []
356        for key, value in flags.items():
357            flaglist.append({"name": key, "status": value})
358        return self.bugzilla.update_bugs([self.bug_id],
359            self.bugzilla.build_update(flags=flaglist))
360
361
362    ########################
363    # Experimental methods #
364    ########################
365
366    def get_attachments(self, include_fields=None, exclude_fields=None):
367        """
368        Helper call to Bugzilla.get_attachments. If you want to fetch
369        specific attachment IDs, use that function instead
370        """
371        if "attachments" in self.__dict__:
372            return self.attachments
373
374        data = self.bugzilla.get_attachments([self.bug_id], None,
375                include_fields, exclude_fields)
376        return data["bugs"][str(self.bug_id)]
377
378    def get_attachment_ids(self):
379        """
380        Helper function to return only the attachment IDs for this bug
381        """
382        return [a["id"] for a in self.get_attachments(exclude_fields=["data"])]
383
384    def get_history_raw(self):
385        """
386        Experimental. Get the history of changes for this bug.
387        """
388        return self.bugzilla.bugs_history_raw([self.bug_id])
389
390
391class User(object):
392    """
393    Container object for a bugzilla User.
394
395    :arg bugzilla: Bugzilla instance that this User belongs to.
396    Rest of the params come straight from User.get()
397    """
398    def __init__(self, bugzilla, **kwargs):
399        self.bugzilla = bugzilla
400        self.__userid = kwargs.get('id')
401        self.__name = kwargs.get('name')
402
403        self.__email = kwargs.get('email', self.__name)
404        self.__can_login = kwargs.get('can_login', False)
405
406        self.real_name = kwargs.get('real_name', None)
407        self.password = None
408
409        self.groups = kwargs.get('groups', {})
410        self.groupnames = []
411        for g in self.groups:
412            if "name" in g:
413                self.groupnames.append(g["name"])
414        self.groupnames.sort()
415
416
417    ########################
418    # Read-only attributes #
419    ########################
420
421    # We make these properties so that the user cannot set them.  They are
422    # unaffected by the update() method so it would be misleading to let them
423    # be changed.
424    @property
425    def userid(self):
426        return self.__userid
427
428    @property
429    def email(self):
430        return self.__email
431
432    @property
433    def can_login(self):
434        return self.__can_login
435
436    # name is a key in some methods.  Mark it dirty when we change it #
437    @property
438    def name(self):
439        return self.__name
440
441    def refresh(self):
442        """
443        Update User object with latest info from bugzilla
444        """
445        newuser = self.bugzilla.getuser(self.email)
446        self.__dict__.update(newuser.__dict__)
447
448    def updateperms(self, action, groups):
449        """
450        A method to update the permissions (group membership) of a bugzilla
451        user.
452
453        :arg action: add, remove, or set
454        :arg groups: list of groups to be added to (i.e. ['fedora_contrib'])
455        """
456        self.bugzilla.updateperms(self.name, action, groups)
457
458
459class Group(object):
460    """
461    Container object for a bugzilla Group.
462
463    :arg bugzilla: Bugzilla instance that this Group belongs to.
464    Rest of the params come straight from Group.get()
465    """
466    def __init__(self, bugzilla, **kwargs):
467        self.bugzilla = bugzilla
468        self.__groupid = kwargs.get('id')
469
470        self.name = kwargs.get('name')
471        self.description = kwargs.get('description', self.name)
472        self.is_active = kwargs.get('is_active', False)
473        self.icon_url = kwargs.get('icon_url', None)
474        self.is_active_bug_group = kwargs.get('is_active_bug_group', None)
475
476        self.membership = kwargs.get('membership', [])
477        self.__member_emails = set()
478        self._refresh_member_emails_list()
479
480    ########################
481    # Read-only attributes #
482    ########################
483
484    # We make these properties so that the user cannot set them.  They are
485    # unaffected by the update() method so it would be misleading to let them
486    # be changed.
487    @property
488    def groupid(self):
489        return self.__groupid
490
491    @property
492    def member_emails(self):
493        return sorted(self.__member_emails)
494
495    def _refresh_member_emails_list(self):
496        """
497        Refresh the list of emails of the members of the group.
498        """
499        if self.membership:
500            for m in self.membership:
501                if "email" in m:
502                    self.__member_emails.add(m["email"])
503
504    def refresh(self, membership=False):
505        """
506        Update Group object with latest info from bugzilla
507        """
508        newgroup = self.bugzilla.getgroup(
509            self.name, membership=membership)
510        self.__dict__.update(newgroup.__dict__)
511        self._refresh_member_emails_list()
512
513    def members(self):
514        """
515        Retrieve the members of this Group from bugzilla
516        """
517        if not self.membership:
518            self.refresh(membership=True)
519        return self.membership
520