view savane/tracker/models.py @ 324:9275694cda61

Trackers: more static fields definition
author Sylvain Beucler <beuc@beuc.net>
date Sat, 21 Aug 2010 12:09:18 +0200
parents 3f005a413dfc
children f34eba406e57
line wrap: on
line source

# Trackers data structure
# Copyright (C) 2010  Sylvain Beucler
#
# This file is part of Savane.
# 
# Savane is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
# 
# Savane is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU Affero General Public License for more details.
# 
# You should have received a copy of the GNU Affero General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

from django.db import models
from django.utils.translation import ugettext, ugettext_lazy as _
import django.contrib.auth.models as auth_models
from django.utils.safestring import mark_safe
import datetime
from savane.utils import htmlentitydecode, unescape

##
# Trackers definition
##



# TODO: default '100' (aka 'nobody' or 'None', depending on
# fields) -> change to NULL?

# Date fields: use default=... rather than auto_now_add=...; indeed,
# auto_now_add cannot be overriden, hence it would mess data imports.
# EDIT: actually I think only forms fields cannot be overriden, it
# still can be done programmatically

RESTRICTION_CHOICES = (('2', _('anonymous')),
                       ('3', _('logged-in user')),
                       ('5', _('project member')),)
PERMISSION_CHOICES = (('', _('group type default')),
                      ('9', _('none')),
                      ('1', _('technician')),
                      ('3', _('manager')),
                      ('2', _('technician & manager')),)
NEW_ITEM_POSTING_RESTRICTION_CHOICES = PERMISSION_CHOICES + (('', _('group type default')),)
COMMENT_POSTING_RESTRICTION_CHOICES = PERMISSION_CHOICES + (('', _('same as new item')),)


NOTIFICATION_ROLES = (
    {'id': 1, 'label': 'SUBMITTER', 'short': _('Submitter'), 'description': _('The person who submitted the item')},
    {'id': 2, 'label': 'ASSIGNEE',  'short': _('Assignee' ), 'description': _('The person to whom the item was assigned')},
    {'id': 3, 'label': 'CC',        'short': _('CC'       ), 'description': _('The person who is in the CC list')},
    {'id': 4, 'label': 'SUBMITTER', 'short': _('Submitter'), 'description': _('A person who once posted a follow-up comment')},
)

NOTIFICATION_EVENTS = (
    {'id': 1, 'label': 'ROLE_CHANGE'     , 'short': _('Role has changed'),
     'description': _("I'm added to or removed from this role")},
    {'id': 2, 'label': 'NEW_COMMENT'     , 'short': _('New comment'),
     'description': _('A new followup comment is added')},
    {'id': 3, 'label': 'NEW_FILE'        , 'short': _('New attachment'),
     'description': _('A new file attachment is added')},
    {'id': 4, 'label': 'CC_CHANGE'       , 'short': _('CC Change'),
     'description': _('A new CC address is added/removed')},
    {'id': 5, 'label': 'CLOSED'          , 'short': _('Item closed'),
     'description': _('The item is closed')},
    {'id': 6, 'label': 'PSS_CHANGE'      , 'short': _('PSS change'),
     'description': _('Priority,Status,Severity changes')},
    {'id': 7, 'label': 'ANY_OTHER_CHANGE', 'short': _('Any other change'),
     'description': _('Any change not mentioned above')},
    {'id': 8, 'label': 'I_MADE_IT'       , 'short': _('I did it'),
     'description': _('I am the author of the change')},
    {'id': 9, 'label': 'NEW_ITEM'        , 'short': _('New Item'),
     'description': _('A new item has been submitted')},
)


class Tracker(models.Model):
    """
    Historically 4 trackers are hard-coded.

    The current implementation reduces the duplication to the
    Item.bugs_id / Item.patch_id / Item.support_id / Item.task_id
    (previous PHP implementation duplicated all tables).
    """
    NAME_CHOICES = (('bugs', _('Bugs')),
                    ('patch', _('Patches')),
                    ('support', _('Support')),
                    ('task', _('Tasks')),
                    )
    name = models.CharField(max_length=7, choices=NAME_CHOICES, primary_key=True)

    def __unicode__(self):
        "Used in the admin interface fields list"
        return self.name

