Logo Search packages:      
Sourcecode: bauble version File versions

plant.py

#
# plant.py
#
"""
Defines the plant table and handled editing plants
"""
import os
import sys
import traceback

import gtk
import gobject
from sqlalchemy import *
from sqlalchemy.orm import *
from sqlalchemy.orm.session import object_session
from sqlalchemy.exc import SQLError

import bauble.db as db
from bauble.i18n import *
from bauble.error import check, CheckConditionError
from bauble.editor import *
import bauble.utils as utils
from bauble.utils.log import debug
import bauble.types as types
import bauble.meta as meta
from bauble.view import SearchStrategy
from bauble.plugins.garden.location import Location, LocationEditor

# TODO: do a magic attribute on plant_id that checks if a plant id
# already exists with the accession number, this probably won't work
# though sense the acc_id may not be set when setting the plant_id

# TODO: might be worthwhile to have a label or textview next to the
# location combo that shows the description of the currently selected
# location

# TODO: Bulk Editor
#
# 1. Go into "Bulk" mode when commas are entered into the plant code,
# this will change the location, accession type and status of all
# plants with code in the list, if any notes are added they will be
# appended to any existing notes the plant might already have...or
# probably better to just disable editing the notes when we enter bulk
# mode
#
# 2. Should turn any widgets red if they have values that aren't
# common to all the plants codes...if the widgets is red then we won't
# be saving this field to all the plants...if the user sets that
# widget then all the plants will take that value
#
# 3. Could cause alot of problems if we mix existing plant code and
# not existing plant codes because the user might not know if there
# are creating new ones or editing existing ones. We could highlight
# the ones that are new but all these color codes might be confusing.
#

plant_delimiter_key = u'plant_delimiter'
default_plant_delimiter = u'.'


def edit_callback(plant):
    session = bauble.Session()
    e = PlantEditor(model=session.merge(plant))
    return e.start() != None


def remove_callback(plant):
    s = '%s: %s' % (plant.__class__.__name__, str(plant))
    msg = _("Are you sure you want to remove %s?") % utils.xml_safe_utf8(s)
    if not utils.yes_no_dialog(msg):
        return
    try:
        session = bauble.Session()
        obj = session.query(Plant).get(plant.id)
        session.delete(obj)
        session.commit()
    except Exception, e:
        msg = _('Could not delete.\n\n%s') % utils.xml_safe_utf8(e)

        utils.message_details_dialog(msg, traceback.format_exc(),
                                     type=gtk.MESSAGE_ERROR)
    return True


plant_context_menu = [('Edit', edit_callback),
                      ('--', None),
                      ('Remove', remove_callback)]


def plant_markup_func(plant):
    '''
    '''
    sp_str = plant.accession.species_str(markup=True)
    if plant.acc_status == 'Dead':
        color = '<span foreground="#666666">%s</span>'
        return color % utils.xml_safe_utf8(plant), sp_str
    else:
        return utils.xml_safe_utf8(plant), sp_str



class PlantSearch(SearchStrategy):

    def __init__(self):
        super(PlantSearch, self).__init__()


    def search(self, text, session=None):
        if session is None:
            session = bauble.Session()
        delimiter = Plant.get_delimiter()
        if delimiter not in text:
            return []
        acc_code, plant_code = text.rsplit(delimiter, 1)
        query = session.query(Plant)
        from bauble.plugins.garden import Accession
        try:
            return query.join('accession').\
                filter(and_(Accession.code==acc_code, Plant.code==plant_code))
        except Exception, e:
            debug(e)
            return []



class PlantHistory(db.Base):
    __tablename__ = 'plant_history'
    _mapper_args__ = {'order_by': 'date'}
    date = Column(types.Date)
    description = Column(UnicodeText)
    plant_id = Column(Integer, ForeignKey('plant.id'), nullable=False)

    def __str__(self):
        return '%s: %s' % (self.date, self.description)



