Net-ng/kansha

View on GitHub
kansha/board/comp.py

Summary

Maintainability
F
5 days
Test Coverage
# -*- coding:utf-8 -*-
# --
# Copyright (c) 2012-2014 Net-ng.
# All rights reserved.
#
# This software is licensed under the BSD License, as described in
# the file LICENSE.txt, which you should have received as part of
# this distribution.
# --

import json
import itertools
from functools import partial

from nagare.i18n import _
from peak.rules import when
from nagare.security import common
from nagare.database import session
from nagare import component, log, security, var

from kansha import title
from kansha.card import Card
from kansha.user import usermanager
from kansha.services import ActionLog
from kansha.column import comp as column
from kansha.user.comp import PendingUser
from kansha.toolbox import popin, overlay
from kansha.card_addons.label import Label
from kansha.authentication.database import forms
from kansha import events, exceptions, validator
from kansha.board_card_filter import BoardCardFilter

from .boardconfig import BoardConfig
from .excel_export import ExcelExport
from .templates import SaveTemplateTask
from .models import DataBoard, BOARD_PRIVATE, BOARD_PUBLIC, BOARD_SHARED


# Votes authorizations
VOTES_OFF = 0
VOTES_MEMBERS = 1
VOTES_PUBLIC = 2

# Comments authorizations
COMMENTS_OFF = 0
COMMENTS_MEMBERS = 1
COMMENTS_PUBLIC = 2


# WEIGHTING CARDS
WEIGHTING_OFF = 0
WEIGHTING_FREE = 1
WEIGHTING_LIST = 2