class GroupTypeConfiguration(models.Model):
    """
    Previously in table "groups_type"
    TODO: keep?
    """
    tracker = models.ForeignKey('Tracker')
    group_type = models.IntegerField()  # TODO: ForeignKey
    new_item_posting_restriction = models.CharField(max_length=1,
                                                    choices=NEW_ITEM_POSTING_RESTRICTION_CHOICES,
                                                    blank=True)
    comment_posting_restriction = models.CharField(max_length=1,
                                                   choices=COMMENT_POSTING_RESTRICTION_CHOICES,
                                                   blank=True)
    default_member_permission = models.CharField(max_length=1, choices=PERMISSION_CHOICES, blank=True)

class GroupConfiguration(models.Model):
    """
    Previously in table "groups_default_permissions"
    """
    tracker = models.ForeignKey('Tracker')
    group = models.ForeignKey(auth_models.Group)
    new_item_restriction = models.CharField(max_length=1,
                                            choices=NEW_ITEM_POSTING_RESTRICTION_CHOICES,
                                            blank=True)
    comment_restriction = models.CharField(max_length=1,
                                           choices=COMMENT_POSTING_RESTRICTION_CHOICES,
                                           blank=True)
    default_member_permission = models.CharField(max_length=1, choices=PERMISSION_CHOICES, blank=True)

class MemberPermission(models.Model):
    """
    Previously in table "user_group"
    """
    tracker = models.ForeignKey('Tracker')
    group = models.ForeignKey(auth_models.Group)
    user = models.ForeignKey(auth_models.User)
    permission = models.CharField(max_length=1, choices=PERMISSION_CHOICES, blank=True)

#class SquadPermission(models.Model): pass


class Field(models.Model):
    """
    """
    class Meta:
        unique_together = (('tracker', 'name'),)
        verbose_name = _("field")
        verbose_name_plural = _("fields")

    DISPLAY_TYPE_CHOICES = (('DF', _('date field')),
                            ('SB', _('select box')),
                            ('TA', _('text area')),
                            ('TF', _('text field')),)
    SCOPE_CHOICES = (('S', _('system')), # user cannot modify related FieldValue's (TF)
                     ('P', _('project')),)  # user can modify related FieldValue's (TF)
    EMPTY_OK_CHOICES = (('0', _('mandatory only if it was presented to the original submitter')),
                        ('1', _('optional (empty values are accepted)')),
                        ('3', _('mandatory')),)

    tracker = models.ForeignKey('Tracker')
    name = models.CharField(max_length=255, db_index=True)
    display_type = models.CharField(max_length=255, choices=DISPLAY_TYPE_CHOICES)
    display_size = models.CharField(max_length=255)
      # DF: unused
      # SB: unused
      # TA: cols/rows
      # TF: visible_length/max_length
    label  = models.CharField(max_length=255)
    description = models.TextField()

    # Field values can be changed (if TF)
    scope = models.CharField(max_length=1, choices=SCOPE_CHOICES)
    # Field cannot be hidden (but can be made optional)
    required = models.BooleanField(help_text=_("field cannot be disabled in configuration"))
    # Default value (fields can always override this except for 'summary' and 'details', cf. 'special')
    empty_ok = models.CharField(max_length=1, choices=EMPTY_OK_CHOICES,
                                default='0')
    # Default value
    keep_history = models.BooleanField()
    # Field cannot be made optional (displayed unless 'bug_id' and 'group_id')
    # Also, field are not displayed (filled by the system) - except for 'summary', 'comment_type' and 'details'
    # (consequently, they cannot be customized in any way, except for 'summary' and 'details' where you can only customize the display size)
    special = models.BooleanField()
    # Field may change label and description
    custom = models.BooleanField(help_text=_("let the user change the label and description"))

    def __unicode__(self):
        return "%s.%s" % (self.tracker_id, self.name)