00138 class Plant(db.Base):
    """
    :Table name: plant

    :Columns:
        *code*: :class:`sqlalchemy.types.Unicode`
            The plant code

        *acc_type*: :class:`bauble.types.Enum`
            The accession type

            Possible values:
                * Plant: Whole plant

                * Seed/Spore: Seed or Spore

                * Vegetative Part: Vegetative Part

                * Tissue Culture: Tissue culture

                * Other: Other, probably see notes for more information

                * None: no information, unknown

        *acc_status*: :class:`bauble.types.Enum`
            The accession status

            Possible values:
                * Living accession: Current accession in living collection

                * Dead: Noncurrent accession due to Death

                * Transfered: Noncurrent accession due to Transfer
                  Stored in dormant state: Stored in dormant state

                * Other: Other, possible see notes for more information

                * None: no information, unknown)

        *notes*: :class:`sqlalchemy.types.UnicodeText`
            Notes

        *accession_id*: :class:`sqlalchemy.types.ForeignKey`
            Required.

        *location_id*: :class:`sqlalchemy.types.ForeignKey`
            Required.

    :Properties:
        *accession*:
            The accession for this plant.
        *location*:
            The location for this plant.

    :Constraints:
        The combination of code and accession_id must be unique.
    """
    __tablename__ = 'plant'
    __table_args__ = (UniqueConstraint('code', 'accession_id'), {})
    __mapper_args__ = {'order_by': ['accession_id', 'plant.code']}

    # columns
    code = Column(Unicode(6), nullable=False)
    acc_type = Column(types.Enum(values=['Plant', 'Seed/Spore',
                                         'Vegetative Part', 'Tissue Culture',
                                         'Other', None]),
                      default=None)
    acc_status = Column(types.Enum(values=['Living accession', 'Dead',
                                           'Transferred',
                                           'Stored in dormant state', 'Other',
                                     None]),
                        default=None)
    notes = Column(UnicodeText)
    accession_id = Column(Integer, ForeignKey('accession.id'), nullable=False)
    location_id = Column(Integer, ForeignKey('location.id'), nullable=False)

    # relations
    history = relation('PlantHistory', backref='plant')

    @classmethod
00218     def get_delimiter(cls):
        """
        Get the plant delimiter from the BaubleMeta table
        """
        return meta.get_default(plant_delimiter_key,
                                default_plant_delimiter).value

    _delimiter = None
    def _get_delimiter(self):
        if self._delimiter is None:
            self._delimiter = Plant.get_delimiter()
        return self._delimiter
    delimiter = property(lambda self: self._get_delimiter())


    def __str__(self):
        return "%s%s%s" % (self.accession, self.delimiter, self.code)


    def markup(self):
        #return "%s.%s" % (self.accession, self.plant_id)
        # FIXME: this makes expanding accessions look ugly with too many
        # plant names around but makes expanding the location essential
        # or you don't know what plants you are looking at
        return "%s%s%s (%s)" % (self.accession, self.delimiter, self.code,
                                self.accession.species_str(markup=True))


from bauble.plugins.garden.accession import Accession


class PlantEditorView(GenericEditorView):

    #source_expanded_pref = 'editor.accesssion.source.expanded'

    _tooltips = {
        'plant_code_entry': _('The plant code must be a unique code'),
        'plant_acc_entry': _('The accession must be selected from the list ' \
                             'of completions.  To add an accession use the '\
                             'Accession editor'),
        'plant_loc_combo': _('The location of the plant in your collection.'),
        'plant_acc_type_combo': _('The type of the plant material.\n\n' \
                                  'Possible values: %s') \
                                  % utils.enum_values_str('plant.acc_type'),
        'plant_acc_status_combo': _('The status of this plant in the ' \
                                    'collection.\nPossible values: %s') \
                                   % utils.enum_values_str('plant.acc_status'),
        'plant_notes_textview': _('Miscelleanous notes about this plant.'),
        }


    def __init__(self, parent=None):
        GenericEditorView.__init__(self, os.path.join(paths.lib_dir(),
                                                      'plugins', 'garden',
                                                      'editors.glade'),
                                   parent=parent)
        self.dialog = self.widgets.plant_dialog
        self.dialog.set_transient_for(parent)
        self.connect_dialog_close(self.dialog)
        def acc_cell_data_func(column, renderer, model, iter, data=None):
            v = model[iter][0]
            renderer.set_property('text', '%s (%s)' % (str(v), str(v.species)))
        self.attach_completion('plant_acc_entry', acc_cell_data_func,
                               minimum_key_length=1)


    def save_state(self):
        pass


    def restore_state(self):
        pass


    def start(self):
        return self.dialog.run()


class ObjectIdValidator(object):

    def to_python(self, value, state):
        return value.id