class Board(events.EventHandlerMixIn):

    """Board component"""

    MAX_SHOWN_MEMBERS = 4
    background_max_size = 3 * 1024  # in Bytes

    def __init__(self, id_, app_title, app_banner, theme, card_extensions, search_engine_service,
                 assets_manager_service, mail_sender_service, services_service,
                 load_children=True, data=None):
        """Initialization

        In:
          -- ``id_`` -- the id of the board in the database
          -- ``mail_sender_service`` -- Mail service, used to send mail
          -- ``on_board_delete`` -- function to call when the board is deleted
        """
        self.model = 'columns'
        self.app_title = app_title
        self.app_banner = app_banner
        self.theme = theme
        self.mail_sender = mail_sender_service
        self.id = id_
        self._data = data
        self.assets_manager = assets_manager_service
        self.search_engine = search_engine_service
        self._services = services_service
        # Board extensions are not extracted yet, so
        # board itself implement their API.
        self.board_extensions = {
            'weight': self,
            'labels': self,
            'members': self,
            'comments': self,
            'votes': self
        }
        self.card_extensions = card_extensions.set_configurators(self.board_extensions)

        self.action_log = ActionLog(self)

        self.version = self.data.version
        self.modal = component.Component(popin.Empty())
        self.card_filter = self._services(BoardCardFilter, Card.schema, self.id,
                                          not self.show_archive)
        self.search_input = component.Component(self.card_filter, 'search_input')

        self.columns = []
        self.archive_column = None
        if load_children:
            self.load_children()

        # Member part
        self.overlay_add_members = component.Component(
            overlay.Overlay(lambda r: (r.i(class_='ico-btn icon-user-plus')),
                            lambda r: component.Component(self).render(r, model='add_member_overlay'),
                            dynamic=True, cls='board-labels-overlay'))
        self.new_member = component.Component(usermanager.NewMember(self.autocomplete_method))

        self.update_members()

        def many_user_render(h, number):
            return h.span(
                h.i(class_='ico-btn icon-user'),
                h.span(number, class_='count'),
                title=_("%s more...") % number)

        self.see_all_members = component.Component(overlay.Overlay(lambda r: many_user_render(r, len(self.all_members) - self.MAX_SHOWN_MEMBERS),
                                                                   lambda r: component.Component(self).render(r, model='members_list_overlay'),
                                                                   dynamic=False, cls='board-labels-overlay'))
        self.see_all_members_compact = component.Component(overlay.Overlay(lambda r: many_user_render(r, len(self.all_members)),
                                                                           lambda r: component.Component(self).render(r, model='members_list_overlay'),
                                                                           dynamic=False, cls='board-labels-overlay'))

        self.comp_members = component.Component(self)

        # Icons for the toolbar
        self.icons = {'add_list': component.Component(Icon('icon-plus', _('Add list'))),
                      'edit_desc': component.Component(Icon('icon-pencil', _('Edit board description'))),
                      'preferences': component.Component(Icon('icon-cog', _('Preferences'))),
                      'export': component.Component(Icon('icon-download3', _('Export board'))),
                      'save_template': component.Component(Icon('icon-insert-template', _('Save as template'))),
                      'archive': component.Component(Icon('icon-bin', _('Archive board'))),
                      'leave': component.Component(Icon('icon-exit', _('Leave this board'))),
                      'history': component.Component(Icon('icon-history', _("Action log"))),
                      }

        # Title component
        self.title = component.Component(
            title.EditableTitle(self.get_title)).on_answer(self.set_title)

    @classmethod
    def get_id_by_uri(cls, uri):
        board = DataBoard.get_by_uri(uri)
        board_id = None
        if board is not None:
            board_id = board.id
        return board_id

    @classmethod
    def exists(cls, **kw):
        return DataBoard.exists(**kw)

    def __eq__(self, other):
        return isinstance(other, Board) and self.id == other.id

    # Main menu actions
    def add_list(self):
        new_column_editor = column.NewColumnEditor(len(self.columns) - 1)
        answer = self.modal.call(popin.Modal(new_column_editor, force_refresh=True))
        if answer:
            index, title, nb_cards = answer
            self.create_column(index, title, nb_cards if nb_cards else None)

    def edit_description(self):
        description_editor = BoardDescription(self.get_description())
        answer = self.modal.call(popin.Modal(description_editor))
        if answer is not None:
            self.set_description(answer)

    def save_template(self, comp):
        save_template_editor = SaveTemplateTask(self.get_title(),
                                                self.get_description(),
                                                partial(self.save_as_template, comp))
        self.modal.call(popin.Modal(save_template_editor))

    def show_actionlog(self):
        self.modal.call(popin.Modal(self.action_log))

    def show_preferences(self):
        preferences = BoardConfig(self)
        self.modal.call(popin.Modal(preferences, force_refresh=True))

    def save_as_template(self, comp, title, description, shared):
        data = (title, description, shared)
        return self.emit_event(comp, events.NewTemplateRequested, data)

    def copy(self, owner):
        """
        Create a new board that is a copy of self, without the archive.
        Children must be loaded.
        """
        new_data = self.data.copy()
        if self.data.background_image:
            new_data.background_image = self.assets_manager.copy(self.data.background_image)
        new_board = self._services(Board, new_data.id, self.app_title, self.app_banner, self.theme,
            self.card_extensions, load_children=False, data=new_data)
        new_board.add_member(owner, 'manager')

        assert(self.columns or self.data.is_template)
        cols = [col() for col in self.columns if not col().is_archive]
        for column in cols:
            new_column = new_board.create_column(-1, column.get_title())
            new_column.update(column)

        return new_board

    def on_event(self, comp, event):
        result = None
        if event.is_(events.ColumnDeleted):
            # actually delete the column
            result = self.delete_column(event.data)
        elif event.is_(events.CardArchived):
            result = self.archive_cards([event.emitter], event.last_relay)
        elif event.is_(events.SearchIndexUpdated):
            result = self.card_filter.reload_search()

        return result

    def switch_view(self):
        self.model = 'calendar' if self.model == 'columns' else 'columns'

    def load_children(self):
        columns = []
        for c in self.data.columns:
            col = self._services(
                column.Column, c.id, self, self.card_extensions,
                self.action_log, self.card_filter, data=c)
            if col.is_archive:
                self.archive_column = col
            columns.append(component.Component(col))

        self.columns = columns

    def increase_version(self):
        self.version += 1
        self.data.increase_version()
        return self.refresh_on_version_mismatch()

    def refresh_on_version_mismatch(self):
        refresh = False
        if self.data.version - self.version != 0:
            self.refresh()  # when does that happen?
            self.version = self.data.version
            refresh = True
        return refresh

    def refresh(self):
        log.info('sync')
        self._data = None
        self.load_children()

    @property
    def all_members(self):
        return self.managers + self.members + self.pending

    def update_members(self):
        """Update members section

        Recalculate members + managers + pending
        Recreate overlays
        """
        data = self.data
        #FIXME: use Membership components
        managers = []
        simple_members = []
        for manager, memberships in itertools.groupby(data.board_members,
                                                      lambda item: item.manager):
            # we use extend because the board_members are not reordered in case of change
            if manager:
                managers.extend([membership.user for membership in memberships])
            else:
                simple_members.extend([membership.user for membership in memberships])
        simple_members.sort(key=lambda m: (m.fullname, m.email))
        self.members = [component.Component(BoardMember(usermanager.UserManager.get_app_user(data=member), self, 'member'))
                        for member in simple_members]
        self.managers = [component.Component(BoardMember(usermanager.UserManager.get_app_user(data=member), self, 'manager' if len(managers) != 1 else 'last_manager'))
                         for member in managers]
        self.pending = [component.Component(BoardMember(PendingUser(token.token), self, 'pending'))
                        for token in data.pending]

    def set_title(self, title):
        """Set title

        In:
            - ``title`` -- new title
        """
        self.data.title = title

    def get_title(self):
        """Get title

        Return :
            - the board title
        """
        return self.data.title

    def mark_as_template(self, template=True):
        self.data.is_template = template

    def count_columns(self):
        """Return the number of columns
        (used in unit tests only)
        """
        return len(self.columns)

    @security.permissions('edit')
    def create_column(self, index, title, nb_cards=None):
        """Create a new column in the board

        In:
            - ``index`` -- the position of the column as an integer
            - ``title`` -- the title of the new column
            - ``nb_cards`` -- the number of maximun cards on the colum
        """
        if index < 0:
            index = index + len(self.columns) + 1
        if title == '':
            return False
        col = self.data.create_column(index, title, nb_cards)
        col_obj = self._services(
            column.Column, col.id, self,
            self.card_extensions, self.action_log, self.card_filter)
        self.columns.insert(
            index, component.Component(col_obj))
        self.increase_version()
        return col_obj

    @security.permissions('edit')
    def delete_column(self, col_comp):
        """Delete a board's column

        In:
            - ``id_`` -- the id of the column to delete
        """
        self.columns.remove(col_comp)
        self.data.delete_column(col_comp().data)
        col_comp().delete()
        self.increase_version()
        return popin.Empty()

    @security.permissions('edit')
    def update_card_position(self, data):
        data = json.loads(data)

        cols = {}
        for col in self.columns:
            cols[col().id] = (col(), col)

        orig, __ = cols[data['orig']]

        dest, dest_comp = cols[data['dest']]
        card_comp = None
        try:
            card_comp = orig.remove_card_by_id(data['card'])
            accepted = dest.insert_card_comp(dest_comp, data['index'], card_comp)
        except AttributeError:
            # one of the columns does not exist anymore
            # stop processing, let the refresh do the rest
            log.warning('attempt to move card between at least one missing column')
            if card_comp:
                orig.append_card(card_comp())
            return
        if accepted:
            card = card_comp()
            values = {'from': orig.get_title(),
                      'to': dest.get_title(),
                      'card': card.get_title()}
            self.action_log.for_card(card).add_history(
                security.get_user(),
                u'card_move', values)
            # reindex it in case it has been moved to the archive column
            card.add_to_index(self.search_engine, self.id, update=True)
            self.search_engine.commit()
            session.flush()
        else:
            orig.append_card(card_comp())

    @security.permissions('edit')
    def update_column_position(self, data):
        data = json.loads(data)
        cols = []
        found = None
        for col in self.columns:
            if col().id == data['list']:
                found = col
            else:
                cols.append(col)
        cols.insert(data['index'], found)
        for i, col in enumerate(cols):
            col().change_index(i)
        self.columns = cols
        session.flush()

    @property
    def visibility(self):
        return self.data.visibility

    @property
    def is_open(self):
        return (self.visibility == BOARD_PUBLIC or self.visibility == BOARD_SHARED)

    def set_visibility(self, visibility):
        """Changes board visibility

        If new visibility is "Member" and comments/votes permissions
        are in "Public" changes them to "Members"

        In:
         - ``visibility`` -- an integer, new visibility (Private or Public)
        """
        if self.comments_allowed == COMMENTS_PUBLIC:
            # If comments are PUBLIC that means the board was PUBLIC and
            # go to PRIVATE. That's why we don't test the visibility
            # input variable
            self.allow_comments(COMMENTS_MEMBERS)
        if self.votes_allowed == VOTES_PUBLIC:
            self.allow_votes(VOTES_MEMBERS)
        self.data.visibility = visibility

    @property
    def archived(self):
        return self.data.archived

    @property
    def show_archive(self):
        return self.data.show_archive

    @show_archive.setter
    def show_archive(self, value):
        self.data.show_archive = value
        self.card_filter.exclude_archived(not value)

    def archive_cards(self, cards, from_column):
        """Archive card

        In:
            - ``cards`` -- cards to archive, from the same column
        """
        for card in cards:
            self.archive_column.append_card(card)
            values = {'column_id': from_column.id, 'column': from_column.get_title(),
                      'card': card.get_title()}
            card.action_log.add_history(security.get_user(), u'card_archive', values)
            # reindex it
            card.add_to_index(self.search_engine, self.id, update=True)
        self.search_engine.commit(True)
        self.card_filter.reload_search()
        self.increase_version()

    ####### For future board extension

    @property
    def weighting_cards(self):
        return self.data.weighting_cards

    def activate_weighting(self, weighting_type):
        self.data.weighting_cards = weighting_type
        if weighting_type != WEIGHTING_FREE:
            # reinitialize card weights?
            self.data.reset_card_weights()

    @property
    def weights(self):
        if not self.data.weights:
            self.data.weights = '0, 1, 2, 3, 5, 8, 13'
        return self.data.weights

    @weights.setter
    def weights(self, weights):
        self.data.weights = weights

    def total_weight(self):
        return self.data.total_weight()

    ######################

    def delete_clicked(self, comp):
        return self.emit_event(comp, events.BoardDeleted)

    def delete(self):
        """Deletes the board.
           Children must be loaded.
        """
        assert(self.columns)  # at least, contains the archive
        for column in self.columns:
            column().delete(purge=True)
        self.data.delete_history()
        self.data.delete_members()
        if self.data.background_image:
            self.assets_manager.delete(self.data.background_image)
        session.refresh(self.data)
        self.data.delete()

        return True

    def archive(self, comp=None):
        """Archive the board
        """
        self.data.archived = True
        if comp:
            self.emit_event(comp, events.BoardArchived)
        return True

    def restore(self, comp=None):
        """Unarchive the board
        """
        self.data.archived = False
        if comp:
            self.emit_event(comp, events.BoardRestored)
        return True

    def export(self):
        return ExcelExport(self).download()

    @property
    def labels(self):
        """Returns the labels associated with the board
        """
        return [self._services(Label, data) for data in self.data.labels]

    @property
    def data(self):
        """Return the board object from the database
        PRIVATE
        """
        if self._data is None:
            self._data = DataBoard.get(self.id)
        return self._data

    def __getstate__(self):
        self._data = None
        return self.__dict__

    def allow_comments(self, v):
        """Changes permission to add comments

        In:
            - ``v`` -- a integer (see security.py for authorized values)
        """
        self.data.comments_allowed = v

    def allow_votes(self, v):
        """Changes permission to vote

        In:
            - ``v`` -- a integer (see security.py for authorized values)
        """
        self.data.votes_allowed = v

    @property
    def comments_allowed(self):
        return self.data.comments_allowed

    @property
    def votes_allowed(self):
        return self.data.votes_allowed

    # Callbacks for BoardDescription component
    def get_description(self):
        return self.data.description

    def set_description(self, value):
        self.data.description = value


    ##################
    # Member methods
    ##################

    def leave(self, comp=None):
        """Children must be loaded."""
        # FIXME: all member management function should live in another component than Board.
        user = security.get_user()
        for member in self.members:
            m_user = member().user().data
            if (m_user.username, m_user.source) == (user.data.username, user.data.source):
                board_member = member()
                break
        else:
            board_member = None
        self.data.remove_member(board_member.data)
        if comp:
            self.emit_event(comp, events.BoardLeft)
        return True

    def last_manager(self, member):
        """Return True if member is the last manager of the board

        In:
         - ``member`` -- member to test
        Return:
         - True if member is the last manager of the board
        """
        return member.role == 'manager' and len(self.managers) == 1

    def has_member(self, user):
        """Return True if user is member of the board

        In:
         - ``user`` -- user to test (User instance)
        Return:
         - True if user is member of the board
        """
        return self.data.has_member(user.data)

    def has_manager(self, user):
        """Return True if user is manager of the board

        In:
         - ``user`` -- user to test (User instance)
        Return:
         - True if user is manager of the board
        """
        return self.data.has_manager(user.data)

    def add_member(self, new_member, role='member'):
        """ Add new member to the board

        In:
         - ``new_member`` -- user to add
         - ``role`` -- role's member (manager or member)
        """
        self.data.add_member(new_member.data, role)

    def remove_pending(self, member):
        # remove from pending list
        self.pending = [p for p in self.pending if p() != member]

        # remove invitation
        self.remove_invitation(member.username)

    def remove_manager(self, manager):
        # remove from managers list
        self.managers = [p for p in self.managers if p() != manager]
        # remove manager from data part
        self.data.remove_member(manager.data)

    def remove_member(self, member):
        # remove from members list
        self.members = [p for p in self.members if p() != member]
        # remove member from data part
        self.data.remove_member(member.data)

    def remove_board_member(self, member):
        """Remove member from board

        Remove member from board. If member is PendingUser then remove
        invitation.

        Children must be loaded for propagation to the cards.

        In:
            - ``member`` -- Board Member instance to remove
        """
        if self.last_manager(member):
            # Can't remove last manager
            raise exceptions.KanshaException(_("Can't remove last manager"))

        log.info('Removing member %s' % (member,))
        remove_method = {'pending': self.remove_pending,
                         'manager': self.remove_manager,
                         'member': self.remove_member}
        remove_method[member.role](member)

    def change_role(self, member, new_role):
        """Change member's role

        In:
            - ``member`` -- Board member instance
            - ``new_role`` -- new role
        """
        log.info('Changing role of %s to %s' % (member, new_role))
        if self.last_manager(member):
            raise exceptions.KanshaException(_("Can't remove last manager"))

        self.data.change_role(member.data, new_role)
        self.update_members()

    def remove_invitation(self, email):
        """ Remove invitation

        In:
         - ``email`` -- guest email to invalidate
        """
        for token in self.data.pending:
            if token.username == email:
                token.delete()
                session.flush()
                break

    def invite_members(self, emails, application_url):
        """Invite somebody to this board,

        Create token used in invitation email.
        Store email in pending list.

        Params:
            - ``emails`` -- list of emails
        """
        for email in set(emails):
            # If user already exists add it to the board directly or invite it otherwise
            invitation = forms.EmailInvitation(self.app_title, self.app_banner, self.theme, email, security.get_user().data, self.data, application_url)
            invitation.send_email(self.mail_sender)

    def resend_invitation(self, pending_member, application_url):
        """Resend an invitation,

        Resend invitation to the pending member

        In:
            - ``pending_member`` -- Send invitation to this user (PendingMember instance)
        """
        email = pending_member.username
        invitation = forms.EmailInvitation(self.app_title, self.app_banner, self.theme, email, security.get_user().data, self.data, application_url)
        invitation.send_email(self.mail_sender)
        # re-calculate pending
        self.pending = [component.Component(BoardMember(PendingUser(token.token), self, "pending"))
                        for token in set(self.data.pending)]

