view savane/tracker/models.py @ 335:0718a9ef6cd5

Tracker items list + bug fixes
author Sylvain Beucler <beuc@beuc.net>
date Sun, 22 Aug 2010 23:10:40 +0200
parents 70f5630b1e1e
children caf040382bee
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.db.models import Q
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
import locale
from copy import deepcopy
from savane.utils import htmlentitydecode, unescape
from defs import *

##
# 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

DISPLAY_TYPE_CHOICES = (('', _('not editable')),
                        ('DF', _('date field')),
                        ('SB', _('select box')),
                        ('TA', _('text area')),
                        ('TF', _('text field')),)
SCOPE_CHOICES = (('S', _('system')), # user cannot modify related FieldChoice's (TF)
                 ('P', _('project')),)  # user can modify related FieldChoice's (TF)

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 FieldOverlay(models.Model):
    """
    Per-group tracker field definition override
    (or site-wide field default if group==NULL)
    """
    class Meta:
        unique_together = (('tracker', 'group', 'field'),)
        verbose_name = _("field overlay")
        verbose_name_plural = _("field overlays")

    EMPTY_OK_CHOICES = (('0', _('mandatory only if it was presented to the original submitter')),
                        ('1', _('optional (empty values are accepted)')),
                        ('3', _('mandatory')),)
    TRANSITION_DEFAULT_AUTH_CHOICES = (('', _('undefined')),
                                       ('A', _('allowed')),
                                       ('F', _('forbidden')),)
    tracker = models.ForeignKey(Tracker)
    group = models.ForeignKey(auth_models.Group, blank=True, null=True,
                              help_text=_("NULL == default for all groups"))
    field = models.CharField(max_length=32)

    # If Field.custom
    label = models.CharField(max_length=255, blank=True, null=True)
    description = models.CharField(max_length=255, blank=True, null=True)

    # If not Field.required:
    use_it = models.BooleanField(_("used"))
    # When posting a new item:
    show_on_add_anonymous = models.NullBooleanField(_("show to anonymous users"), blank=True, null=True)
    show_on_add_connected = models.NullBooleanField(_("show to connected users"), blank=True, null=True)
    show_on_add_members   = models.NullBooleanField(_("show to project members"), blank=True, null=True)

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

    # Can always be changed
    rank = models.IntegerField(help_text=_("display 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
    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
    keep_history = models.BooleanField(_("keep field value changes in history"))

    def apply_on(self, field_definition):
        """
        Modify a default field definition with FieldOverlay's override
        values.  Only apply sensible overlays.
        """
        if not field_definition['required']:
            field_definition['use_it'] = self.use_it
            field_definition['show_on_add_anonymous'] = self.show_on_add_anonymous
            field_definition['show_on_add_connected'] = self.show_on_add_connected
            field_definition['show_on_add_members'] = self.show_on_add_members
        field_definition['empty_ok'] = self.empty_ok
        field_definition['rank'] = self.rank
        if field_definition['display_type'] == 'SB':
            field_definition['transition_default_auth'] = self.transition_default_auth
        elif field_definition['display_type'] in ('TA', 'TF'):
            field_definition['display_size'] = self.display_size
            print field_definition['name'], field_definition['display_size']
            # Make it easier to access the field from templates:
            if field_definition['display_size'] is not None:  # some old data may have weird values
                if field_definition['display_type'] == 'TF':
                    field_definition['input_size'] = field_definition['display_size'].split("/")[0]
                    field_definition['input_maxlength'] = field_definition['display_size'].split("/")[1]
                else:
                    field_definition['textarea_cols'] = field_definition['display_size'].split("/")[0]
                    field_definition['textarea_rows'] = field_definition['display_size'].split("/")[1]
        if self.group_id is None or field_definition['special'] != 1:
            field_definition['keep_history'] = self.keep_history
        if self.group_id is None or field_definition['custom'] == 1:
            field_definition['label'] = self.label
            field_definition['description'] = self.description

class FieldChoice(models.Model):
    """
    Per-group tracker select box values override
    (or site-wide field default if group==NULL)
    """
    class Meta:
        unique_together = (('tracker', 'group', 'field', 'value_id'),)

    STATUS_CHOICES = (('A', _('active')),
                      ('H', _('hidden')), # mask previously-active or system fields
                      ('P', _('permanent')),) # status cannot be modified, always visible
    tracker = models.ForeignKey(Tracker, blank=True, null=True)
    group = models.ForeignKey(auth_models.Group, blank=True, null=True,
                              help_text=_("NULL == default for all groups"))
    field = models.CharField(max_length=32)
    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()
    rank = models.IntegerField()
    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)

    def __unicode__(self):
        #return "%s.%s: %s (%d)" % (self.tracker_id, self.field, self.value, self.value_id)
        group_name = '<default>'
        if self.group_id is not None:
            group_name = self.group.name
        return "%s.%s: %s (%d)" % (group_name, self.field, self.value, self.value_id)

##
# Field
##

def field_get_values(tracker_id, group, field_def, cur_item_value_id=None):
    """
    Return all possible values for this select box field
    """
    name = field_def['name']
    if name == 'submitted_by':
        # Not editable
        return []
    if name == 'assigned_to':
        # Hard-coded processing: it's a list of project members
        values = [{'value_id' : -1, 'value' : _("None")}]
        pks = list(group.user_set.order_by('username').values_list('pk', flat=True))
        # Add the current value if the user was previously part of the
        # project and assigned this time
        if cur_item_value_id not in pks:
            pks.insert(0, cur_item_value_id)
        for user in auth_models.User.objects.filter(pk__in=pks):
            values.append({'value_id' : user.pk, 'value' : user.username})
    else:
        values = list(FieldChoice.objects \
            .filter(tracker=tracker_id, group=None, field=name) \
            .filter(~Q(status='H')|Q(value_id=cur_item_value_id)) \
            .values('value_id', 'value', 'rank'))
        # value overlays
        overlay_values = list(FieldChoice.objects \
            .filter(tracker=tracker_id, group=group, field=name) \
            .values('value_id', 'value', 'rank', 'status'))
        for o in overlay_values:
            found = False
            i = 0
            for v in values:
                if v['value_id'] == o['value_id']:
                    found = True
                    if o['status'] == 'H':
                        del values[i]
                        i -= 1
                    else:
                        v['value'] = o['value']
                        v['rank'] = o['rank']
                    break
                i += 1
            if not found and o['status'] != 'H' and field_def['scope'] != 'S':
                values.append(o)
        values.sort(key=lambda x: x['rank'])

    # Try to apply a translation:
    for v in values:
        v['value'] = ugettext(v['value'])

    return values

# 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, related_name='items_submitted')
    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('FieldChoice', to_field='value_id', default=5)
    #            + constraint(same group or 100) + constraint(field='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.ForeignKey(auth_models.User, related_name='items_assigned', 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_icon_name(self):
        if self.tracker_id == 'bugs':
            return "bug"
        elif self.tracker_id == 'patch':
            return "patch"
        elif self.tracker_id == 'support':
            return "help"
        elif self.tracker_id == 'task':
            return "task"

    def get_field_defs(self):
        """
        Return fields definition for this group tracker (default
        values + group-specific overlay).  Only apply sensible
        overlay values (cf. FieldOverlay model definition).
        """
        fields = deepcopy(field_defs)
        for overlays in (FieldOverlay.objects.filter(tracker=self.tracker_id, group=None),
                         FieldOverlay.objects.filter(tracker=self.tracker_id, group=self.group)):
            for overlay in overlays:
                name = overlay.field
                overlay.apply_on(fields[name])
        for name in fields:
            if fields[name]['display_type'] == 'SB':
                fields[name]['choices'] = field_get_values(self.tracker_id, self.group,
                                                          fields[name], self.get_value(name))
        return fields

    def get_form_fields(self, user=None):
        """
        Return displayable fields, ordered by rank
        """
        fields = self.get_field_defs()
        ret = []
        for name in fields.keys():
            if (not (fields[name]['required'] or fields[name]['use_it'])
                or fields[name]['special']):
                continue
            ret.append((name,fields[name]))
        ret.sort(key=lambda x: x[1]['rank'])
        return ret

    def get_value(self, key):
        if key == 'comment_type_id':
            # not stored in the item, but in the history
            # TODO: it actually has nothing do in fields definitions
            # (not part of the generic form, not a stored value); move
            # it out!
            return None
        elif key in ('submitted_by', 'assigned_to'):
            return getattr(self, key+'_id')
        else:
            return getattr(self, key)

    def get_form(self, user=None):
        # TODO: privacy
        # TODO: privileges
        form_fields = self.get_form_fields()
        html = '';
        for field_no, (name,field) in enumerate(form_fields):
            value = self.get_value(name)

            if field_no % 2 == 0:
                html += '<tr>'

            html += u'<th><span class="help" title="%s">%s</span></th>\n' % (field['description'], field['label']+ugettext(": "))

            html += '<td>'
            if field['display_type'] == 'DF':
                html += u'<select name="%s_dayfd" value="TODO">\n' % (name)
                for i in range(1,31+1):
                    html += '<option value="%d">%d</option>\n' % (i, i)
                html += '</select>\n'
                html += u'<select name="%s_monthfd" value="TODO">\n' % (name)
                for i,langinfo_constant in enumerate( \
                    (locale.MON_1, locale.MON_2, locale.MON_3, locale.MON_4,
                     locale.MON_4, locale.MON_6, locale.MON_7, locale.MON_8,
                     locale.MON_9, locale.MON_10, locale.MON_11, locale.MON_12)):
                    html += '<option value="%d">%s</option>\n' % (i, locale.nl_langinfo(langinfo_constant))
                html += '</select>\n'
                html += u'<input type="text" size="4" maxlength="4" name="%s_yearfd" value="TODO">' % (name)
            elif field['display_type'] == 'SB':
                html += u'<select name="%s">\n' % name
                for option in field['choices']:
                    selected = ''
                    if option['value_id'] == value:
                        selected = ' selected="selected"'
                    html += u'<option value="%d"%s>%s</option>\n' % (option['value_id'], selected, option['value'])
                html += '</select>'
            elif field['display_type'] == 'TA':
                # TODO: display_size
                html += u'<textarea name="%s">%s</textarea>' % (name, value)
            elif field['display_type'] == 'TF':
                # TODO: display_size
                html += u'<input type="text" name="%s" value="%s" />' % (name, value)
            html += '</td>\n'

            if field_no % 2 == 1:
                html += '</tr>\n'
        if field_no % 2 == 0:  # close if odd number of fields
            html += '</tr>\n'

        #return mark_safe(''.join(['%s (%d)<br />' % (f, v['rank']) for f,v in form_fields]))
        return mark_safe(html)

    @models.permalink
    def get_absolute_url(self):
        return ('savane:tracker:item_detail', [self.tracker_id, self.get_public_id()])

    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='details')
    - value changes, for fields that have history tracking enabled
    """
    item = models.ForeignKey('Item')
    field = models.CharField(max_length=32)
       # Should be: field = models.ForeignKey('Field', to_field='name')
       #            + constraint (item.tracker=field.tracker)
       # or simply: field = 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('FieldChoice', to_field='value_id')
      #        + constraint(same group or 100) + constraint(field='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