class PlantEditorPresenter(GenericEditorPresenter):


    widget_to_field_map = {'plant_code_entry': 'code',
                           'plant_acc_entry': 'accession',
                           'plant_loc_combo': 'location',
                           'plant_acc_type_combo': 'acc_type',
                           'plant_acc_status_combo': 'acc_status',
                           'plant_notes_textview': 'notes'}

    PROBLEM_DUPLICATE_PLANT_CODE = 5

    def __init__(self, model, view):
        '''
        @param model: should be an instance of Plant class
        @param view: should be an instance of PlantEditorView
        '''
        GenericEditorPresenter.__init__(self, model, view)
        self.session = object_session(model)
        self._original_accession_id = self.model.accession_id
        self._original_code = self.model.code
        self.__dirty = False

        # initialize widgets
        self.init_location_combo()
        self.init_enum_combo('plant_acc_status_combo', 'acc_status')
        self.init_enum_combo('plant_acc_type_combo', 'acc_type')

#        self.init_history_box()

        # set default values for acc_status and acc_type
        if self.model.id is None and self.model.acc_type is None:
            default_acc_type = unicode('Plant')
            self.model.acc_type = default_acc_type
        if self.model.id is None and self.model.acc_status is None:
            default_acc_status = unicode('Living accession')
            self.model.acc_status = default_acc_status

        self.refresh_view() # put model values in view

        # connect signals
        def acc_get_completions(text):
            query = self.session.query(Accession)
            return query.filter(Accession.code.like(unicode('%s%%' % text)))

        def on_select(value):
            self.set_model_attr('accession', value)
            # reset the plant code to check that this is a valid code for the
            # new accession, fixes bug #103946
            self.on_plant_code_entry_changed()
        self.assign_completions_handler('plant_acc_entry', acc_get_completions,
                                        on_select=on_select)

        self.view.widgets.plant_code_entry.connect('changed',
                                            self.on_plant_code_entry_changed)

        self.assign_simple_handler('plant_notes_textview', 'notes',
                                   UnicodeOrNoneValidator())
        self.assign_simple_handler('plant_loc_combo', 'location')#, ObjectIdValidator())
        self.assign_simple_handler('plant_acc_status_combo', 'acc_status',
                                   UnicodeOrNoneValidator())
        self.assign_simple_handler('plant_acc_type_combo', 'acc_type',
                                   UnicodeOrNoneValidator())

        self.view.widgets.plant_loc_add_button.connect('clicked',
                                                    self.on_loc_button_clicked,
                                                    'add')
        self.view.widgets.plant_loc_edit_button.connect('clicked',
                                                    self.on_loc_button_clicked,
                                                    'edit')


    def dirty(self):
        return self.__dirty


    def on_plant_code_entry_changed(self, *args):
        text = utils.utf8(self.view.widgets.plant_code_entry.get_text())
        #debug('on_plant_code_entry_changed(%s)' % text)
        if text == u'':
            self.set_model_attr('code', None)
        else:
            self.set_model_attr('code', text)

        if self.model.accession is None:
            self.remove_problem(self.PROBLEM_DUPLICATE_PLANT_CODE,
                                self.view.widgets.plant_code_entry)
            self.refresh_sensitivity()
            return

        # reference accesssion.id instead of accession_id since
        # setting the accession on the model doesn't set the
        # accession_id until the session is flushed
        nplants_query = self.session.query(Plant).join('accession').\
                  filter(and_(Accession.id==self.model.accession.id,
                              Plant.code==text))

        # add a problem if the code is not unique but not if its the
        # same accession and plant code that we started with when the
        # editor was opened
        if self.model.code is not None and nplants_query.count() > 0 \
               and self._original_accession_id != self.model.accession.id \
               and self.model.code == self._original_code:
            self.add_problem(self.PROBLEM_DUPLICATE_PLANT_CODE,
                             self.view.widgets.plant_code_entry)
        else:
            self.remove_problem(self.PROBLEM_DUPLICATE_PLANT_CODE,
                                self.view.widgets.plant_code_entry)
        self.refresh_sensitivity()


    def refresh_sensitivity(self):
        #debug('refresh_sensitivity()')
        sensitive = (self.model.accession is not None and \
                     self.model.code is not None and \
                     self.model.location is not None) \
                     and self.dirty() and len(self.problems)==0
        self.view.widgets.plant_ok_button.set_sensitive(sensitive)
        self.view.widgets.plant_next_button.set_sensitive(sensitive)


    def set_model_attr(self, field, value, validator=None):
        #debug('set_model_attr(%s, %s)' % (field, value))
        super(PlantEditorPresenter, self)\
            .set_model_attr(field, value, validator)
        self.__dirty = True
        self.refresh_sensitivity()


    def on_loc_button_clicked(self, button, cmd=None):
        location = None
        combo = self.view.widgets.plant_loc_combo
        it = combo.get_active_iter()
        if it is not None:
            location = combo.get_model()[it][0]
        if cmd is 'edit':
            e = LocationEditor(location, parent=self.view.dialog)
        else:
            e = LocationEditor(parent=self.view.dialog)
        e.start()
        self.init_location_combo()

        if location is not None:
            self.session.refresh(location)
            new = self.session.get(Location, location.id)
            utils.set_combo_from_value(combo, new)
        else:
            combo.set_active(-1)


    def init_location_combo(self):
        """
        Initialize plant_loc_combo
        """
        # build the model
        locations = self.session.query(Location)
        model = gtk.ListStore(object)
        locs = sorted([l for l in locations], key=utils.natsort_key)
        for loc in locs:
           model.append([loc])

        combo = self.view.widgets.plant_loc_combo
        combo.set_model(model)
        combo.clear()
        renderer = gtk.CellRendererText()
        combo.pack_start(renderer, True)
        def cell_data_func(column, cell, model, it, data=None):
            v = model[it][0]
            cell.set_property('text', utils.utf8(v))
        combo.set_cell_data_func(renderer, cell_data_func)

        # TODO: why doesn't this work, get_active() show that it is
        # active but the entry isn't shown in the combo
        if locations.count() == 1:
            combo.set_active_iter(model.get_iter_root())