class FieldOverlay(models.Model):
    """
    Per-group tracker item definition override
    """
    class Meta:
        unique_together = (('field', 'group'),)
        verbose_name = _("field usage")
        verbose_name_plural = _("field usages")

    TRANSITION_DEFAULT_AUTH_CHOICES = (('', _('undefined')),
                                       ('A', _('allowed')),
                                       ('F', _('forbidden')),)
    SHOW_ON_ADD_CHOICES = (('0', _('no')),
                           ('1', _('show to logged in users')),
                           ('2', _('show to anonymous users')),
                           ('3', _('show to both logged in and anonymous users')),)
    field = models.ForeignKey('Field')
    group = models.ForeignKey(auth_models.Group, blank=True, null=True, help_text=_("NULL == default"))

    # If not Field.required:
    use_it = models.BooleanField(_("used"))
    show_on_add = models.CharField(max_length=1, choices=SHOW_ON_ADD_CHOICES,
                                   default='0', blank=True, null=True)
      # new:
      # show_on_add_logged_in = models.BooleanField("show to logged in users")
      # show_on_add_anonymous = models.BooleanField("show to anonymous users")
    show_on_add_members = models.BooleanField(_("show to project members"))

    # Can always be changed (expect for special 'summary' and 'details')
    custom_empty_ok = models.CharField(max_length=1, choices=Field.EMPTY_OK_CHOICES,
                                       default='0', blank=True, null=True)

    # Can always be changed
    place = models.IntegerField(help_text=_("display rank")) # new:rank

    # Specific to SB
    # Can always be changed
    transition_default_auth = models.CharField(max_length=1, choices=TRANSITION_DEFAULT_AUTH_CHOICES, default='A')

    # Specific to TA and TF
    # Works for both custom and non-custom fields
    custom_display_size = models.CharField(max_length=255, blank=True, null=True)
      # The default value is in Field.display_size
      #   rather than FieldUsage(group_id=100).custom_display_size
    # If !Field.special
    custom_keep_history = models.BooleanField(_("keep field value changes in history"))

    # If Field.custom
    # Specific (bad!) fields for custom fields (if Field.custom is True):
    custom_label = models.CharField(max_length=255, blank=True, null=True)
    custom_description = models.CharField(max_length=255, blank=True, null=True)

class FieldValue(models.Model):
    """
    Per-group tracker select box values override
    """
    class Meta:
        unique_together = (('field', 'group', 'value_id'),)

    STATUS_CHOICES = (('A', _('active')),
                      ('H', _('hidden')), # mask previously-active or system fields
                      ('P', _('permanent')),) # status cannot be modified, always visible
    field = models.ForeignKey('Field')
    group = models.ForeignKey(auth_models.Group, blank=True, null=True, help_text=_("NULL == default"))
    value_id = models.IntegerField(db_index=True) # group_specific value identifier
      # It's not a duplicate of 'id', as it's the value referenced by
      # Item fields, and the configuration of that value can be
      # customized per-project.
    value = models.CharField(max_length=255) # label
    description = models.TextField()
    order_id = models.IntegerField() # new:rank
    status = models.CharField(max_length=1, choices=STATUS_CHOICES, default='A', db_index=True)

    # Field category: specific (bad!) field for e-mail notifications
    email_ad = models.TextField(blank=True, null=True,
                                help_text=_("comma-separated list of e-mail addresses to notify when an item is created or modified in this category"))
    send_all_flag = models.BooleanField(_("send on all updates"), default=True)

# Auto_increment counters
# We could make this more generic, but we'd have to implement
# per-tracker atomic ID increment manually.
class BugsPublicId   (models.Model): pass
class PatchPublicId  (models.Model): pass
class SupportPublicId(models.Model): pass
class TaskPublicId   (models.Model): pass