################

    def autocomplete_method(self, v):
        """ Method called by autocomplete component.

        This method is called when you search a user on the add member
        overlay int the field autocomplete

        In:
            - ``v`` -- first letters of the username
        Return:
            - list of user (User instance)
        """
        users = usermanager.UserManager.search(v)
        results = []
        for user in users:
            if user.is_validated() and user.email not in [m().email for m in self.all_members]:
                results.append(user)
        return results

    def get_last_activity(self):
        return self.action_log.get_last_activity()

    def get_available_user_ids(self):
        """Return list of member

        Return:
            - list of members
        """
        return set(dbm.user.id for dbm in self.data.board_members)

    def set_background_image(self, new_file):
        """Set the board's background image
        In:
            - ``new_file`` -- the background image (FieldStorage)
        Return:
            nothing
        """
        if new_file is not None:
            fileid = self.assets_manager.save(new_file.file.read(),
                                              metadata={'filename': new_file.filename,
                                                        'content-type': new_file.type})
            self.data.background_image = fileid
        else:
            self.data.background_image = None

    def set_background_position(self, position):
        self.data.background_position = position

    @property
    def background_image_url(self):
        img = self.data.background_image
        try:
            return self.assets_manager.get_image_url(img, include_filename=False) if img else None
        except IOError:
            log.warning('Missing background %r for board %r', img, self.id)
            return None

    @property
    def background_image_position(self):
        return self.data.background_position or 'center'

    @property
    def title_color(self):
        return self.data.title_color

    def set_title_color(self, value):
        self.data.title_color = value or u''

    @classmethod
    def get_all_boards(cls, user, app_title, app_banner, theme, card_extensions,
                       services_service, load_children=False):
        """Return all boards the user is member of."""
        return [services_service(cls, data.id, app_title, app_banner, theme, card_extensions,
                                 data=data, load_children=load_children)
                for data in DataBoard.get_all_boards(user.data)]

    @classmethod
    def get_shared_boards(cls, app_title, app_banner, theme, card_extensions,
                          services_service, load_children=False):
        """Return all boards the user is member of."""
        return [services_service(cls, data.id, app_title, app_banner, theme, card_extensions,
                                 data=data, load_children=load_children)
                for data in DataBoard.get_shared_boards()]

    @staticmethod
    def get_templates_for(user):
        return DataBoard.get_templates_for(user.data, BOARD_PUBLIC)