#    def init_acc_entry(self):
#        pass
#    def init_type_and_status_combo(self):
#        pass
#    def init_history_box(self):
#        pass

    def refresh_view(self):
        for widget, field in self.widget_to_field_map.iteritems():
#            if field is 'accession_id':
#                value = self.model.accession
#            elif field is 'location_id':
#                value = self.model.location
#            else:
            value = getattr(self.model, field)
            self.view.set_widget_value(widget, value)
        self.refresh_sensitivity()


    def start(self):
        return self.view.start()


class PlantEditor(GenericModelViewPresenterEditor):

    label = 'Plant'
    mnemonic_label = '_Plant'

    # these have to correspond to the response values in the view
    RESPONSE_NEXT = 22
    ok_responses = (RESPONSE_NEXT,)


    def __init__(self, model=None, parent=None):
        '''
        @param model: Plant instance or None
        @param parent: None
        '''
        if model is None:
            model = Plant()
        GenericModelViewPresenterEditor.__init__(self, model, parent)
        if not parent and bauble.gui: # should we even allow a change in parent
            parent = bauble.gui.window
        self.parent = parent
        self._committed = []


    def handle_response(self, response):
        not_ok_msg = _('Are you sure you want to lose your changes?')
        if response == gtk.RESPONSE_OK or response in self.ok_responses:
#                debug('session dirty, committing')
            try:
                if self.presenter.dirty():
                    self.commit_changes()
                    self._committed.append(self.model)
            except SQLError, e:
                exc = traceback.format_exc()
                msg = _('Error committing changes.\n\n%s') % e.orig
                utils.message_details_dialog(msg, str(e), gtk.MESSAGE_ERROR)
                self.session.rollback()
                return False
            except Exception, e:
                msg = _('Unknown error when committing changes. See the '\
                      'details for more information.\n\n%s') \
                      % utils.xml_safe_utf8(e)
                debug(traceback.format_exc())
                utils.message_details_dialog(msg, traceback.format_exc(),
                                             gtk.MESSAGE_ERROR)
                self.session.rollback()
                return False
        elif self.presenter.dirty() and utils.yes_no_dialog(not_ok_msg) \
                or not self.presenter.dirty():
            self.session.rollback()
            return True
        else:
            return False

#        # respond to responses
        more_committed = None
        if response == self.RESPONSE_NEXT:
            e = PlantEditor(Plant(accession=self.model.accession),
                            parent=self.parent)
            more_committed = e.start()

        if more_committed is not None:
            self._committed = [self._committed]
            if isinstance(more_committed, list):
                self._committed.extend(more_committed)
            else:
                self._committed.append(more_committed)

        return True


    def start(self):
        from bauble.plugins.garden.accession import Accession
        # TODO: should really open the accession and location editors here, and
        # ask 'Would you like to do that now?'
        if self.session.query(Accession).count() == 0:
            msg = 'You must first add or import at least one Accession into '\
                  'the database before you can add plants.\n\nWould you like '\
                  'to open the Accession editor?'
            if utils.yes_no_dialog(msg):
                from bauble.plugins.garden.accession import AccessionEditor
                e = AccessionEditor()
                return e.start()
        if self.session.query(Location).count() == 0:
            msg = 'You must first add or import at least one Location into '\
                  'the database before you can add species.\n\nWould you '\
                  'like to open the Location editor?'
            if utils.yes_no_dialog(msg):
                e = LocationEditor()
                return e.start()
        self.view = PlantEditorView(parent=self.parent)
        self.presenter = PlantEditorPresenter(self.model, self.view)

        # add quick response keys
        dialog = self.view.dialog
        self.attach_response(dialog, gtk.RESPONSE_OK, 'Return',
                             gtk.gdk.CONTROL_MASK)
        self.attach_response(dialog, self.RESPONSE_NEXT, 'n',
                             gtk.gdk.CONTROL_MASK)

        # set default focus
        if self.model.accession is None:
            self.view.widgets.plant_acc_entry.grab_focus()
        else:
            self.view.widgets.plant_code_entry.grab_focus()

        while True:
            response = self.presenter.start()
            self.view.save_state() # should view or presenter save state
            if self.handle_response(response):
                break

        self.session.close() # cleanup session
        return self._committed