class Item(models.Model):
    """
    One tracker item: a bug report, a support request...
    """

    class Meta:
        unique_together = (('tracker', 'public_bugs'),
                           ('tracker', 'public_patch'),
                           ('tracker', 'public_support'),
                           ('tracker', 'public_task'),)

    # Rename 'id' to avoid confusion with public ids below
    internal_id = models.AutoField(primary_key=True)
    tracker = models.ForeignKey('Tracker')

    # Per-tracker public item identifier.  Reason is historical:
    # trackers were stored in different tables, each with its own
    # auto_increment field:
    public_bugs    = models.OneToOneField(BugsPublicId,    blank=True, null=True)
    public_task    = models.OneToOneField(TaskPublicId,    blank=True, null=True)
    public_support = models.OneToOneField(SupportPublicId, blank=True, null=True)
    public_patch   = models.OneToOneField(PatchPublicId,   blank=True, null=True)

    # Non-fields values
    group = models.ForeignKey(auth_models.Group)
    spamscore = models.IntegerField(default=0)
    ip = models.IPAddressField(blank=True, null=True)
    submitted_by = models.ForeignKey(auth_models.User, blank=True, null=True)
    date = models.DateTimeField(default=datetime.date.today)
    close_date = models.DateTimeField(blank=True, null=True)

    # Forward dependencies
    dependencies = models.ManyToManyField('self', symmetrical=False,
                                          related_name='reverse_dependencies')

    ##
    # Field values
    ##
    # Note: For select boxes, FK should be limited to same group, and
    # to a specific field each e.g.:
    # severity = models.ForeignKey('FieldValue', to_field='value_id', default=5)
    #            + constraint(same group or 100) + constraint(field_name='severity')
    # To avoid unnecessary burden, let's drop the above incomplete ForeignKey

    # More generally one can wonder if this should be moved to a M2M
    # item<->field_value table; but after we're done with the
    # migration from the previous database :) Plus it might just be
    # cumbersome, given there's already several hardcoded fields
    # behavior.

    # - fields with hard-coded processing
    summary = models.TextField()
    details = models.TextField()
    privacy = models.IntegerField(default=5)
    discussion_lock = models.IntegerField(default=0)
    vote = models.IntegerField(default=0)
    category_id = models.IntegerField(default=100)
    assigned_to = models.IntegerField(blank=True, null=True)

    # - other fields
    status_id = models.IntegerField(default=100, verbose_name=_("open/closed"))
    resolution_id = models.IntegerField(default=100)
    severity = models.IntegerField(default=5)
    planned_starting_date = models.DateTimeField(blank=True, null=True)
    planned_close_date = models.DateTimeField(blank=True, null=True)
    percent_complete = models.IntegerField(default=1) # SB
    reproducibility_id = models.IntegerField(default=100)
    bug_group_id = models.IntegerField(default=100, verbose_name=_("item group"))
    keywords = models.CharField(max_length=255)
    hours = models.FloatField(default=0.0)
    priority = models.IntegerField(default=5)
    size_id = models.IntegerField(default=100)
    platform_version_id = models.IntegerField(default=100)
    fix_release = models.CharField(max_length=255)
    fix_release_id = models.IntegerField(default=100)
    plan_release = models.CharField(max_length=255)
    plan_release_id = models.IntegerField(default=100)
    release = models.CharField(max_length=255)
    release_id = models.IntegerField(default=100)
    category_version_id = models.IntegerField(default=100)
    component_version = models.CharField(max_length=255)
    originator_name = models.CharField(max_length=255)
    originator_email = models.EmailField(max_length=255)
    originator_phone = models.CharField(max_length=255)

    # - fields dedicated to user customization
    custom_tf1  = models.CharField(max_length=255)
    custom_tf2  = models.CharField(max_length=255)
    custom_tf3  = models.CharField(max_length=255)
    custom_tf4  = models.CharField(max_length=255)
    custom_tf5  = models.CharField(max_length=255)
    custom_tf5  = models.CharField(max_length=255)
    custom_tf6  = models.CharField(max_length=255)
    custom_tf7  = models.CharField(max_length=255)
    custom_tf8  = models.CharField(max_length=255)
    custom_tf9  = models.CharField(max_length=255)
    custom_tf10 = models.CharField(max_length=255)

    custom_ta1  = models.TextField()
    custom_ta2  = models.TextField()
    custom_ta3  = models.TextField()
    custom_ta4  = models.TextField()
    custom_ta5  = models.TextField()
    custom_ta6  = models.TextField()
    custom_ta7  = models.TextField()
    custom_ta8  = models.TextField()
    custom_ta9  = models.TextField()
    custom_ta10 = models.TextField()

    custom_sb1  = models.IntegerField(default=100)
    custom_sb2  = models.IntegerField(default=100)
    custom_sb3  = models.IntegerField(default=100)
    custom_sb4  = models.IntegerField(default=100)
    custom_sb5  = models.IntegerField(default=100)
    custom_sb6  = models.IntegerField(default=100)
    custom_sb7  = models.IntegerField(default=100)
    custom_sb8  = models.IntegerField(default=100)
    custom_sb9  = models.IntegerField(default=100)
    custom_sb10 = models.IntegerField(default=100)

    custom_df1  = models.DateTimeField()
    custom_df2  = models.DateTimeField()
    custom_df3  = models.DateTimeField()
    custom_df4  = models.DateTimeField()
    custom_df5  = models.DateTimeField()

    def get_public_id(self):
        if self.tracker_id == 'bugs':
            return self.public_bugs_id
        elif self.tracker_id == 'patch':
            return self.public_patch_id
        elif self.tracker_id == 'support':
            return self.public_support_id
        elif self.tracker_id == 'task':
            return self.public_task_id

    def get_shortcut(self):
        if self.tracker_id == 'bugs':
            return "bug #%d" % self.public_bugs_id
        elif self.tracker_id == 'patch':
            return "patch #%d" % self.public_bugs_id
        elif self.tracker_id == 'support':
            return "sr #%d" % self.public_bugs_id
        elif self.tracker_id == 'task':
            return "task #%d" % self.public_bugs_id

    def get_tracker_name(self):
        for (k,v) in Tracker.NAME_CHOICES:
            if k == self.tracker_id:
                return v

    def get_summary(self):
        # Unapply HTML entities
        # TODO: convert field to plain text
        return unescape(self.summary)

    def get_priority_css_class(self):
        from string import ascii_letters
        return "prior" + ascii_letters[self.priority-1]

    def get_form_fields(self):
        fields = self.tracker.field_set.filter(special=0)
        usages_default = self.tracker.fieldusage_set.filter(group=None, field__special=0)
        usages_group   = self.tracker.fieldusage_set.filter(group=self.group, field__special=0)
        return fields

    def get_form(self, user=None):
        # TODO: privacy
        form_fields = self.get_form_fields()
        print form_fields
        return mark_safe(''.join([f.name + '<br />' for f in form_fields]))

    def __unicode__(self):
        return "%s #%d" % (self.tracker_id, self.get_public_id())

