#- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
# © Hub People 2024› https://www.hubpeople.be · https://www.geezteem.com/455
# AGPL-3.0-or-later - https://www.gnu.org/licenses/agpl-3.0.txt

#- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
import sys

from DateTime import DateTime

from appy.all import *
from appy.utils import dates as dutils
from appy.model.fields.calendar import Calendar

from . import Utils
from .User import User
from .timetracker.Slice import Slice
from .timetracker.Quotas import Quotas
from .timetracker.Timespan import Timespan
from .timetracker.Contract import Contract
from .timetracker.timeData import TimeData
from .timetracker.TimespanTemplate import TimespanTemplate

#- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
UDATA_ACT  = f'%s :: %s.'

#- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
class UserDataWorkflow:
    '''Workflow for UserData objects'''

    # ~~~ Roles ~~~
    ma = 'Manager'
    ow = Role('Owner', local=True)
    # Consult Utils.py for an explanation about these local roles
    ga = Role('GroupAdmin'      , local=True)
    gm = Role('GroupManager'    , local=True)
    ge = Role('GroupEditor'     , local=True)
    gw = Role('GroupWriter'     , local=True)
    gr = Role('GroupReader'     , local=True)
    gt = Role('GroupTimetracker', local=True)

    # ~~~ Specific permissions ~~~
    wt = 'Write timetracker'

    # ~~~ Groups of roles ~~~
    managers = ma, ga, gt
    editors  = ma, ow, ga, gt
    all      = ma, ow, ga, gm, ge, gw, gr, gt

    # ~~~ States ~~~
    active = State({r:all, w:editors, wt:managers, d:managers}, initial=True)
    inactive = State({r:all, w:managers, wt:managers, d:ma})

    # ~~~ Transitions ~~~
    tp = {'condition': managers, 'confirm': True}
    deactivate = Transition( (active, inactive), **tp)
    reactivate = Transition( (inactive, active), **tp)

#- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
class UserData:
    '''Data related to a user in the context of a given group'''

    # A UserData object is therefore a kind of association class between a user
    # and a group. The Ref to the user is below. The Ref to the group is defined
    # on the group. Currently, such data is 100% related to the timetracker.

    workflow = UserDataWorkflow

    # CSS classes
    styles = {'title': 'titlet'}

    @staticmethod
    def update(class_):
        '''Configures the title'''
        title = class_.fields['title']
        title.show = Show.VE_
        title.page.show = lambda o: True if o.allows('Write timetracker') \
                                         else 'view'

    mainGroup = Group('main', ['200em', ''], style='grid', hasLabel=False)
    p = {'group': mainGroup}
    listColumns = ('title', 'state*100px|')

    # Let anyone create UserData instances, because the write permission on
    # HsGroup.data will block anyone excepted authorized people.
    creators = ['Authenticated']

    def validUser(self, value):
        '''Ensure, when creating a new UserData object, that such an object does
           not exist yet for the chosen user (p_value).'''
        # Execute this only when creating a new UserData instance
        if not self.isTemp(): return True
        user = value[0]
        # Prevent a special user to be chosen
        if user.login in ('anon', 'system'):
            return self.translate('unselectable_user')
        for data in self.container.data:
            if user == data.tuser:
                return self.translate('user_data_duplicate')
        return True

    tuser = Ref(User, add=False, link='popup', render='links',
                back=Ref(attribute='data', page='data', multiplicity=(1,None),
                         showHeaders=True, shownInfo=listColumns,
                         showActions=False),
                show=lambda o: True if o.isTemp() else Show.E_,
                multiplicity=(1,1), select=User.popupSearch,
                validator=validUser, **p)

    # It is required to get the user login directly from a UserData object (ie,
    # from the local roles machinery).
    def computeLogin(self):
        '''Returns the login of the tied user'''
        user = self.tuser
        return user.login if user else '?'

    login = Computed(method=computeLogin, show=False, layouts='f')

    #- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    #                            Main fields
    #- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

    # Is this user timetracked in this group ? For the moment, because user data
    # is only used for timetracker-related data, it has no sense to define a
    # UserData object for a user not being timetracked. Consequently, field
    # "timetracked" below is currently hidden.

    timetracked = Boolean(layouts=Boolean.Layouts.gd, default=True, show='xml',
                          **p)
    comment = Text(Layouts.gd, **p)

    #- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    #                             Timespans
    #- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

    def showTimetrackerPage(self, adminOnly=False):
        '''Show any timetracker-related page only if the related user is
           timetracked in this group and if the logged user is allowed to
           consult it.'''
        # Do not show anything while the object is under creation or if the user
        # is not timetracked.
        if self.isTemp() or not self.timetracked:
            return
        # Do not show the page if the logged user is not allowed to consult it
        condition = self.user.isAdminOn(self) if adminOnly \
                                              else self.grants('view')
        return 'view' if condition else None

    pts = {'page': Page('timespans', show=showTimetrackerPage)}

    # The list of timespans for this user in this group, sorted
    # antichronologically.
    def mayAddTimespan(self):
        '''Timespans may be added by authorized users only, if p_self is active
           and if at least one contract is defined.'''
        return not self.isEmpty('contracts') and self.state == 'active' and \
               self.grants('edit')

    # Total rows for timespan searches defined in Ref timespans::searches
    timespanTotals = [Search.Totals('T', 'total', ('duration',), 'title',
                                    Timespan.sumDuration)]

    def getTimespanSearches(self):
        '''Returns timespan searches for the last few months'''
        # Start with the next month
        date = dutils.Month.getSibling(DateTime(), next=True)
        # Get the currrent period: the corresponding search must be the default
        curYear, curMonth = Utils.getCurrentPeriod()
        curWalked = False # True if the current period has been walked
        r = {}
        for i in range(19): # 1.5 year
            if i != 0:
                # Get the previous month
                date = dutils.Month.getSibling(date, next=False)
            interval = dutils.Month.getInterval(date, hour='23:59')
            key = date.strftime('%Y%m')
            if not curWalked and date.year() == curYear and \
               date.month() == curMonth:
                default = curWalked = True
            else:
                default = False
            # Borrow some parameters from the Timespan advanced search instance
            adv = Timespan.searchAdvanced
            r[key] = Search(start=in_(*interval), maxPerPage=100,
                            sortBy=adv.sortBy, sortOrder=adv.sortOrder,
                            default=default, translated=date.strftime('%m/%Y'),
                            checkboxes=adv.checkboxes, actions=adv.actions,
                            totalRows=UserData.timespanTotals)
        return r

    timespans = Ref(Timespan, multiplicity=(0, None), composite=True,
      add=mayAddTimespan, link=False, queryable=True, changeOrder=False,
      back=Ref(attribute='data', show=False, layouts='f'),
      showHeaders=True, shownInfo=Timespan.listColumns,
      insert=lambda o,ts:-ts.start.millis(),
      searches=getTimespanSearches, **pts)

    def getTimespansAt(self, period):
        '''Returns a dict of all the timespans for this user in this group, at
           this p_period (=one month), keyed by day number.'''
        # p_period is of the form "YYYY/mm"
        r = {}
        interval = dutils.Month.getInterval(DateTime(f'{period}/01'),
                                            hour='23:59')
        for timespan in self.search('Timespan', start=in_(*interval), \
                                    cid=f'{self.iid}_data', state='validated'):
            day = timespan.start.day()
            if day in r:
                r[day].append(timespan)
            else:
                r[day] = [timespan]
        return r

    def getTimespansAtDay(self, day=None, created=True):
        '''Returns the timespans already created, at this p_day (or today if
           p_day is None) for this user and group.'''
        # The value, for a specific combination of args, may be cached (for not
        # created timespans @today).
        cache = self.cache
        if not created and day is None and 'timespansToday' in cache:
            return cache.timespansToday
        # Get the timespans for today and possibly the day before (the user can
        # have started to work yesterday in the evening).
        at = (day or DateTime()).strftime('%Y/%m/%d')
        interval = DateTime(f'{at} 00:00') - 1, DateTime(f'{at} 23:59')
        r = self.search('Timespan',
          start=in_(*interval), sortBy='start', sortOrder='desc',
          state='created' if created else or_('proposed', 'validated'),
          cid=f'{self.iid}_data')
        # Remove timespans from yesterday that do not overflow on today
        i = len(r) - 1
        while i >= 0:
            ts = r[i]
            if ts.start.day() != interval[1].day():
                # This is a timespan from yesterday
                if not ts.endsNextDay():
                    del r[i]
            i -= 1
        # Cache the result when appropriate and return it
        if not created and day is None:
            cache.timespansToday = r
        return r

    # A message if no timespan can be created because no contract is defined
    infoTimespans = Info(focus=True,
      show=lambda o: 'view' if o.isEmpty('contracts') else None, **pts)

    #- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    #                               Contracts
    #- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

    # 2 Refs store contracts
    contractFields = ('contracts', 'pastContracts')

    def getContractOrder(self, contract):
        '''Gets the order for this contract, allowing to sort contracts
           antichronologically in Refs "contracts" and "pastContracts".'''
        # A contract with no end date will come first
        end = contract.end
        end = -end.millis() if end else -sys.maxsize
        return end, -contract.start.millis()

    pc = {'link': False, 'multiplicity': (0,None), 'composite': True,
          'showHeaders': True, 'shownInfo': Contract.listColumns,
          'page': Page('contracts', show=showTimetrackerPage),
          'insert': getContractOrder, 'changeOrder': False,
          'layouts': Ref.Layouts.wdb}

    # The current contracts for this user in this group
    contracts = Ref(Contract,
      add=lambda o: o.state == 'active' and o.grants('editC'),
      actionsDisplay='inline', writePermission=UserDataWorkflow.wt,
      back=Ref(attribute='data', show=False), noObjectLabel='no_contract', **pc)

    def getContractsInterval(self):
        '''Gets the date interval, as a tuple
                        (DateTime_start, DateTime_end|None)
           representing all active contracts for this user in this group.'''
        if self.isEmpty('contracts'): return None, None
        start = end = None
        # Browse active contracts
        for contract in self.contracts:
            cstart = contract.start
            cend = contract.end
            if start is None:
                # This is the first encountered contract
                start = cstart
                end = cend
            else:
                # Adapt start
                if cstart < start:
                    start = cstart
                # Adapt end, excepted if None, which means that there is not a
                # defined end, so the contract lasts forever: we have already
                # reached the maximum possible "date".
                if end is not None:
                    if cend is None or cend > end:
                        end = cend
        # Convert end date to UTC to avoid problems
        if end is not None: end = dutils.Date.toUTC(end)
        return start, end

    # Past contracts
    pastContracts = Ref(Contract, add=False, delete=True,
                        back=Ref(attribute='pdata', show=False), **pc)

    def userWorksBetween(self, start, end):
        '''Does the user, in this data set, have at least one contract whose
           period intersects interval [start, end] ?'''
        # Check first among current contracts, then in past contracts
        for name in UserData.contractFields:
            for contract in getattr(self, name):
                if dutils.Date.periodsIntersect(start, end, contract.start,
                                                contract.end):
                    return True

    def getContracts(self, start, end, formatted=False, schedule=None):
        '''Gets the contracts being applicable from p_start to p_end'''
        # When p_formatted is False, the r_esult is a tuple of the form:
        #                  (contracts, cstart, cend)
        # , "contracts" being a list of Contract instances.
        #
        # "cstart" and "cend" represent the global time interval containing all
        # contracts.
        #
        # When p_formatted is True, the "contracts" part is a chunk of XHTML and
        # the tuple has one more entry:
        #               (contracts, hours, cstart, cend)
        # , where "hours" is a list of strings, one per contract, containing the
        # number of hours per week for that contract.
        #
        # If p_schedule is not None, only contracts having this p_schedule will
        # be part of the result.
        r = []
        if formatted:
            hoursPerWeek = []
        cstart = cend = None
        for name in UserData.contractFields:
            for contract in getattr(self, name):
                # Ignore irrelevant contracts
                if schedule and contract.schedule != schedule:
                    continue
                if dutils.Date.periodsIntersect(start, end, contract.start,
                                                contract.end):
                    # Update "cstart" and "cend" with this contract
                    if cstart is None:
                        cstart = contract.start
                    else:
                        cstart = min(cstart, contract.start)
                    try:
                        cend = max(cend, contract.end)
                    except TypeError:
                        # Python 3 generates this error for max(None, None)
                        pass # v_cend is still None
                    # Format contract info if requested
                    if formatted:
                        minutesS = contract.getMinutesPerWeek(True)
                        hoursPerWeek.append(f'<p>{minutesS}</p>')
                        contract = f'<p>{contract.asString(start, end)}</p>'
                    r.append(contract)
        # Return the complete result
        if formatted:
            r = ''.join(r), ''.join(hoursPerWeek), cstart, cend
        else:
            r = r, cstart, cend
        return r

    def getAllContracts(self):
        '''Returns all (past + present) contracts defined on p_self. Cache
           it.'''
        # Getting all contracts is required for computing the duration of a
        # timespan:
        # - that depends on contractual info;
        # - that is retrieved via an arbitrary search or shown via a Ref.
        # When computing the duration of timespans being collected in the
        # context of a Calendar field, contracts for the period being currently
        # selected in the field are pre-computed. But in the hereabove-mentioned
        # case, there is no such pre-computation. Because matched timespans can
        # be from any period, we must access to all contracts. To avoid
        # performance problems, they are cached on the request.
        #
        # Get cached contracts, if found
        key = f'{self.tuser.id}_contracts'
        cache = self.cache
        if key in cache: return cache[key]
        # Get contracts from p_self's Refs
        r = self.contracts + self.pastContracts
        cache[key] = r
        return r

    def getMinutesToWork(self, grid):
        '''Compute the number of minutes the user must work during the period
           defined by p_grid, according to his contract(s).'''
        # The method also returns the list of concerned contracts. So the return
        # value is a tuple of the form ~(d_minutesToWork, [Contract])~.
        r = 0
        contracts = []
        # Browse contracts
        for name in UserData.contractFields:
            for contract in getattr(self, name):
                minutes = contract.getMinutesToWork(grid)
                if minutes:
                    r += minutes
                    contracts.append(contract)
        return r, contracts

    def getContractYears(self):
        '''Returns the antichronological list of years for which at least one
           contract occurred.'''
        # When a contract has no end date, take the year following the current
        # one as the highest possible year.
        nextYear = DateTime().year() + 1
        # Start with a set
        r = set()
        for name in UserData.contractFields:
            for contract in getattr(self, name):
                # get the contract's end date, or use the next year
                end = contract.end
                endYear = end.year() if end else nextYear
                # Add any year during which this contract applies
                year = contract.start.year()
                while year <= endYear:
                    r.add(year)
                    year += 1
        # Convert the result to an antichronological list
        r = list(r)
        r.sort(reverse=True)
        return r

    #- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    #                                 Planning
    #- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

    # ID for the main timeslot
    mainTimeslotId = 'main',

    def listEventTypes(self):
        '''Lists the event types one may enter into this calendar'''
        # Every event type is structured that way:
        #              <timespanTypeID>*<timespanTemplateID>
        # Suffix "Wish" is appended if the event represents a wish from a
        # worker, and not the final, validated, event type.
        r = []
        for typE in self.tgroup.timespanTypes:
            # Ignore inactive types
            if typE.state == 'inactive': continue
            for template in typE.listTemplatesFor(self):
                eventType = typE.getEventType(template)
                r.append(eventType)
            # If v_typE is an askable absence, get the corresponding "wish"
            # event type.
            wish = typE.getWishType()
            if wish:
                r.append(wish)
        return r

    def getEventName(self, eventType):
        '''Gets the complete name of the given p_eventType'''
        return Utils.getTTName(self.tool, eventType)

    def unwishedType(self, eventType):
        '''Return this p_eventType without the trailing "W" if there is one'''
        r = eventType
        if r.endswith('W'):
            r = r[:-1]
        return r

    def mayValidateEvents(self):
        '''Only timetrackers may validate wish events'''
        return self.user.isTimetrackerOn(self, orManager=True)

    def validQuota(self, date, eventType, span, data, event):
        '''Returns an error message if creating an event having this p_eventType
           or updating this p_event at this p_date would produce a quota
           overrun. Else, the method returns None.'''
        wish = eventType.endswith('W')
        eventType = self.unwishedType(eventType)
        # Don't check anything if we are updating an event without changing its
        # type. Ignore event wishness.
        if event and event.getType('W') == eventType: return
        # Get the type and template objects
        typeId, templateId = eventType.split('*')
        typE = self.getObject(typeId)
        # No constraint holds if the event does not represent an absence
        if typE.absence:
            template = self.getObject(templateId)
            if template:
                # Are we respecting the possibly defined absence quotas if this
                # event is created ?
                r = Quotas.complyWith(self, typE, template, data, date, wish,
                                      span)
                if isinstance(r, str): # No
                    return r

    def validTiming(self, date, eventType):
        '''Returns an error message if it is too early or too late for asking an
           absence (p_eventType represents an absence type).'''
        now = DateTime()
        # p_date cannot be in the past
        if date <= now:
            return self.translate('absence_ask_past')
        group = self.tgroup
        # It must not be too early to ask an absence at this p_date
        firstDate = date - group.askNotBefore
        if now < firstDate:
            maP = {'date': self.tool.formatDateS(firstDate)}
            return self.translate('absence_ask_too_early', mapping=maP)
        # It must not be too late to ask an absence at this p_date
        lastDate = date - group.askNotAfter
        if now > lastDate:
            maP = {'date': self.tool.formatDateS(lastDate)}
            return self.translate('absence_ask_too_late', mapping=maP)

    def validPlanningEvent(self, date, eventType, timeslot, span, data, event):
        '''Ensures an event can be created or updated, according to the user's
           contract, p_self's state and quotas.'''
        # Refuse to create or update any event in a inactive UserData object
        if self.state != 'active':
            return self.translate('event_ko_inactive')
        # Checks being specific to event creation
        if not event:
            # Refuse to create an event if the user has no contract
            if self.isEmpty('contracts'):
                return self.translate('event_ko_no_contract')
            # Check if, at p_date, the user has a contract
            has = False
            for contract in self.contracts:
                if contract.isEffectiveAt(date):
                    has = True # There is at least one effective contract
                    break
            if not has:
                return self.translate('event_ko_out_contract')
        # Ensure absence quotas are respected. p_eventType may be a wish or a
        # final event.
        text = self.validQuota(date, eventType, span, data, event)
        if text: return text
        # Ensure the user is in the right timing for asking an absence
        if eventType.endswith('W'):
            text = self.validTiming(date, eventType)
            if text:
                self.resp.fleetingMessage = False
                return text
        return True

    def getAllowedEvents(self, eventTypes):
        '''Gets, among all p_eventTypes, those that can be created by the
           currently logged user.'''
        # Managers can use all event types
        if self.mayValidateEvents():
            r = eventTypes
        else:
            # The worker himself can only use wishes
            r = [et for et in eventTypes if et.endswith('W')]
        return r

    def planningIsEditable(self):
        '''In addition to workflow-based security, this method may prevent the
           planning to be editable by managers that would not have the
           appropriate sup-permission.'''
        if self.mayValidateEvents():
            # A manager must have the appropriate sub-permission
            r = self.grants('editP')
        else:
            # The worker himself is allowed to edit the planning in order to
            # encode his absence wishes.
            r = True
        return r

    def mayDeleteEvent(self, eventType):
        '''A worker can only delete wish events'''
        return True if self.mayValidateEvents() else eventType.endswith('W')

    def getSlotMap(self, eventType):
        '''Askable absences may only be set in the main timeslot'''
        # None means: any timeslot mayu be used
        return self.mainTimeslotId if eventType.endswith('W') else None

    def getTimeslots(self):
        '''Returns the available timeslots for the user planning'''
        # The default timeslot varies depending on user roles
        if self.mayValidateEvents():
            defaultMain = False
            defaultA = True
        else:
            defaultMain = True
            defaultA = False
        return [Calendar.Timeslot('main', default=defaultMain),
                Calendar.Timeslot('A', default=defaultA, dayPart=0),
                Calendar.Timeslot('B', dayPart=0),
                Calendar.Timeslot('C', dayPart=0),
                Calendar.Timeslot('D', dayPart=0)]

    def validationMapper(self, eventType):
        '''Returns the final, validated event type corresponding to p_eventType,
           if this latter is a wish.'''
        return eventType[:-1] if eventType.endswith('W') else None

    def getUser(self):
        '''Returns the User object being related to p_self'''
        return self.tuser

    # Individual monthly figures, shown under the individual planning
    pxMonthlyFigures = Px('''
     <x var="data=o.getMonthlyFigures(_ctx_.view.monthDayOne)">::data</x>''')

    def getMonthlyFigures(self, first):
        '''Computes, for p_self, monthly figures as they appear as totals in the
           global HP group planning.'''
        group = self.tgroup
        period = first.strftime('%Y/%m')
        data = Slice.computeData(group, period=period, consolidate=False,
                                 searchSlice=True, userData=self, format=True)
        if not data: return ''
        r = []
        _ = self.translate
        # Browse values, in their logical order
        for name, subField in Slice.getDataFieldsFor(self):
            subText = _(subField.labelId)
            r.append(f'<b>{subText}</b> : {getattr(data, name)}')
        r = ' ✴ '.join(r)
        period = first.strftime('%m / %Y')
        return f'<div class="topSpace">{period} :: {r}</div>'

    def getEventFields(self, eventType):
        '''Returns the event fields being specific to that p_eventType'''
        # Get the timespan template
        template = self.getObject(eventType.split('*', 1)[1])
        if not template: return
        flavour = template.flavour
        if flavour == 'dynamic':
            r = TimespanTemplate.dynamicFields
        elif flavour == 'multiple':
            r = TimespanTemplate.multipleFields
        else:
            r = None
        return r

    planning = Calendar(eventTypes=listEventTypes, eventNameMethod=getEventName,
      page=Page('planning', show=showTimetrackerPage), slotMap=getSlotMap,
      validator=validPlanningEvent, editable=planningIsEditable,
      editableEvents=True, delete=mayDeleteEvent,
      allowedEventTypes=getAllowedEvents, timeslots=getTimeslots,
      useEventComments=True, eventFields=getEventFields, dataClass=TimeData,
      validation=Calendar.Validation(mayValidateEvents, validationMapper,
        email=getUser, emailSubjectLabel='validate_events_subject',
        emailBodyLabel='validate_events_body'),
      bottomPx=pxMonthlyFigures)

    #- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    #                                Summary
    #- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

    summary = Computed(method=Slice.computeMultiData, layouts=Layouts.dv,
                       page=Page('summary', show=showTimetrackerPage))

    #- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    #                              Permissions
    #- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

    # Beyond workflow-based security, for every timetracked user, dict
    # "permissions" below defines finer-grained permissions, for every
    # "timetracker" (=user being among hpGroup.timetrackers).

    def showPermissions(self):
        '''Only admin-groups are allowed to view and edit timetracker-related
           permissions.'''
        # Do not show this page if no timetracker user is defined
        return self.showTimetrackerPage(adminOnly=True) and \
               not self.container.isEmpty('timetrackers')

    pp = {'page': Page('permissions', show=showPermissions)}

    # Every sub-field corresponds to a permission
    sub = (
      # View this user's timespans or provisional planning
      ('view', Boolean(default=True)),
      # Validate this user's timespans
      ('validate', Boolean(default=True)),
      # Edit this user's timespans
      ('edit', Boolean(default=True)),
      # Edit this user's events in its provisional planning
      ('editP', Boolean(default=True)),
      # Edit this user's contracts (create, update, delete & workflow actions)
      ('editC', Boolean(default=True))
    )

    def listTimetrackers(self):
        '''Lists the users being defined as timetrackers for this HP group'''
        # If we are on the "view" layout, display only actually stored entries
        onView = self.H().getLayout() == 'view'
        r = []
        stored = self.permissions
        for user in self.container.timetrackers:
            # Determine if this user must be added to the result
            login = user.login
            if onView:
                condition = stored and login in stored
            else:
                condition = True
            # Add it if the condition is met
            if condition:
                r.append((login, user.getTitle()))
        return r

    permissions = Dict(listTimetrackers, sub, layouts=Layouts.dv, **pp)

    # Some specific permissions correspond to standard workfow permissions
    permissionsMap = {'view': r, 'edit': w}

    def grants(self, permission):
        '''Does p_self grant this p_permission for the currently logged user?'''
        # This method is based on dict p_self.permissions
        #
        # If no permission is stored on p_self, the answer is "yes"
        user = self.user
        if self.isEmpty('permissions') or user.isAdminOn(self):
            return True
        # If the logged user is not mentioned in dict "permissions", it is "no"
        login = user.login
        permissions = self.permissions
        if login not in permissions:
            # If p_permission is a standard workflow permission, use workflow
            # security here.
            perm = self.permissionsMap.get(permission)
            return self.allows(perm) if perm else None
        return getattr(permissions[login], permission, False)

    # Action allowing to empty field "permissions"

    def showDeletePermissions(self):
        '''Show action "delete permissions" only if there are permissions to
           delete.'''
        if not self.isEmpty('permissions'): return 'buttons'

    def doDeletePermissions(self):
        '''Cleans field "permissions"'''
        self.getField('permissions').store(self, None, overwrite=True)

    deletePermissions = Action(action=doDeletePermissions, confirm=True,
                               icon='HubPeople/deleteAll',
                               show=showDeletePermissions, **pp)

    #- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    #                                 Quotas
    #- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

    pq = {'page': Page('quotas', show=Quotas.showPage,
                                 showSave=Quotas.showSave)}

    # Info to show on "view" if field "quotas" is empty
    infoQuota = Info(show=lambda o: o.isEmpty('quotas'),
                     layouts=Info.Layouts.do, **pq)

    # Info to show when field "quotas" can't be edited, because the tied group
    # does not propose any active timespan type with quotas.
    iLayouts = Layouts(edit='ld')

    infoQuotaMissingFields = Info(focus=True, layouts=iLayouts,
      show=lambda o: None if Quotas.getFields(o) else 'edit', **pq)

    # Info to show when field "quotas" can't be edited, because no "contract
    # year" was found for that user
    infoQuotaMissingYears = Info(focus=True, layouts=iLayouts,
      show=lambda o: None if Quotas.getYears(o) else 'edit', **pq)

    # Quotas, for every year and timespan type having quotas
    quotas = Dict(Quotas.getYears, Quotas.getFields, layouts=Layouts.d,
                  show=Quotas.showField, **pq)

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

    def validate(self, new, errors):
        '''Ensure dict "permissions" is valid'''
        page = self.req.page
        if page == 'permissions' and new.permissions:
            permissions = new.permissions
            # Scan every entry in dict "permissions"
            for login, data in permissions.items():
                user = self.search1('User', login=login)
                if not user: continue # Should not happen
                # If the user is also group-admin, it is useless to uncheck any
                # checkbox. So, if it is the case, set a validation error.
                _ = self.translate
                if user.isAdminOn(self):
                    message = _('ud_perms_unchecked')
                    field = self.getField('permissions')
                    for name, sub in field.fields:
                        if not getattr(data, name, None):
                            attr = field.getEntryName(name, login)
                            setattr(errors, attr, message)
                if errors: return
                # If "view" is unchecked, it is useless to grant any other
                # permission.
                if not data.view:
                    # Browse all other values
                    message = _('ud_perms_useless')
                    for name, sub in self.getField('permissions').fields:
                        if name == 'view': continue
                        if getattr(data, name, False):
                            attr = field.getEntryName(name, login)
                            setattr(errors, attr, message)

    def mayDelete(self):
        '''It is not possible anymore to delete p_self if some sub-data are
           defined.'''
        no = self.isEmpty
        return no('timespans') and no('contracts') and no('pastContracts')

    def onDelete(self):
        '''Logs the deletion'''
        self.log(UDATA_ACT % self.strinG())

    def onEdit(self, created):
        # Create a title
        self.title = f'{self.tuser.title} ↔ {self.tgroup.title}'
        # Set local roles
        self.tgroup.setLocalRoles(self)
        verb = 'Created' if created else 'Edited'
        self.log(UDATA_ACT % (self.strinG(), verb))
#- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