import os
import bauble.paths as paths
from bauble.view  import InfoBox, InfoExpander, PropertiesExpander, \
     select_in_search_results


00625 class GeneralPlantExpander(InfoExpander):
    """
    general expander for the PlantInfoBox
    """

00630     def __init__(self, widgets):
        '''
        '''
        InfoExpander.__init__(self, "General", widgets)
        general_box = self.widgets.general_box
        self.widgets.remove_parent(general_box)
        self.vbox.pack_start(general_box)
        self.current_obj = None

        def on_acc_code_clicked(*args):
            select_in_search_results(self.current_obj.accession)
        utils.make_label_clickable(self.widgets.acc_code_data,
                                   on_acc_code_clicked)

        def on_species_clicked(*args):
            select_in_search_results(self.current_obj.accession.species)
        utils.make_label_clickable(self.widgets.name_data, on_species_clicked)

        def on_location_clicked(*args):
            select_in_search_results(self.current_obj.location)
        utils.make_label_clickable(self.widgets.location_data,
                                   on_location_clicked)


00654     def update(self, row):
        '''
        '''
        self.current_obj = row
        acc_code = str(row.accession)
        plant_code = str(row)
        head, tail = plant_code[:len(acc_code)], plant_code[len(acc_code):]

        self.set_widget_value('acc_code_data', '<big>%s</big>' % \
                                                utils.xml_safe(unicode(head)))
        self.set_widget_value('plant_code_data', '<big>%s</big>' % \
                              utils.xml_safe(unicode(tail)))
        self.set_widget_value('name_data',
                              row.accession.species_str(markup=True))
        self.set_widget_value('location_data',row.location.site)
        self.set_widget_value('status_data',
                         row.acc_status, False)
        self.set_widget_value('type_data',
                              row.acc_type, False)



00676 class NotesExpander(InfoExpander):
    """
    the plants notes
    """

00681     def __init__(self, widgets):
        '''
        '''
        InfoExpander.__init__(self, "Notes", widgets)
        notes_box = self.widgets.notes_box
        self.widgets.remove_parent(notes_box)
        self.vbox.pack_start(notes_box)


00690     def update(self, row):
        '''
        '''
        self.set_widget_value('notes_data', row.notes)


00696 class PlantInfoBox(InfoBox):
    """
    an InfoBox for a Plants table row
    """

00701     def __init__(self):
        '''
        '''
        InfoBox.__init__(self)
        #loc = LocationExpander()
        #loc.set_expanded(True)
        glade_file = os.path.join(paths.lib_dir(), "plugins", "garden",
                                  "plant_infobox.glade")
        self.widgets = utils.GladeWidgets(glade_file)
        self.general = GeneralPlantExpander(self.widgets)
        self.add_expander(self.general)
        self.notes = NotesExpander(self.widgets)
        self.add_expander(self.notes)
        self.props = PropertiesExpander()
        self.add_expander(self.props)


00718     def update(self, row):
        '''
        '''
        # TODO: don't really need a location expander, could just
        # use a label in the general section
        #loc = self.get_expander("Location")
        #loc.update(row.location)
        self.general.update(row)
        self.props.update(row)

        if row.notes is None:
            self.notes.set_expanded(False)
            self.notes.set_sensitive(False)
        else:
            self.notes.set_expanded(True)
            self.notes.set_sensitive(True)
            self.notes.update(row)


from bauble.plugins.garden.accession import Accession
#from bauble.plugins.garden.location import Location, LocationEditor

Generated by  Doxygen 1.6.0   Back to index