# TODO: move this to board extension
@when(common.Rules.has_permission, "user and perm == 'Add Users' and isinstance(subject, Board)")
def has_permission_Board_add_users(self, user, perm, board):
    """Test if users is one of the board's managers, if he is he can add new user to the board"""
    return board.has_manager(user)

################


class Icon(object):

    def __init__(self, icon, title=None):
        """Create icon object

        In:
          - ``icon`` -- icon class name (use icomoon custom font)
          - ``title`` -- icon title (and alt)
        """
        self.icon = icon
        self.title = title

################


class BoardDescription(object):

    """Description component
    """

    def __init__(self, description):
        """Initialization

        In:
            - ``description`` -- callable that returns the description.
        """
        self.description = var.Var(description)

    def commit(self, comp):
        description = self.description().strip()
        if description:
            description = validator.clean_text(description)
        comp.answer(description)

    def cancel(self, comp):
        comp.answer(None)


class BoardMember(object):

    def __init__(self, user, board, role):
        self.user = component.Component(user)
        self.role = role
        self.board = board

    @property
    def username(self):
        return self.user().username

    @property
    def fullname(self):
        return self.user().fullname

    @property
    def email(self):
        return self.user().email

    @property
    def data(self):
        return self.user().data

    def dispatch(self, action, application_url):
        if action == 'remove':
            self.board.remove_board_member(self)
        elif action == 'toggle_role':
            self.board.change_role(self, 'manager' if self.role == 'member' else 'member')
        elif action == 'resend':
            self.board.resend_invitation(self, application_url)