class ItemMsgId(models.Model):
    """
    Identifier for 'Message-Id' and 'References' e-mail fields, used
    to group messages by conversation
    """
    item = models.ForeignKey('Item')
    msg_id = models.CharField(max_length=255)


class ItemHistory(models.Model):
    """
    This stores 2 kinds of values:
    - item comments (field_name='details')
    - value changes, for fields that have history tracking enabled
    """
    item = models.ForeignKey('Item')
    field_name = models.CharField(max_length=255)
       # Should be: field_name = models.ForeignKey('Field', to_field='name')
       #            + constraint (item.tracker=field.tracker)
       # or simply: field_name = models.ForeignKey('Field')
       # But as it's a history field, adding constraints might be just bad.
    old_value= models.TextField(blank=True, null=True)
    new_value= models.TextField()
    mod_by = models.ForeignKey(auth_models.User)
    date = models.DateTimeField(default=datetime.date.today)
    ip = models.IPAddressField(blank=True, null=True)

    # Specific (bad!) field for 'details'
    # I guess 'details' could be stored separately.
    type = models.IntegerField(_("comment type"), blank=True, null=True)
      # Should be:
      # type = models.ForeignKey('FieldValue', to_field='value_id')
      #        + constraint(same group or 100) + constraint(field_name='comment_type_id')
      # The purpose is to add <strong>[$comment_type]</strong> when
      # displaying an item comment.
    spamscore = models.IntegerField(_("total spamscore for this comment"))

class ItemCc(models.Model):
    """
    Item carbon copies for mail notifications
    """
    item = models.ForeignKey('Item')
    email = models.EmailField(max_length=255)
    added_by = models.ForeignKey(auth_models.User)
    comment = models.TextField()
    date = models.DateTimeField(default=datetime.date.today)

#class ItemDependencies:
# => cf. Item.dependencies

class ItemFile(models.Model):
    """
    One file attached to an item.
    """
    item = models.ForeignKey('Item')
    submitted_by = models.ForeignKey(auth_models.User)
    date = models.DateTimeField(default=datetime.date.today)
    description = models.TextField()
    filename = models.TextField()
    filesize = models.IntegerField(default=0)
    filetype = models.TextField()
    # /!\ `file` longblob NOT NULL - if not savane-cleanup

class ItemSpamScore(models.Model):
    """
    Spam reports

    Score is summed in ItemHistory.spamscore.
    """
    score = models.IntegerField(default=1)
    affected_user = models.ForeignKey(auth_models.User, related_name='itemspamscore_affected_set')
    reporter_user = models.ForeignKey(auth_models.User, related_name='itemspamscore_reported_set')
    item = models.ForeignKey('Item')
    comment_id = models.ForeignKey('ItemHistory', null=True)


# TODO:
# - trackers_notification  # yes/no configuration depending on the events and roles
# - groups  # per-group notification settings
# - bugs_canned_responses

# Re-implement?  Not much used:
# - user_squad
# - trackers_field_transition
# - trackers_field_transition_other_field_update
# - trackers_watcher
# - trackers_export  # to implement differently
# - trackers_spamban  # spamassassin gateway
# - trackers_spamcheck_queue
# - trackers_spamcheck_queue_notification
# - user_votes

# Depends if we display in a compatible manner:
# - user_preferences # per-tracker browse configuration
# - bugs_report
# - bugs_report_field

# Feature disabled in 2004 by yeupou, empty tables:
# http://svn.gna.org/viewcvs/savane?view=rev&rev=3094
# http://svn.gna.org/viewcvs/savane/savane/trunk/frontend/php/include/trackers_run/index.php?rev=3094&view=diff&r1=3094&r2=3093&p1=savane/trunk/frontend/php/include/trackers_run/index.php&p2=/savane/trunk/frontend/php/include/trackers_run/index.php
# - bugs_filter