#- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
# HubSessions © GeezTeem 2010› https://www.geezteem.com/253
# AGPL-3.0-or-later - https://www.gnu.org/licenses/agpl-3.0.txt

#- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
import os.path, logging, time
from DateTime import DateTime
from persistent.list import PersistentList

import appy
from appy.all import *
from appy.xml.cleaner import Cleaner
from appy.utils import executeCommand
from appy.test.monitoring import Monitoring
from appy.model.tool import Tool as BaseTool

import HubSessions
from . import utils
from .pod import Pods
from .Mail import Mail
from .Item import Item
from .utils import Vars
from .Place import Place
from .Facet import Facet
from .Advice import Advice
from .peer.base import Peer
from .item.laws import Laws
from .Meeting import Meeting
from .HsGroup import HsGroup
from .Revenue import Revenue
from .Dossier import Dossier
from .Category import Category
from .Division import Division
from .NightTask import NightTask
from .Committee import Committee
from .OrderType import OrderType
from .AnnexType import AnnexType
from .Page import Page as HsPage
from .peer.creator import Creator
from .utils.repair import Repairer
from .meeting.Invite import Invite
from .ThirdParty import ThirdParty
from .peer.listener import Listener
from .utils.appy0 import MapApplyer
from .utils.duration import Duration
from .MeetingType import MeetingType
from .User import User, ImportOptions
from .Group import Group as TechGroup
from .ItemTemplate import ItemTemplate
from .Remunerations import Remunerations
from .utils.adaptations import ModelHacker
from .utils.dataImporter import DataImporter
from .meeting.searches import Searches as SearchesM
from .utils.nightlife import NightLife, ThreadedLife

#- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
NO_GIT     = 'No information available. Git was not found.'
NO_DATA_PY = 'Empty HubSessions site: no data.py found.'
IT_REX_KO  = 'Italicize text error :: Functionality was disabled :: %s.'

#- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
class DeleteMeetingsOptions:
    '''Class allowing to define options for the "delete meetings" action'''

    @staticmethod
    def update(class_):
        '''Hide the title'''
        class_.fields['title'].show = False

    creators = True # Let anyone "create" options
    popup = ('550px', '300px')
    indexable = False

    # Must meetings *before* or *after* the following date be deleted ?
    beforeOrAfter = Select(validator=('before', 'after'), multiplicity=(1,1))
    date = Date(format=Date.WITH_HOUR, multiplicity=(1,1))
    removeRevenuesWithItems = Boolean()
    removeRevenuesWithoutItems = Boolean()
    removeItemsOutsideMeetings = Boolean()
    info = Info(focus=True, show='edit')

    def mustDelete(self, meeting):
        '''Must p_meeting be deleted ?'''
        if self.beforeOrAfter == 'before':
            r = meeting.date < self.date
        else:
            r = meeting.date > self.date
        return r

#- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
class ToolWorkflow:
    '''Workflow for the tool and some of its sub-objects'''

    # Roles
    ma  = 'Manager'
    o   = 'Owner'
    au  = 'Authenticated'

    # Groups of roles
    writers = (ma, o)
    all = writers + (au, 'Anonymous')

    # Specific permission "Write third party", used as specific write permission
    # for field Tool::thirdParties, is granted to any authenticated person,
    # which is too permissive. It is not a problem because it is used in
    # conjunction with method ThirdParty.creators that prevents any unauthorized
    # object creation.
    wtp = 'writeTP'

    # State
    active = State({r:all, w:writers, wtp:au, d:None}, initial=True)

#- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
class Tool:
    '''Configuration panel for HubSessions'''

    workflow = ToolWorkflow
    traverse = BaseTool.traverse

    # Allow to access some names from the tool
    Item = Item
    Mail = Mail
    Dossier = Dossier
    Meeting = Meeting
    Creator = Creator
    Listener = Listener

    isTool = True # This is the tool

    @staticmethod
    def update(class_):
        fields = class_.fields
        # Hide page "translations"
        fields['translations'].page.show = False
        # Configure ref "users"
        users = fields['users']
        users.iconOut = True
        users.iconCss = 'iconHSA'
        users.shownInfo = User.Columns.ref
        users.listCss = 'list clist'
        ul = users.layouts
        ul['edit'] = ul['view'] = Layout('l-f', css='topSpace')
        # Configure Ref "groups"
        groups = fields['groups']
        groups.shownInfo = TechGroup.Columns.base
        groups.iconOut = True
        groups.iconCss = 'iconHSA'
        groups.actionsDisplay = 'right'
        # Groups are for Managers only. It is not appropriate to show
        # "technical" groups to anyone else having access to the tool.
        groups.page.show=lambda o: 'view' if o.user.hasRole('Manager') else None
        # Configure Ref "pages"
        pages = fields['pages']
        pages.iconOut = True
        pages.iconCss = 'iconHSA'
        pages.actionsDisplay = 'right'
        pages.shownInfo = HsPage.Columns.base
        # Hide carousels and queries
        fields['carousels'].page.show = fields['queries'].page.show = False
        # Put action "repairDatabase" in page "database" in the admin zone
        fields['repairDatabase'].setPage(fields['databaseInfo'].page)
        # Make page "history" viewable to anyone having write access to the tool
        record = fields['record']
        record.show = lambda o: Show.VX if o.allows(w) else None
        record.page.show = lambda o: 'view' if o.allows(w) else None

    def initialiseHandler(self, handler):
        '''Cache some info on the p_handler'''
        handler.cache.minimalist = self.mode == 'minimalist'

    def _getContentLanguages(self):
        '''Some defauls values for content-related fields may need to be
           multilingual.'''
        return self.config.dataLanguages

    def _listWorkflowElements(self, class_, type, subset='all'):
        '''Lists the available transitions or states (depending on p_type) for
           this p_class_. A precise p_subset of these elements is given.'''
        r = []
        _ = self.translate
        workflow = class_.workflow
        name = class_.__name__
        states = type == 'states'
        className = f'{name}_{name}'
        workflowName = workflow.__name__
        for name in getattr(workflow, type)[subset]:
            element = getattr(workflow, name)
            # Check if the state has not been deactivated by some workflow
            # adaptation (= if, from this state, we can reach another state).
            if not states or not element.isIsolated():
                r.append((name, _(f'{workflowName}_{name}')))
        return r

    def listItemStates(self):
        '''Lists all item states'''
        return self._listWorkflowElements(Item, 'states')

    def listItemTransitions(self):
        '''Lists the "forward" transitions for items'''
        return self._listWorkflowElements(Item, 'transitions', subset='forward')

    def listMeetingStates(self):
        '''Lists all meeting states'''
        return self._listWorkflowElements(Meeting, 'states')

    def squareLabel(self, **kw):
        '''See called method's docstring'''
        return utils.squareLabel(self, **kw)

    #- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    #                              Home page
    #- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

    def getHomePage(self):
        '''Redirects any user to the list of available meetings, excepted if
           adaptation "no meeting" is active.'''
        base = self.url
        user = self.user
        config = self.config
        # For an anonymous user, go to the home page, excepted if site access is
        # granted to everyone.
        if not config.isPublic() and user.isAnon():
            return f'{base}/home' # The app home page
        # For a publisher, consult ongoing publications
        isPublisher = user.hasRole('PublicationManager', solely=True)
        if isPublisher:
            r = f'{base}/Search/results?className=Publication&search=ongoing'
        elif config.M.noMeeting in config.adaptations:
            suffix = 'view' if user.hasRole('Manager') \
                            else 'Search/results?className=Item&search='
            r = f'{base}/{suffix}'
        else:
            # Go to the default search for meetings
            pre = 'past' if SearchesM.defaultIsPast(self) else 'current'
            r = f'{base}/Search/results?className=Meeting&search={pre}Meetings'
        return r

    #- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    #            Visibility methods for protecting pages and fields
    #- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

    def toManagersView(self):
        '''Grant access to Managers only'''
        return 'view' if self.user.hasRole('Manager') else None

    def toWritersView(self):
        '''Grants access, in "view" mode, to some pages and/or fields, to anyone
           having write access to the tool.'''
        return 'view' if self.allows(w) else None

    def toWritersEdit(self):
        '''Grants access, in "view" as well as "edit" modes, to some pages
           and/or fields, to anyone write access to the tool.'''
        return True if self.allows(w) else None

    def showMonoEdit(self):
        '''Use this method to show a page on "view" and "edit" layouts, on a
           mono-committee site.'''
        if not self.config.committees.enabled:
            return self.toWritersEdit()

    def showMonoView(self):
        '''Use this method to show a page on "view" on a mono-committee site'''
        if not self.config.committees.enabled:
            return self.toWritersView()

    def showAction(self):
        '''Determine who can perform tool-rooted actions'''
        # Such actions can't currently be performed by transcribers or commons
        # managers.
        user = self.user
        config = self.config
        if config.M.meetingManagerHasTool in config.adaptations:
            may = user.isHsManager()
        else:
            may = user.hasRole('Manager')
        return 'buttons' if may else None

    #- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    #                          Users and groups
    #- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

    # Action for manipulating users by importing an ODS file
    importUsers = Action(options=ImportOptions, icon='HubSessions/import',
      page=Page('users', label='Tool_page_users'),
      show=lambda o: 'buttons' if o.user.hasRole('Manager') else None,
      action=lambda o, options: options.run())

    # This incremental number is used to generate automatic logins, ie, when
    # creating users from guests.
    lastLoginNumber = Integer(show='xml', default=0)

    def getIncrementalLogin(self, prefix='user'):
        '''Consume and return a new number for producing a new generated
           login.'''
        r = self.lastLoginNumber + 1
        self.lastLoginNumber = r
        return f'{prefix}{r}'

    # HubSessions groups - - - - - - - - - - - - - - - - - - - - - - - - - - - -

    back = {'show': False, 'layouts': 'f'}

    # 2 pages: one for HS groups in use, one for archived HS groups. Currently,
    #          on a multi-committee site, HS groups cannot be defined at the
    #          global, tool level. This may change: we could meet situations
    #          where it has sense to define groups, at the tool level, that can
    #          be implied in several or all committees.
    pageHs = Page('hsGroups', show=showMonoView, icon='groupsHS.svg')
    pageHsa = Page('archivedGroups', show=showMonoView, icon='groups.svg')

    # Calc exports
    groupsDocs = Pod(template=('pod/Groups.ods', 'pod/Members.ods'),
      context=Pods.getGroupsContext, templateName=Pods.getGroupTemplateName,
      layouts=Pod.Layouts.rt, page=pageHs, show=toWritersView)

    hsGroups = Ref(HsGroup, add=True, link=False, multiplicity=(0,None),
      composite=True, numbered=True, back=Ref(attribute='hstool', **back),
      showHeaders=True, shownInfo=HsGroup.listColumns, page=pageHs,
      xml=lambda o, value: HsGroup.asXml(value), queryable=True, iconOut=True,
      iconCss='iconHSA', layouts=Ref.Layouts.w)

    # Button for archiving HS groups
    def doArchiveGroups(self, unarchive=False):
        '''Removes any inactive HS group from "hsGroups" and add it to
           "archiveGroups", or performs the reverse operation if p_unarchive
           is True.'''
        count = 0 # Count the number of archived groups
        if unarchive:
            from_ = 'archivedGroups'; to = 'hsGroups'
        else:
            from_ = 'hsGroups'; to = 'archivedGroups'
        for hsGroup in getattr(self, from_):
            if hsGroup.state == 'inactive':
                self.relink(from_, to, hsGroup)
                count += 1
        msgLabel = 'hsgroups_transfered' if count else 'action_null'
        return True, self.translate(msgLabel, mapping={'nb': count})

    def showArchiveGroups(self):
        '''Show action "archive groups" only to HS managers, if at least one HS
           grooup is defined.'''
        if self.isEmpty('hsGroups'): return
        return self.showAction()

    archiveGroups = Action(action=doArchiveGroups, page=pageHs, confirm=True,
                           show=showArchiveGroups)

    # Button for synchronizing groups with a distant, authentic HS groups source

    def showSyncGroups(self):
        '''Show this action only if a "groups source" is defined and not
           dedicated to committees only.'''
        source = self.config.groupsSource
        if source and not source.forCommittees and self.user.isHsManager():
            return 'buttons'

    def doSyncGroups(self):
        '''Syncs with source groups'''
        source = self.config.groupsSource
        if not source: return
        # A commit is needed and is not automatic when this method is called
        # from a job.
        self.H().commit = True
        return True, source.synchronize(self)

    synchronizeGroups = Action(confirm=True, show=showSyncGroups, page=pageHs,
                            action=doSyncGroups, icon='HubSessions/synchronize')

    # Archived HS groups
    archivedGroups = Ref(HsGroup, add=False, link=False, multiplicity=(0,None),
      showHeaders=True, shownInfo=HsGroup.listColumns, page=pageHsa,
      delete=True, composite=True, numbered=True, queryable=True,
      xml=lambda o, value: HsGroup.asXml(value), iconOut=True,
      iconCss='iconHSA', back=Ref(attribute='toolb', **back))

    unarchiveGroups = Action(confirm=True, show=showAction,
      action=lambda self: self.doArchiveGroups(unarchive=True), page=pageHsa)

    def hasProducedGroupId(self, id):
        '''Returns True if the HS group p_id has been produced by the tool =
           without committee prefix.'''
        # If committees are disabled, any group is at the tool level
        return ('-' not in id) if self.config.committees.enabled else True

    # Meeting users  - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

    meetingUsers = Ref(User, add=False, link='popup', multiplicity=(0,None),
      back=Ref(attribute='inMeeting', show=False), numbered=True,
      afterUnlink=lambda o, user: MeetingType.unlinkUser(o, user),
      layouts=Layouts.dvt, actionsDisplay='right', showHeaders=True,
      iconOut=True, iconCss='iconHSA', shownInfo=User.Columns.refParticipants,
      page=Page('meetingUsers', show=showMonoView, icon='pageParticipants.svg'),
      select=Search(maxPerPage=20, sortBy='title', navAlign='center',
                    listCss='list clist', shownInfo=User.Columns.selectMulti),
      listCss='list clist', xml=lambda o, users: User.asXml(users))

    def getMeetingUsers(self, active=True, usage=None):
        '''Gets the meeting users matching parameters p_active and p_usage'''
        return utils.getSubObjects(self, 'meetingUsers', active, usage)

    # Third parties  - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    ptp = {'page': Page('thirdParties', show=toWritersView)}

    thirdParties = Ref(ThirdParty, add=True, link=False, multiplicity=(0, None),
      composite=True, back=Ref(attribute='tool0', **back), showHeaders=True,
      queryable=True, shownInfo=ThirdParty.listColumns, actionsDisplay='right',
      writePermission=ToolWorkflow.wtp, iconOut=True, iconCss='iconHSA',
      historized=True, **ptp)

    syncThirdParties = Action(action=ThirdParty.sync, show=ThirdParty.showSync,
                              confirm=True, **ptp)

    #- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    #                              Versions
    #- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

    def computeVersions(self):
        '''Displays version and status information about this HubSessions'''
        infos = Monitoring(forceComplete=True)
        # Determine paths to appy, HubSessions and its ext
        dirname = os.path.dirname
        paths = [('appy', dirname(appy.__file__)),
                 ('HubSessions', dirname(HubSessions.__file__)),
                ]
        ext = self.config.ext
        if ext:
            extModule = __import__(ext)
            paths.append((ext, dirname(extModule.__file__)))
        # Add information about HubSessions
        for name, path in paths:
            cmd = ['git', '-C', path, 'remote', '-v']
            try:
                out, err = executeCommand(cmd)
                output = (out or err).strip().replace('\n', '<br/>')
            except OSError as err:
                output = NO_GIT
            infos.app.append((name, output))
        return infos.get(self, html=True)

    versions = Computed(method=computeVersions,
      layouts=Layouts(view=Layout('l-d-f', css='topSpace')),
      page=Page('versions', phase='admin', show=toManagersView))

    #- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    #                              Parameters
    #- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

    # Meeting-related parameters - - - - - - - - - - - - - - - - - - - - - - - -

    do = {'page': Page('meetings', phase='parameters', show=showMonoEdit),
          'layouts': Layouts.gd,
          'group': Group('data', ['23%','77%'], hasLabel=False, style='grid',
                         css='topSpace')}

    # The "minimalist" mode allows to improve HubSessions' performance by
    # producing minimalist lists of items. Indeed, item queries are the most
    # computation-consuming pages to display in HubSessions.
    mode = Select(default='normal', validator=['normal', 'minimalist'], **do)
    version = String(**do)
    lastMeetingNumber = Integer(default=0, **do)
    lastItemNumber = Integer(default=0, **do)
    lastPubNumber = Integer(default=0,
                            show=lambda o: o.config.publications.enabled, **do)

    # Default duration for meeting items
    defaultItemDuration = Hour(default=Duration.itemDefault,
                               show=lambda o: Duration.isEnabled(o), **do)

    # Predefined places and pre-places for meetings
    del do['layouts']
    places = Text(show=Place.showTextBased, **do)
    prePlaces = Text(show=lambda o: Place.showTextBased(o, pre=True), **do)

    # Default values for "observation" Meeting fields
    preObservations = Rich(width='99%', languages=_getContentLanguages,
      show=lambda o: 'preObservations' not in o.config.unusedMeetingFields,
      **do)

    observations = Rich(width='99%', languages=_getContentLanguages, **do)

    moreObservations = Rich(width='99%', languages=_getContentLanguages,
      show=lambda o: 'moreObservations' not in o.config.unusedMeetingFields,
      **do)

    # Value determining the deadline, relative to some meeting date, for
    # publishing normal items for this meeting.
    do['layouts'] = Layouts.gd

    def showDefaultDeadlines(self):
        '''Show default deadlines only if deadlines are in use'''
        return 'deadlines' not in self.config.unusedMeetingFields

    publishDeadlineDefault = String(show=showDefaultDeadlines,
      default=lambda o: o.config.deltaPublishDeadline, **do)

    # Value determining the deadline, relative to some meeting date, for
    # publishing late items for this meeting.
    freezeDeadlineDefault = String(show=showDefaultDeadlines,
      default=lambda o: o.config.deltaFreezeDeadline, **do)

    # Value determining the pre-meeting date relative to a meeting date
    preDateDefault = String(default=lambda o: o.config.deltaPreDate,
                            show=lambda o: o.config.preMeetingUsed(), **do)

    # Default value for legacy Meeting fields "signatures", "assembly" and
    # "contact".
    del do['layouts']
    signatures = Text(
      show=lambda o: 'signatures' not in o.config.unusedMeetingFields, **do)
    assembly = Text(
      show=lambda o: 'assembly' not in o.config.unusedMeetingFields, **do)
    contact = Text(
      show=lambda o: 'contact' not in o.config.unusedMeetingFields, **do)

    # Place objects  - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

    # Instead of using meeting places from text field "places" and "prePlaces"
    # hereabove, it is possible to use HubSessions.Place.Place objects via the
    # following fields.

    pagePO = Page('places', phase='parameters', show=Place.showObjectBased)

    placesObjects = Ref(Place, back=Ref(attribute='toTool', show=False),
                        page=pagePO, **Committee.pob)

    prePlacesObjects = Ref(Place, back=Ref(attribute='toTool2', show=False),
                           show=Place.showObjectBasedPre, page=pagePO,
                           **Committee.pob)

    # Action allowing to delete meetings
    def doDeleteMeetings(self, options):
        '''Delete all meetings whose date is before or after some specified
           date, as determined by p_options.'''
        # Browse meetings in chronological order
        count = 0
        at = self.tool.formatDate(options.date, withHour=True)
        self.log('deleting meetings %s %s...' % (options.beforeOrAfter, at))
        for meeting in self.search('Meeting', sortBy='date'):
            # Must this meeting be deleted ?
            if options.mustDelete(meeting):
                meeting.doDeleteWithItems(say=False,
                  deleteTiedRevenues=options.removeRevenuesWithItems)
                count += 1
        self.log('%d meeting(s) deleted.' % count)
        msg = self.translate('meetings_deleted', mapping={'nb': count})
        self.say(msg)
        # Delete items outside meetings if requested
        if options.removeItemsOutsideMeetings:
            count = 0
            # Compute the states of items outside meetings
            states = Item.workflow.states['noMeeting']
            if not states: return # There is no item without meeting
            for item in self.search('Item', state=or_(states)):
                # Some states, like "validated" in the default Item workflow,
                # may correspond to items outside meetings as well as items
                # within meetings.
                if not item.isEmpty('meeting'): continue
                item.delete()
                count += 1
            self.log('%d item(s) deleted.' % count)
            msg = self.translate('items_deleted', mapping={'nb': count})
            self.say(msg)
        # Delete revenues without items if requested
        if options.removeRevenuesWithoutItems:
            count = 0
            for revenue in self.search('Revenue'):
                if revenue.isEmpty('item'):
                    revenue.delete()
                    count += 1
            self.log('%d revenue(s) deleted.' % count)
            msg = self.translate('revenues_deleted', mapping={'nb': count})
            self.say(msg)

    deleteMeetings = Action(action=doDeleteMeetings, page=do['page'],
      icon='deleteMany.svg', options=DeleteMeetingsOptions, show=showAction)

    # Mail configuration - - - - - - - - - - - - - - - - - - - - - - - - - - - -

    pm = {'page': Page('mail', phase='parameters', show=showMonoEdit),
          'group': Group('main', ['20%','80%'], style='grid', hasLabel=False,
                         css='topSpace')}

    # (de)activating mails is done via config.py. But when it is activated in
    # config.py, one may switch it off via field "mailEnabled" below in the ui
    # for temporarily disabling it.
    mailEnabled = Boolean(default=True, show=Mail.showEnabled,
                          layouts=Boolean.Layouts.d, **pm)

    # Activated item-related mail events
    pm['layouts'] = Layouts.gd
    mailItemEvents = Select(validator=Selection(Mail.listItemEvents),
                            multiplicity=(0,None), render='checkbox', **pm)

    # Activated meeting-related mail events
    mailMeetingEvents = Select(validator=Selection(Mail.listMeetingEvents),
                               multiplicity=(0,None), render='checkbox',
                               show=Mail.showMeetingEvents, **pm)

    # Regarding meeting-related events, among official assembly members
    # (substitutes included), must we email all these people, or only those
    # being actual participants to the meeting in question ?
    pm['layouts'] = Boolean.Layouts.gdl
    mailActualParticipants = Boolean(**pm)

    # Activated task-related mail events
    pm['layouts'] = Layouts.gd
    mailTaskEvents = Select(validator=Selection(Mail.listTaskEvents),
                            multiplicity=(0,None), render='checkbox',
                            show=Mail.showTaskEvents, **pm)

    # Activated revenue-related mail events
    mailRevenueEvents = Select(show=Mail.showRevenueEvents, render='checkbox',
      validator=Selection(Mail.listRevenueEvents), multiplicity=(0,None), **pm)

    # Activated dossier-related mail events
    mailDossierEvents = Select(show=Mail.showDossierEvents, render='checkbox',
      validator=Selection(Mail.listDossierEvents), multiplicity=(0,None), **pm)

    # ~~~ Post-scriptum for emails ~~~
    del pm['layouts']
    mailPostScriptum = Text(**pm)
    mailSeparator = String(layouts=Layouts.gd, **pm)

    # "reply-to" email address
    email = String(validator=String.EMAIL, layouts=Layouts.gd, **pm)

    # ~~~ Send a mail from HubSessions ~~~
    sendMailing = Action(action=lambda o, options: options.send(),
                         icon='HubSessions/mail', show=Mail.showSend,
                         options=Mail, page=pm['page'])

    # Invites · First invite · Mail subject and body
    inviteSubject = String(show=Invite.showMail, width=60, layouts=Layouts.gd,
                           default=lambda o: Invite.getSubject(o, 1), **pm)

    inviteBody = Text(show=Invite.showMail, width='40em', height=12,
                      default=lambda o: Invite.getBody(o, 1), **pm)

    # Invites · Second one (to substitutes) · Mail subject and body
    inviteSubject2 = String(show=Invite.showMail, width=60, layouts=Layouts.gd,
                            default=lambda o: Invite.getSubject(o, 2), **pm)

    inviteBody2 = Text(show=Invite.showMail, width='40em', height=12,
                       default=lambda o: Invite.getBody(o, 2), **pm)

    # Invites · Third one (to proxies) · Mail subject and body
    inviteSubject3 = String(show=Invite.showMail, width=60, layouts=Layouts.gd,
                            default=lambda o: Invite.getSubject(o, 3), **pm)

    inviteBody3 = Text(show=Invite.showMail, width='40em', height=12,
                       default=lambda o: Invite.getBody(o, 3), **pm)

    # The list of possible advice types
    def listAdviceTypes(self):
        '''Lists the activated advice types and their translations'''
        r = []
        for type in self.config.advices.activatedTypes:
            r.append((type, self.translate(f'advice_{type}')))
        return r

    # Organization using HubSessions - - - - - - - - - - - - - - - - - - - - - -

    po = {'page': Page('organization', phase='parameters', show=showMonoEdit),
          'group': Group('org', ['17%','83%'], style='grid', hasLabel=False,
                         css='topSpace'),
          'layouts': Layouts.gd, 'width': 70}

    warningOrg = Info(show='edit', focus=True, page=po['page'])
    siteName = String(**po)
    organization = String(multiplicity=(1,1), **po)
    department = String(**po)
    service = String(**po)

    # For the most important entity among organization, department and service,
    # the following fields store additional information.
    del po['width']

    # A short name
    entityShort = String(multiplicity=(1,1), width=15, **po)

    # An acronym, maybe used within references
    entityAcronym = String(width=4, **po)
    entityGender = Select(validator=Selection('tool:listGenders'), **po)
    meetingName = String(multiplicity=(1,1), width=70, **po)
    meetingNameGender = Select(multiplicity=(1,1),
                               validator=Selection('tool:listGenders'), **po)

    # Contact info
    po['width'] = 70
    contactOrg = String(**po)
    contactAddressLine1 = String(**po)
    contactAddressLine2 = String(**po)
    contactEmail = String(validator=String.EMAIL, **po)
    contactFax = String(**po)
    contactPhone = String(**po)
    contactPerson = String(**po)
    po['width'] = 4
    contactPersonInitials = String(**po)
    po['width'] = 70
    contactPersonPhone = String(**po)
    contactPersonEmail = String(validator=String.EMAIL, **po)

    # The format of the reference
    reference = String(**po)

    def listGenders(self):
        '''Return the genders: m/f'''
        r = []
        for gender in ('m', 'f'):
            r.append((gender, self.translate('gender_%s' % gender)))
        return r

    def getReference(self, meeting):
        '''Computes the committee reference for this p_meeting'''
        return Committee.computeReference(self, meeting)

    # The possible color defined for the entity
    del po['width']
    color = Color(**po)

    # Its logo
    logo = File(isImage=True, resize=True, width='250px', **po)

    # A user guide
    userGuide = File(**po)

    # User guide: news
    userGuideNews = File(**po)

    # A specific title for user guide news
    userGuideNewsTitle = String(**po)

    def getUserGuideNewsTitle(self):
        '''Returns the title for document "userGuideNews"'''
        return self.userGuideNewsTitle or self.translate('user_guide_news')

    # User guide for managers
    managerGuide = File(**po)

    # Managers guide: news
    managerGuideNews = File(**po)

    # A specific title for user guide news
    managerGuideNewsTitle = String(**po)

    def getManagerGuideNewsTitle(self):
        '''Returns the title for document "managerGuideNews"'''
        return self.managerGuideNewsTitle or \
               self.translate('manager_guide_news')

    # The welcome text, shown on the home page, even for unauthenticated users
    welcomeText = Rich(default=lambda o: o.config.welcomeText, **po)

    # POD templates configuration  - - - - - - - - - - - - - - - - - - - - - - -

    pp = {'page': Page('pods', phase='parameters', show=showMonoEdit),
          'group': Group('pods', ['17%','83%'], style='grid', hasLabel=False,
                         css='topSpace'),
          'layouts': List.Layouts.gd}

    # Parameters for every family of templates
    itemPodOptions = Dict(lambda o: Pods.list(o, Item.docs),
                          Pods.getOptionsSub(), **pp)

    meetingPodOptions = Dict(
      lambda o: Pods.list(o, (Meeting.docs, Meeting.spreadsheets)),
      Pods.getOptionsSub(),
      show=lambda o: o.config.M.noMeeting not in o.config.adaptations, **pp)

    dossierPodOptions = Dict(lambda o: Pods.list(o, Dossier.docs),
      Pods.getOptionsSub(), show=lambda o: o.config.dossiers.enabled, **pp)

    def listFonts(self): return Pods.listFonts(self)

    # Remunerations  - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

    # Parameters related to remunerations. Remunerations are based on the notion
    # of token: a token represents the presence of a participant, during a fixed
    # duration. For example, if a meeting lasts 2 hours, and the token duration
    # is a half hour, the participant will receive a remuneration corresponding
    # to 4 tokens.

    rsp = {'alignOnEdit': 'center'}
    remunFields = (
      ('enabled', Boolean(default=True)),
      # Remuneration amount for a single token
      ('amount', Float(**rsp)),
      # Coefficient that applies to the base amount, and may be updated on, ie,
      # a yearly basis.
      ('amountFactor', Float(**rsp)),
      # Number of hours a token represents. This is a float number: a half hour
      # is represented by 0.5.
      ('durationUnit', Float(**rsp)),
      # Normally, HubSessions computes the number of due tokens base on the
      # duration of a meeting. That being said, one may force the number of
      # granted tokens to some participant. The maximum number of forced granted
      # tokens is defined by the following field.
      ('maxForced', Integer(**rsp)),
      # Remuneration due for a single km for a route between the participant's
      # home and the meeting place.
      ('amountKm', Float(**rsp)),
      # May reading remunerations be due to participants ?
      ('reading', Boolean()),
    )

    remunParams = Dict(Remunerations.listParamKeys, remunFields,
      layouts=Layouts.dvt,
      page=Page('remunParams', phase='remunerations',
                show=Remunerations.showParams))

    # Signatories: remuneration's prereviewer and reviewer
    sp = {'page': Page('remunSigners', phase='remunerations',
                       show=Remunerations.showParams)}

    # Info about remunerations' signers
    infoRemunSigners = Info(layouts=Layouts(view=LayoutF('ld', css='topSpace')),
                            **sp)

    sp['group'] = Group('main', ['18%','82%'], style='grid',
                        hasLabel=False, css='topSpace')
    sp['layouts'] =  Layouts.gd

    remunPrereviewer = Ref(User, add=False, link='dropdown', render='links',
                           back=Ref(attribute='backPR', show=False), **sp)

    # This person's role in the context of the remunerations process 
    remunPrereviewerRole = String(**sp)

    remunReviewer = Ref(User, add=False, link='dropdown', render='links',
                        back=Ref(attribute='backRV', show=False), **sp)

    # This person's role in the context of the remunerations process 
    remunReviewerRole = String(**sp)

    # Batches of remunerations
    prem = {'page': Page('remunerations', phase='remunerations',
                         show=Remunerations.showPageInViewMode)}

    # Info explaining that this page is only available if remunerations are
    # configured with batches.
    layoutsIR = Layouts(view=LayoutF('ld=', css='topSpace focus'))

    infoRemunBatchOnly = Info(layouts=layoutsIR, show=Remunerations.showNoBatch,
                              **prem)

    remunerations = Ref(Remunerations, add=False, link=False, delete=True,
      insert='start', multiplicity=(0,None), layouts=Layouts.dvt,
      showHeaders=True, shownInfo=Remunerations.Columns.get, viaPopup=False,
      actionsDisplay='right', back=Ref(attribute='back', show=False), **prem)

    # Action for creating a (real or test) remuneration
    addRemuneration = Action(action=lambda o, options: options.add(),
      options=Remunerations, show=Remunerations.showBatchAction,
      icon='portletAdd.svg', **prem)

    # Store here, in a hidden field, the last dry-run Remunerations object
    lastRemun = Ref(Remunerations, add=False, link=True, show=False,
                    back=Ref(attribute='backH', show=False))

    prr = {'page': Page('remunReport', phase='remunerations',
                        show=Remunerations.showPageInViewMode)}

    # Info explaining that this page is only available if remunerations are
    # configured with batches.
    infoReportBatchOnly = Info(layouts=layoutsIR,show=Remunerations.showNoBatch,
                               label=(None, 'infoRemunBatchOnly'), **prr)

    # Last report concerning remunerations
    remunReport = Computed(method=Remunerations.getLastReport,
                           layouts=Layouts.dvt, **prr)

    # Recompute the last remuneration
    refreshLastRemun = Action(confirm=True, icon='refresh.svg',
      action=Remunerations.refreshLastReport, show=Remunerations.showLastAction,
      **prr)

    # Delete the last remuneration
    deleteLastRemun = Action(confirm=True, icon='deleteS.svg',
      action=Remunerations.deleteLastReport, show=Remunerations.showLastAction,
      **prr)

    # Turn the last remuneration to real
    convertLastRemun = Action(confirm=True, icon='confirm.svg',
      action=Remunerations.convertLastReport, show=Remunerations.showLastAction,
      **prr)

    # Mail notification : a remun is ready for you
    prm = {'page': Page('remunMails', phase='remunerations',
                        show=Remunerations.showPageInEditMode)}

    # Info explaining that this page is only available if remunerations are
    # configured with batches.
    infoMailBatchOnly = Info(layouts=layoutsIR, show=Remunerations.showNoBatch,
                             label=(None, 'infoRemunBatchOnly'), **prm)

    remunMailInfo = Info(layouts=Info.Layouts.dvt, **prm)

    prm['group'] =  Group('main', ['15%','85%'], style='grid', wide=True,
                          hasLabel=False, css='topSpace')

    remunMailSubject = String(default=Mail.Remun.defaultSubject,
                              width=70, **prm)
    remunMailBody = Text(default=Mail.Remun.defaultBody,
                         width=70, height=17, **prm)
    remunMailReplyTo = String(validator=String.EMAIL, layouts=Layouts.gd, **prm)

    # Comma-separated list of emails representing Managers being notified when a
    # payee triggers an action on a remuneration object.
    remunAdminMails = String(validator=Remunerations.validMails, width=70,
                             layouts=Layouts.gd, **prm)

    # Facets - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

    def showFacets(self, page=False):
        '''Show facets when enabled, to HS managers only'''
        if not self.config.facetsEnabled: return
        r = self.toWritersView()
        if not r: return
        return r if page else Show.VX

    facets = Ref(Facet, add=True, link=False, multiplicity=(0,None),
      insert='start', back=Ref(attribute='toTool', show=False),
      layouts=Ref.Layouts.wdt, show=showFacets, actionsDisplay='right',
      page=Page('facets', phase='parameters',
                show=lambda o: o.showFacets(page=True)),
      showHeaders=True, shownInfo=Facet.listColumns,
      iconOut=True, iconCss='iconHSA')

    def getActiveFacets(self): return Facet.getActive(self)

    # Recordings - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

    def showRecordingsPage(self):
        '''When recordings are in use, show a page containing fields and info
           about recordings and their possibly tied transcriptions.'''
        if self.config.recordings.enabled: return self.toWritersEdit()

    prec = {'page': Page('recordings', phase='parameters',
                         show=showRecordingsPage),
            'group': Group('recordings', ['20%','80%'], style='grid',
                           hasLabel=False, css='topSpace')}

    # (de)Activate the Javascript timers for automatically refreshing
    # recordings' panels on meeting and item views.
    activateRecTimers = Boolean(default=True, layouts=Boolean.Layouts.gdl,
                                **prec)

    traverse['switchRecTimers'] = 'Manager'
    def switchRecTimers(self):
        '''Switch the value for field p_self.activateRecTimers'''
        # Switch the value
        self.activateRecTimers = not self.activateRecTimers
        # A commit is required
        self.H().commit = True
        # Say the action has been done
        self.say(self.translate('action_done'))

    # The interval, in seconds, for the automatic refresh of recordings
    def getDefaultRecordingsInterval(self):
        '''Gets the default recordings interval from p_self.config.recordings'''
        recordings = self.config.recordings
        if recordings: return recordings.recordingsInterval

    def validInterval(self, interval):
        '''Ensure the specified p_interval is within authorized limits'''
        cfg = self.config.recordings
        valid = cfg.minInterval <= interval <= cfg.maxInterval
        if not valid:
            return self.translate('invalid_interval',
                       mapping={'min': cfg.minInterval, 'max': cfg.maxInterval})
        return True

    recordingsInterval = Integer(default=getDefaultRecordingsInterval,
      validator=validInterval, layouts=Layouts.gd, multiplicity=(1,1), **prec)

    # The interval, in seconds, for the automatic refresh of scans
    def showScans(self):
        '''Show scans-related stuff only if scans are in use'''
        recordings = self.config.recordings
        return recordings and recordings.scansRootPath

    def getDefaultScansInterval(self):
        '''Gets the default scans interval from p_self.config.recordings'''
        return self.config.recordings.scansInterval

    scansInterval = Integer(default=getDefaultScansInterval, show=showScans,
      validator=validInterval, layouts=Layouts.gd, multiplicity=(1,1), **prec)

    # Divisions  - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

    # A division is used to distribute labour between people managing a meeting
    # and its recordings.
    def showDivisionsPage(self):
        '''Show page "divisions" when divisions are in use, for Managers only'''
        # Because information about division of work is touchy, it is accessed
        # by Managers only, not HS managers.
        if self.config.divisionsEnabled and self.user.hasRole('Manager'):
            return True

    pdiv = {'page': Page('divisions', phase='parameters',
                         show=showDivisionsPage),
            'group': Group('division', ['21%','89%'], css='topSpace',
                           style='grid', hasLabel=False)}

    # Normally, the current division's date is "today". But we may force, for
    # testing purposes or for some other reason, to force the current division's
    # date to be at another date.

    def showDivisionDate(self):
        '''Do not show this date if empty, excepted on "edit", for
           encoding it.'''
        return 'edit' if self.isEmpty('divisionDate') else True

    divisionDate = Date(format=Date.WITHOUT_HOUR, layouts=Layouts.gd,
                        native=True, show=showDivisionDate, **pdiv)

    # When a person wants to join a division, HubSessions will scan for active
    # divisions around the current date (p_divisionDate or today). The scanned
    # range will be [current date - divPastDays, current date + divFutureDays].

    divPastDays = Integer(default=3, layouts=Layouts.gd,
      validator=lambda o, value: Integer.inRange(o, (1,30), value), **pdiv)

    divFutureDays = Integer(default=1, layouts=Layouts.gd,
      validator=lambda o, value: Integer.inRange(o, (1,20), value), **pdiv)

    def getDivisionsOrder(self, division):
        '''Divisions are sorted anti-chronologically'''
        return -division.date.millis()

    # The list of defined divisions
    del pdiv['group']

    divisions = Ref(Division, add=True, link=False, composite=True,
      multiplicity=(0, None), back=Ref(attribute='toTool', show=False),
      insert=getDivisionsOrder, layouts=Ref.Layouts.wdt, iconOut=True,
      iconCss='iconHSA', actionsDisplay='inline', showHeaders=True,
      shownInfo=Division.listColumns, changeOrder=False, **pdiv)

    def getActiveDivisions(self, asString=True):
        '''See docstring in HubSessions/Division.py::getActive'''
        return Division.getActive(self, asString=asString)

    def getDivisionByDate(self, date, isString=True):
        '''See docstring in HubSessions/Division.py::getByDate'''
        return Division.getByDate(self, date, isString=isString)

    #- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    #                            Classifications
    #- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

    # Item categories  - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

    pageCat = Page('categories', show=toWritersView, phase='classifications')
    cp = Committee.cp.copy()
    del cp['label']; del cp['creators']

    categories = Ref(Category, back=Ref(attribute='tool1', **back),
                     page=pageCat, **cp)

    # "natures" refers to a higher categorization level for an item
    natures = Ref(Category, back=Ref(attribute='tool1b', **back),
      page=Page('natures', show=toWritersView, phase='classifications'), **cp)

    # "itemTypes" refers to yet another categorization schema for items
    itemTypes = Ref(Category, back=Ref(attribute='tool1c', **back),
      page=Page('itemTypes', show=toWritersView, phase='classifications'), **cp)

    # "decisionTypes" allows to determine categories for qualifying the decision
    # on an item, giving more details than the item's state (accepted /
    # refused / etc).
    decisionTypes = Ref(Category, back=Ref(attribute='tool1d', **back),
      page=Page('decisionTypes', show=toWritersView, phase='classifications'),
      **cp)

    # Categories and natures, for dossiers
    def showCategoriesDossiers(self):
        '''Show dossier-related categories or natures if dossiers are enabled'''
        return self.toWritersView() if self.config.dossiers.enabled else None

    categoriesDossiers = Ref(Category, back=Ref(attribute='tool1e', **back),
      page=Page('categoriesD', show=showCategoriesDossiers,
                phase='classifications'), **cp)

    naturesDossiers = Ref(Category, back=Ref(attribute='tool1f', **back),
      page=Page('naturesD', show=showCategoriesDossiers,
                phase='classifications'), **cp)

    # "classifiers" is yet another categorization schema, involving a high
    # number of categories.
    del cp['actionsDisplay']
    classifiers = Ref(Category, back=Ref(attribute='tool2', **back),
      page=Page('classifiers', show=toWritersView, phase='classifications'),
      **cp)

    # Annex types  - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

    atp = Committee.atp.copy()
    del atp['label']; del atp['creators']
    atPage = Page('annexTypes', show=toWritersView, phase='classifications')

    # For items
    annexTypes = Ref(AnnexType, back=Ref(attribute='tool3', **back),
                     page=atPage, shownInfo=AnnexType.listColumns, **atp)

    # For revenues
    def showAnnexTypesRevenues(self):
        '''Annex types for revenues are in use only if revenues are'''
        if self.config.revenues.enabled: return Show.E_

    annexTypesRevenues = Ref(AnnexType, back=Ref(attribute='tool3a', **back),
                             show=showAnnexTypesRevenues, page=atPage,
                             shownInfo=AnnexType.listColumns, **atp)

    # For meetings
    annexTypesMeetings = Ref(AnnexType, back=Ref(attribute='tool3c', **back),
                             page=atPage, shownInfo=AnnexType.listColumns,
                             **atp)

    # For dossiers
    def showAnnexTypesDossiers(self):
        '''Annex types for dossiers are in use only if dossiers are'''
        if self.config.dossiers.enabled: return Show.E_

    annexTypesDossiers = Ref(AnnexType, back=Ref(attribute='tool3d', **back),
                             show=showAnnexTypesDossiers, page=atPage,
                             shownInfo=AnnexType.listColumns, **atp)

    # For tasks
    def showAnnexTypesTasks(self):
        '''Annex types for tasks are in use only if the complete tasks system is
           in use.'''
        if self.config.tasksSystem == 'complete': return Show.E_

    annexTypesTasks = Ref(AnnexType, back=Ref(attribute='tool3e', **back),
                          show=showAnnexTypesTasks, page=atPage,
                          shownInfo=AnnexType.listColumns, **atp)

    def getPodAsAnnex(self, o, name=None, template=None, format=None,
                      annexTypeId=None, title=None):
        '''Computes, on p_o, a pod result from pod field whose name is p_name
           (defined on p_o), with a given p_template (indeed, the field can be
           multi-template) and produces an annex from it, in some p_format and
           with this p_title. This annex has the type whose ID is
           p_annexTypeId.'''

        # If parameters p_name, p_template, p_format, p_annexTypeId and p_title
        # are not None, the annex is created as described hereabove and is added
        # among p_o's annexes.

        # Alternately, these parameters can be None and homonym parameters can
        # be found on the request object (this is the "request mode"). In this
        # case, instead of creating the annex and inserting it among p_o's
        # annexes, an object mimicking the annex is created and returned. This
        # allows a peer site to get a pod result as if it was an annex and
        # create or update the corresponding annex on his side.

        # If no annexTypeId is given, the first active one from the config is
        # used (or the first one suitable for transmission if we are in request
        # mode).

        # Are we in request mode ?
        requestMode = not name
        # Get the parameters
        req         = self.req
        name        = name or req.name
        template    = template or req.template
        format      = format or req.format
        annexTypeId = annexTypeId or req.annexTypeId
        title       = title or req.title
        # Get the annex type from its ID (if given), or a default one
        if annexTypeId:
            annexType = self.search1('AnnexType', annexTypeId=annexTypeId)
        else:
            # Get the first active annex type, or the first one that is suitable
            # for transmission if p_transmitOnly is True.
            fieldName = AnnexType.fieldsByContainer[o.class_.name]
            annexType = AnnexType.getActive(self, name=fieldName,
                                            transmitOnly=requestMode)[0]
        # Get the POD field and produce the result
        field = o.getField(name)
        now = DateTime()
        resultPod = field.getValue(o, template=template, format=format,
                                   secure=False)
        resultTitle = title or field.getTemplateName(o, template)
        if requestMode:
            # Create a fake object and return it
            creator = self.creator
            # Define a fake history entry for this object
            events = [O(transition='_init_', state='created', login=creator,
                        comment=None, date=now)]
            annexTypeId = f'{annexType.id}:{annexType.annexTypeId}'
            self.resp.setContentType('xml')
            return O(created=now, creator=creator, modified=now,
                     title=resultTitle, file=resultPod, annexType=annexTypeId,
                     record=events)
        # If we are here, we are not in request mode: create the annex
        o.create('annexes', title=resultTitle, file=resultPod,
                 annexType=annexType)
        o.H().commit = True

    # Meeting types  - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

    meetingTypes = Ref(MeetingType, back=Ref(attribute='tool3b', **back),
      page=Page('meetingTypes', show=toWritersView, phase='classifications'),
      shownInfo=MeetingType.listColumns, **atp)

    # Order types  - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    def showOrderTypes(self):
        '''Show order types only if orders are enabled'''
        if self.config.ordersEnabled: return self.toWritersView()

    orderTypes = Ref(OrderType, back=Ref(attribute='tool4', **back),
      page=Page('orderTypes', show=showOrderTypes, phase='classifications'),
      shownInfo=OrderType.listColumns, **atp)

    #- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    #                                 Data
    #- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

    # Night tasks & life - - - - - - - - - - - - - - - - - - - - - - - - - - - -

    np = {'page': Page('night', phase='data', show=NightTask.show)}

    nightTasks = Ref(NightTask, add=True, link=False, multiplicity=(0,None),
      layouts=Ref.Layouts.wdt, iconOut=True, iconCss='iconHSA',
      showHeaders=True, shownInfo=NightTask.listColumns,
      back=Ref(attribute='toTool', show=False), **np)

    # Actions allowing to trigger nightlife/threadedlife activities from the UI

    nightlifeAction = Action(show=showAction, confirm=True,
      action=lambda o: (True, NightLife(o, fromUi=True).trigger()),  **np)

    def nightlife(self):
        '''HubSessions's nightlife executed from the night script'''
        return NightLife(self).trigger()

    threadedlifeAction = Action(show=showAction, confirm=True,
      action=lambda o: (True, ThreadedLife(o, fromUi=True).trigger()), **np)

    def threadedlife(self):
        '''HubSessions's threadedlife executed from a threaded job'''
        return ThreadedLife(self).trigger()

    # Item templates - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

    del atp['actionsDisplay']
    pageIT = Page('itemTemplates', show=toWritersView, phase='data')

    itemTemplates = Ref(ItemTemplate, back=Ref(attribute='tool5', **back),
      page=pageIT, shownInfo=ItemTemplate.Columns.base, **atp)

    updateItemTemplates = Action(action=ItemTemplate.updateOn, confirm=True,
                                 page=pageIT, show=showAction,
                                 icon='refresh.svg')

    # "articles" is a categorization scheme having, unlike previous ones,
    # precise semantics: in this scheme, every category represents a budget
    # article.
    pageArt = Page('articles', show=toWritersView, phase='data')

    articles = Ref(Category, back=Ref(attribute='tool2b', **back),
                   queryFields=Category.searchArticleFields, page=pageArt,
                   shownInfo=Category.Columns.refArticles,
                   actionsDisplay='right', **atp)

    def getActiveArticles(self):
        return [c for c in self.articles if c.state == 'active']

    def getAvailableArticles(self):
        '''Mimics HubBudget method of the same name'''
        return [O(identifier=a.categoryId, title=a.title, isExpense=a.isExpense,
                  isOrdinary=a.isOrdinary, balance=a.balance,
                  allowedGroups=[g.id for g in a.hsGroups]) \
                  for a in self.getActiveArticles()]

    def getBudgetArticles(self, name, restrictHsGroups=True):
        '''Gets all the active budget articles representing expenses/revenues
           (depending on p_name) and that can be used by the current user
           according to its HS groups (if p_restrictHsGroups is True).'''
        # Build search parameters
        params = {'isExpense': name == 'expenses', 'state': 'active',
                  'cid': '1_tool2b', 'navAlign': 'center'}
        if restrictHsGroups:
            params['hsGroups'] = self.user.getHsGroupsQuery()
        # Create and return the Search instance
        _ = self.translate
        label = 'search_articles'
        return Search(maxPerPage=25, sortBy='categoryId', translated=_(label),
                      translatedDescr=_(f'{label}_descr'), **params)

    def replaceDuplicateInItem(self, item, articles):
        '''Replace any duplicate article mentioned in this p_item by a
           non-duplicate.'''
        # Returns True if the item was modified
        r = False
        # Scan expenses and revenues
        attributes = ('expenses', 'revenues')
        for attribute in attributes:
            value = getattr(item, attribute)
            if not value: continue
            # Browse rows of data within persistent list v_value
            i = 0
            while i < len(value):
                row = value[i]
                if row.article:
                    art = row.article[0]
                    artId = art.categoryId
                    if artId in articles:
                        replacement = articles[artId][0]
                        if replacement != art:
                            self.log('Item %d::%s: replacing article "%d:%s" ' \
                                     'with "%d:%s".' % (item.id, attribute,
                                                        art.id, art.title,
                                                        replacement.id,
                                                        replacement.title))
                            # Replacing with the first elem of double articles
                            new = PersistentList([replacement])
                            value[i] = row.clone(article=new)
                            r = True # A change occurred on p_item
                i += 1
        return r

    def replaceDuplicateInRevenue(self, revenue, articles):
        '''Replace any duplicate article mentioned in this p_revenue by a
           non-duplicate.'''
        # This method returns True if the revenue was modified
        art = revenue.article
        if not art: return
        artId = art.categoryId
        if artId not in articles: return
        replacement = articles[artId][0]
        if replacement == art: return
        # This revenue uses a duplicate article
        self.log('Revenue %d: replacing article "%d:%s" with "%d:%s".' % \
                 (revenue.id, art.id, art.title,
                  replacement.id, replacement.title))
        revenue.article = replacement
        return True

    def getDuplicateArticles(self):
        '''Create and return duplicate articles'''
        r, arts = {}, {}
        for a in self.articles:
            identifier = a.categoryId
            if identifier not in r:
                r[identifier] = a
            else:
                if identifier not in arts:
                    arts[identifier] = [r[identifier]]
                arts[identifier].append(a)
        return arts

    def removeDuplicateArticles(self):
        '''Will replace all duplicate articles in expenses by the first instance
           of it, then delete all other instances.'''
        articles = self.getDuplicateArticles()
        toDelete = [a[1:][0] for a in articles.values()]
        if not toDelete:
            self.log('No duplicate article was found.')
            return
        # Scan objects possibly tied to duplicate articles
        for className in ('Item', 'Revenue'):
            count = 0
            self.log('Scanning all %ss...' % className.lower())
            for o in self.search(className):
                count += 1
                method = getattr(self, 'replaceDuplicateIn%s' % className)
                method(o, articles)
            self.log('%d objects scanned.' % count)
        # Delete duplicate revenues
        self.log('Deleting duplicate articles...')
        count = len(toDelete)
        for article in toDelete:
            article.delete()
        self.log('%d articles deleted.' % count)

    # Action for synchronizing articles from a distant HubBudget app with
    # HubSessions' local copies stored in tool.articles.
    def showSyncArticles(self):
        '''Show this action if a distant HubBudget app is configured'''
        if self.config.budget: return self.showAction()

    synchronizeArticles = Action(page=pageArt, show=showSyncArticles,
      action=lambda o: o.config.budget.synchronizeArticles(o), confirm=True)

    # "laws" is another categorization scheme having a precise semantics: a
    # "law" is a legal base for an item's decision.
    atp['queryFields'] = ('searchable', 'categoryId') # Restricted search fields
    atp['shownInfo'] = Category.Columns.refWithDescr

    pageLaws = Page('laws', show=Laws.showPage, phase='data')

    laws = Ref(Category, back=Ref(attribute='tool2c', **back), page=pageLaws,
               **atp)

    # "talk events" is a categorization scheme with precise semantics. Unlike
    # all the other schemes hereabove, it does not apply to items, but to rows
    # within field Item.talk, to indicate a type of event occurring during a
    # discussion.

    def talkEnabled(self):
        '''Defines visibility for pages related to functionality tied to
           field Item::talk.'''
        if 'talk' in self.config.unusedItemFields: return
        return self.toWritersView()

    pageTalk = Page('talk', show=talkEnabled, phase='data')

    talkEvents = Ref(Category, back=Ref(attribute='tool2d', **back),
                     page=pageTalk, actionsDisplay='right', **atp)

    def listTalkEvents(self):
        '''Returns the active talk events'''
        # Cache the result on the request to avoid calling this method for every
        # row in fields Item[Template].talk.
        cache = self.cache
        if 'talkEvents' in cache: return cache.talkEvents
        # Compute and return it
        r = cache.talkEvents = [cat for cat in self.talkEvents \
                                if cat.state == 'active']
        return r

    # "sentences" are predefined sentences that can be inserted into fields
    # Item::talk::speech.
    sentences = Ref(Category, back=Ref(attribute='tool2f', **back),
      page=Page('sentences', show=talkEnabled, phase='data'), **atp)

    # "acronyms" is another categorization scheme with precise semantics. An
    # acronym is a term mentioned in items of some meeting. The acronym must be
    # precisely spelled and has a specific meaning, stored in the category
    # description.

    pageAcronyms = Page('acronyms', show=talkEnabled, phase='data')

    acronyms = Ref(Category, back=Ref(attribute='tool2e', **back),
                   page=pageAcronyms, listCss='list clist',
                   actionsDisplay='right', **atp)

    # "italics", yet another categorization scheme with precise semantics,
    # defines words that must be italicized in item::talk::speech fields.

    def showPageItalics(self):
        '''Page "italics" can't be used when multiple data languages are in
           use.'''
        if len(self.config.configLanguages) != 1: return
        return self.talkEnabled()

    pageItalics = Page('italics', show=showPageItalics, phase='data')

    italics = Ref(Category, back=Ref(attribute='tool2g', **back),
                  page=pageItalics, listCss='list clist',
                  actionsDisplay='right', **atp)

    def getItalicsRex(self):
        '''Returns a regular expression representing all words to italicize'''
        if self.isEmpty('italics'): return
        # Get the cached version, when present
        cache = self.cache
        if 'itRegex' in cache: return cache.itRegex
        r = set()
        for it in self.italics:
            r.add(it.title)
            descr = it.description
            if descr:
                for word in descr.split(bn):
                    word = word.strip()
                    if word:
                        r.add(word)
        try:
            r = Cleaner.getItalicizeRex(r)
        except Exception as err:
            # Take no risk: the process of italicizing text, including computing
            # this regex, occurs while saving an item. To avoid any loss of
            # data, catch any exception here.
            self.log(IT_REX_KO % str(err), type='warning')
            return
        # Return and cache the regex
        cache.itRegex = r
        return r

    # Committees - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

    def showCommittees(self):
        '''Show committees to HS managers if enabled'''
        if self.config.committees.enabled and self.user.hasRole('Manager'):
            return 'view'

    committees = Ref(Committee, add=True, link=False, composite=True,
      multiplicity=(0, None), back=Ref(attribute='toTool', show=False),
      layouts=Ref.Layouts.wdt, actionsDisplay='right', showHeaders=True,
      shownInfo=Committee.listColumns, iconOut=True, iconCss='iconHSA',
      page=Page('committees', phase='data', show=showCommittees))

    def getActiveCommittees(self):
        '''Get the list of active committees on this HubSessions'''
        return [com for com in self.committees if com.state != 'inactive']

    def listActiveCommittees(self, ignore=None):
        '''Returns active committees as a list of (s_ididentier, s_title)
           tuples.'''
        r = []
        for com in self.committees:
            # Ignnore inactive or to p_ignore (being a committee identifier)
            if com.state == 'inactive' or (ignore and com.identifier == ignore):
                continue
            r.append((com.identifier, com.getShownValue()))
        return r

    def getSelectedCommittee(self, orTool=False):
        '''Returns the current committee as set in the authentication context,
           or None if no committee is set.'''
        # This method can't be defined as classmethod Committee.getSelected: it
        # would lead to circular imports while using it in classes like
        # Category, MeetingType, etc.
        if self.config.committees.enabled:
            r = Committee.get(self, requestOnly=True, orTool=False)
        else:
            r = None
        if not r and orTool:
            r = self
        return r

    # Migration from Appy 0  - - - - - - - - - - - - - - - - - - - - - - - - - -

    def updateIDs(self, field, mapFile, removeNotFound):
        '''Updates, for all items, field Item::sourceUrl or Item::targetUrl
           (depending on p_field) based on a "id0 > id1" mapping as marshalled
           in a file named p_mapFile.'''
        MapApplyer(field, mapFile, removeNotFound).run(self)

    #- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    #                           Repair the dabase
    #- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

    repairDatabase = Action(action=Repairer.run, confirm=True, show='buttons')

    #- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    #                             Main methods
    #- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

    def restrictedGrantableRoles(self):
        '''HubSessions-specific method selecting roles that one may assign
           globally to a user.'''
        # If committees are enabled, no role, Manager and CommonsManager
        # excepted, may be assigned globally. Indeed, all other standard roles
        # become local to the committee. There is nevertheless one exception: an
        # ext may specify always-global roles (even if committees are enabled),
        # via config attribute config.model.alwaysGrantableRoles.
        config = self.config
        if config.committees.enabled:
            # Bypass the Appy standard logic and build our own list of grantable
            # roles.
            forced = config.model.alwaysGrantableRoles
        else:
            forced = None
        return self.model.getGrantableRoles(self, checkManager=True,
                                            forced=forced)

    def getPluginFolder(self):
        '''Gets the disk folder containing the HubSessions plug-in'''
        return os.path.join(os.path.dirname(self.getDiskFolder()),
                            self.config.ext)

    def validate(self, new, errors):
        '''Inter-field validation method'''
        page = self.req.page or 'main'
        # Remember the current values for mail*Events fields
        if page == 'mail': Mail.rememberPreviousValues(self)
        # Validate POD options
        elif page == 'pods': Pods.validateOptions(self, new, errors)
        # Validate parameters related to remunerations
        elif page == 'remunerations':
            Remunerations.validateParams(self, new, errors)

    def showPortletAt(self, url):
        '''Don't show the portlet for anonymous users, excepted for a public
           site.'''
        if self.config.isPublic(): return True
        return not self.user.isAnon()

    def cerbereIsEnabled(self):
        '''Return True if the Cerbere SSO plug-in is enabled'''
        config = self.config.security.sso
        return config and hasattr(config, 'cerbere')

    def syncLdapUsers(self):
        '''Synchronizes users from an external LDAP source, when relevant'''
        # A LdapConfig must exist and be enabled
        ldapConfig, ssoConfig = self.config.security.getLdap()
        if ldapConfig and ldapConfig.enabled and not self.cerbereIsEnabled():
            # If the Cerbere plug-in is enabled, the standard LDAP sync must not
            # be performed: the Cerbere plug-in has its own sync code.
            ldapConfig.init(self)
            return ldapConfig.synchronizeUsers(self)

    def getDataModule(self):
        '''Return, if it exists, the 'data' module from the HubSessions ext'''
        ext = self.config.model.extPath
        if not ext: return
        dataPy = ext / 'data.py'
        if not dataPy.is_file(): return
        return __import__(f'{ext.stem}.data').data

    def onInstall(self):
        '''Installation method'''
        # Versions for CSS and JS files, used to force reload by the browser
        config = self.config
        versions = config.server.static.versions
        versions['hubsessions.js'] = 9
        versions['hubsessions.css'] = 58
        # Apply adaptations
        ModelHacker(self).run()
        # Tell what peer applications have been defined
        Peer.logDefined(self)
        empty = self.isEmpty
        if empty('annexTypes') and empty('committees') and empty('hsGroups'):
            # The database is considered being empty. Import data from a
            # "data.py" file if found in an ext.
            data = self.getDataModule()
            if data:
                DataImporter(self, data).run()
            else:
                self.log(NO_DATA_PY, type='warning')
            # Import LDAP users if a LdapConfig is found and enabled
            self.syncLdapUsers()
        # Import data from peer HubSessions sites as committees when relevant
        if config.committees.enabled and config.peersAsCommittees:
            for data in config.peersAsCommittees:
                data.peer.importAsCommittee(self, data)
        # Ensure default budget articles exist
        self.config.budgetC.initialise(self)
        # Apply slaveries when defined
        for slavery in config.slaveries: slavery.apply(self)
        # Instantiate a profiler when required
        if config.profilerEnabled:
            from appy.test.profiler import Profiler
            config.profiler = Profiler(self)
        # When committees are enabled, some global roles become local. Ensure
        # Appy considers the correct "globality" for these roles.
        isLocal = bool(config.committees.enabled)
        for role in self.model.grantableRoles:
            if role.name in Vars.unglobalizableRoles:
                role.local = isLocal
        # If a mirror is there, check its config and log its status
        if config.mirror.enabled: config.mirror.check(self)
        # The Cerbere SSO plug-in has its own LDAP sync, launched during the
        # nightlife. Disable the standard one if this plugin is enabled.
        if self.cerbereIsEnabled():
            self.class_.fields['synchronizeExternalUsers'].show = False
        # Potentially change the app name within i18n files
        self.setAppName()

    def setAppName(self):
        '''Change the app name within i18n files, if this site is an archive
           site.'''
        site = self.config.productionSite
        if not site or not site.myName: return
        for tr in self.translations:
            tr.app_name = site.myName

    def onEdit(self, created):
        page = self.req.page or 'main'
        if page == 'mail' and Mail.hasPreviousValues(self):
            Mail.propagateEventChanges(self)
#- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
