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

#- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
from appy.all import *
from appy.px import Px
from appy.utils.string import Normalize
from appy.utils.ical import ICalExporter
from appy.utils.path import getTempFileName
from appy.model.user import User as BaseUser

from . import Utils
from .timetracker.Timespan import TimespanIcon

#- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
U_CREATED  = 'User "%s" created.'
U_EDITED   = 'User "%s" edited.'
CAL_EXP    = '%d calendar event(s) exported.'

# Options for changing a user's main group - - - - - - - - - - - - - - - - - - -
class ChangeMainGroup:
    '''Options class used for changing a user's main HP group'''

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

    popup = ('400px', '200px')
    indexable = False

    # One may choose a group among the HP groups the user already belongs to
    def listUserGroups(self):
        '''Lists all the groups the user (=originator object) is, its current
           main group excepted.'''
        r = []
        tool = self.tool
        user = self.initiator.o
        mainGroup = user.container
        for group in user.getHpGroups():
            if group == mainGroup: continue
            r.append((str(group.iid), group.title))
        return r

    group = Select(validator=Selection(listUserGroups), multiplicity=(1,1))

    @classmethod
    def show(class_, user):
        '''Action allowing to change a p_user's main group can be performed by
           managers only, on non-global, not-archived users only.'''
        # Avoid showing this action on page HsGroup::users. Indeed, the action
        # has the effect of removing the user from this page, because he changes
        # his main group. Consequently, it may produce an error.
        if user.req.page == 'users': return
        if user.user.hasRole('Manager') and not user.isEmpty('mainGroup') and \
           len(user.getHpGroups()) > 1:
            return Show.BS

    def apply(self, user):
        '''Change p_self's main group'''
        # Get the previous and next HP groups
        previousGroup = user.mainGroup
        newGroup = self.getObject(self.group)
        # Switch groups
        user.mainGroup = newGroup
        user.reindex(fields=('cid',)) # p_user has a new container
        # Update local roles accordingly: configure read access to the previous
        # group and complete ownership to the new group.
        for group in (previousGroup, newGroup): group.unsetLocalRoles(user)
        previousGroup.setLocalRoles(user, reset=False, read=True, reindex=True)
        newGroup.setLocalRoles(user, reset=False, read=False, reindex=True)
        self.say(self.translate('object_saved'))

#- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
class UserWorkflow:
    '''Workflow for a users'''

    # ~~~ Roles in use in this workflow ~~~
    ma = Role('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 ~~~
    wp = 'Write planning'

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

    # Groups of roles, expressed as strings, suitable as params for m_hasRole
    smanagers     = [role.name for role in managers]
    stimetrackers = [role.name for role in timetrackers]
    stmanagers    = [role.name for role in tmanagers]

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

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

    def doReactivate(self, user):
        '''If the user is among archived users of some HP group, move it to
           standard users.'''
        if not user.isEmpty('archGroup'):
            group = user.archGroup
            group.link('users', user)
            group.unlink('archivedUsers', user)
            # If we stay on the same page, navigation may now be wrong (due to
            # the Ref change) and lead to an error.
            group.goto('%s?page=users' % group.url)
            return group.translate('user_unarchived',
                                   mapping={'name': user.getTitle()})

    reactivate = Transition( (inactive, active), action=doReactivate, **tp)

#- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
class User:
    '''Represents a HubPeople user'''

    workflow = UserWorkflow
    listColumns = ('title*60%', 'login*150px|', 'state*120px|')
    memberColumns = ('title*30%', 'login*30%', 'fullAddress*35%',
                     'phone*120px|', 'trainee*100px|', 'state*100px|')
    searchAdvanced = Search('advanced', actionsDisplay='inline')

    # Search used by several Refs to choose users
    popupSearch = Search(maxPerPage=15, sortBy='title', state='active')

    # Classes whose instances must be owned by the user itself
    userClasses = ('User', 'UserData', 'Timespan')

    def getSortName(self, order):
        '''Get the p_self's name, in a form tailored to be used for that sort
           p_order.'''
        if order == 'pseudo':
            # Use the name and first name in case the pseudo is missing
            pseudo = self.pseudo
            if pseudo:
                r = Normalize.fileName(pseudo).lower()
            else:
                r = ''.join(self.getSortName('name'))
        else:
            # Use p_self's name and firstName
            name = Normalize.fileName(self.name or self.login).lower()
            firstName = self.firstName or ''
            if firstName:
                firstName = Normalize.fileName(firstName).lower()
            if order == 'name':
                r = name, firstName
            else: # p_order is 'firstName'
                r = firstName, name
        return r

    @staticmethod
    def mayCreate(tool):
        '''Managers and admin-groups may create users'''
        user = tool.user
        return user.hasRole('Manager') or not user.isEmpty('administeredGroups')

    creators = mayCreate

    def isGlobal(self):
        '''Is this user global (ie, not local to an HP group ?)'''
        return self.container.isTool

    @staticmethod
    def update(class_):
        fields = class_.fields
        # Prevent use of global roles on local users
        fields['roles'].show = class_.python.isGlobal
        # Hide Ref "groups"
        fields['groups'].show = False
        # Stricter password policy
        fields['password'].occurrences = {'lower' :2, 'upper'  :1,
                                          'figure':1, 'special':1}

    pxPseudos = Px('''<x>::o.getPseudos('targets')</x>''')

    #- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    #                              Page "main"
    #- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

    def getSupTitle(self, nav):
        '''Render an icon if the user s an external (=faceted) user)'''
        if self.isFaceted():
            title = self.translate('facet_user')
            return f'<span class="help" title="{title}">⇫</span>'

    def getSupBreadCrumb(self): return self.getSupTitle(None)

    # Is this user a trainee ?
    baseGroup = BaseUser.baseGroup

    def showTrainee(self):
        '''Showing field "trainee" is similar to showing page "profile", but it
           is convenient to have the field appearing on the user creation
           form.'''
        if self.isTemp(): return True
        return self.showPageProfile()

    trainee = Boolean(layouts=Boolean.Layouts.gd, group=baseGroup,
                      show=showTrainee)

    # Phones and email
    mp = {'layouts': Layouts.g, 'width': 28, 'group': baseGroup}
    phone = String(**mp)
    phone2 = String(**mp)
    email2 = String(validator=String.EMAIL, **mp)

    # Pseudo
    mp['layouts'] = Layouts.gh; del mp['width']
    pseudo = String(width=15, multiplicity=(1,1), **mp)

    # The following fields displays the pseudo or another info, when missing
    pseudoOr = Computed(method=lambda o:o.pseudo or o.getTitle(),
                        label=(None, 'pseudo'), show='result')

    def doIcalExport(self):
        '''Computes and returns the iCal version of all messages (meets and
           tasks) for which this user is a target.'''
        # Get a name for the ICS file to produce in a temp folder
        name = getTempFileName('%s_evenements' % self.login, 'ics',
                               timestamp=False)
        exporter = ICalExporter(name, config=self.config.ical)
        exporter.start()
        # Fill the file with one entry for every meet/task for which this user
        # is a target.
        context = O(count=0, exporter=exporter)
        self.tool.compute('Message', sortBy='modifiedC', context=context,
                          expression='o.ical(ctx.exporter); ctx.count += 1',
                          messageType=or_('meet', 'task'), targets=self.iid)
        self.log(CAL_EXP % context.count)
        exporter.end()
        return True, open(name)

    def showIcalExport(self):
        '''Show this button only to the user corresponding to the current User
           instance.'''
        return 'buttons' if self.user == self and self.isJourneyman() else None

    icalExport = Action(action=doIcalExport, show=showIcalExport, confirm=True,
                        result='file', icon='calendar.svg')

    # Action for changing the user's main group
    changeMainGroup = Action(action=lambda o, options: options.apply(o),
                             render='icon', icon='groupChange.svg',
                             show=ChangeMainGroup.show, options=ChangeMainGroup)

    #- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    #                              Page "profile"
    #- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

    def showPageProfile(self):
        '''Page "profile" contains sensitive information about a user. Only
           managers and timetrackers can edit it; the user itself can only
           view it.'''
        user = self.user
        container = user.container
        if container.isTool:
            r = user.hasRole('Manager')
        elif user.isManagerOn(self) or user.isTimetrackerOn(self):
            r = True
        elif user == self:
            # It has no sense to store profile info for external users
            r = None if user.isFaceted() else 'view'
        else:
            r = None
        return r

    pp = {'page': Page('profile', show=showPageProfile),
          'group': Group('profile', ['20%','80%'], style='grid',
                         hasLabel=False)}

    birthDate = Date(format=Date.WITHOUT_HOUR,
      startYear=Utils.currentYear-80, endYear=Utils.currentYear-15,
      label='Beneficiary', **pp)

    nrn = String(validator=String.BELGIAN_NISS, **pp)

    pp['multiplicity'] = (1,1)
    gender = Select(validator=('m', 'f'), render='radio', **pp)

    def getShownName(self, html=False):
        '''Returns this user's name. Use the pseudo if defined, its first and
           last names else.'''
        r = self.pseudo or self.getTitle()
        if html and self.allows('read'):
            r = '<a href="%s" target="_blank">%s</a>' % (self.url, r)
        return r

    # Address
    del pp['multiplicity']
    addressGroup = Group('address', group=pp['group'])
    pp.update({'group': addressGroup, 'label': 'HpGroup', 'placeholder': True})
    # Sub-group with address, number, box
    pp['layouts'] = Layouts(edit='frv', view='lf')
    pp['group'] = Group('address1', ['']*3, hasLabel=False, wide=False,
                        align='left', group=pp['group'])
    address = String(**pp)
    number = String(width=5, **pp)
    box = String(width=5, **pp)
    # Sub-group with postal code and city
    pp['group'] = Group('address2', ['']*2, hasLabel=False, wide=False,
                        align='left', group=pp['group'].group)
    postalCode = String(width=6, **pp)
    city = String(**pp)

    # A computed field displaying the complete address in one line
    def getFullAddress(self): return Utils.fullAddress(self)
    fullAddress = Computed(method=getFullAddress, show='result',
                           plainText=True, label=('HpGroup', 'address'))

    del pp['label']
    del pp['placeholder']
    pp['group'] = pp['group'].group.group
    nationality = String(**pp)
    idCardNumber = String(**pp)
    picture = File(isImage=True, width='300px', resize=True, **pp)
    schoolLevel = String(**pp)
    distanceToWork = Float(**pp)

    pp['layouts'] = Rich.Layouts.g
    description = Rich(**pp)
    detailedDescription = Rich(**pp)

    def getHpGroups(self, subs=None, sorted=False, active=True):
        '''Returns the HP groups this user belongs to. If p_subs is None, it
           returns all the groups, independently of the role the user plays in
           the group. If p_subs is given, it corresponds to a list of roles and
           we will only return HP groups into which the user has one of those
           roles. If p_sorted is True, returned groups are sorted
           alphabetically.
        '''
        # If p_active is...
        #- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
        # None    | It returns all groups, be they active or not
        # True    | Only active groups are returned
        # False   | Only inactive groups are returned
        #- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
        r = []
        # Add "standard" groups
        for sub in (subs or Utils.subGroups):
            for group in getattr(self, Utils.subFields[sub]):
                # Take care of p_active
                if not group.match(active): continue
                # Add the group to the result
                if group not in r: r.append(group)
        # Add facet-related groups
        if subs is None:
            for facet in self.facets:
                group = facet.group
                # Take care of p_active
                if not group.match(active): continue
                # Add the group to the result
                if group not in r: r.append(group)
        # Sort the result when relevant
        if sorted:
            r.sort(key=lambda x: x.title)
        return r

    def inGroup(self, hpGroup):
        '''A user is considered to belong to this HP group (p_self) if he
           belongs to at least one of its sub-groups.'''
        for sub in hpGroup.subGroups:
            if self in getattr(hpGroup, sub):
                return True

    def isAdmin(self):
        '''Returns True if this user is Manager or admin-group of at least one
           group.'''
        return self.hasRole('Manager') or not self.isEmpty('administeredGroups')

    def isJourneyman(self):
        '''Returns True if p_self has at least one journeyman-related role in at
           leat one HP group.'''
        # This info can be cached
        cache = self.cache
        if 'isJM' in cache: return cache.isJM
        # Compute it
        r = False
        for name in Utils.journeymanRefs:
            if not self.isEmpty(name):
                r = True
                break
        # Cache and return it
        cache.isJM = r
        return r

    def isManager(self):
        '''Returns True if p_self is a global manager or admin-group or manager
           for at least one group.'''
        # Info can be cached
        cache = self.cache
        if 'isManager' in cache: return cache.isManager
        # Compute, cache and return it
        r = self.hasRole('Manager') or not self.isEmpty('administeredGroups') \
            or not self.isEmpty('managedGroups')
        cache.isManager = r
        return r

    def isInternal(self):
        '''Returns True if p_self has at least one "internal" (=not faceted)
           role within at least one HP group.'''
        for name in Utils.subFields.values():
            if not self.isEmpty(name):
                return True

    def isAdminOn(self, o):
        '''Returns True if this user is Manager or admin-group on p_o'''
        return self.hasRole(UserWorkflow.smanagers, o)

    def isJourneymanOn(self, o, orGlobalManager=True):
        '''Returns True if p_self has at least one journeyman-related role on
           this p_o(bject).'''
        if orGlobalManager and self.hasRole('Manager'): return True
        return self.hasRole(Utils.journeymanRoles, o)

    def isJourneypodOn(self, o, orGlobalManager=True):
        '''Returns True if p_self has at least one journeypod-related role on
           this p_o(bject).'''
        if orGlobalManager and self.hasRole('Manager'): return True
        return self.hasRole(Utils.journeypodRoles, o)

    def isManagerOn(self, o, orGlobalManager=True):
        '''Returns True if this user is either GroupAdmin or GroupManager on
           this p_o(bject).'''
        r = self.hasRole(Utils.managerRoles, o)
        if r or not orGlobalManager: return r
        return self.hasRole('Manager')

    def isTimetrackerOn(self, o, orManager=False):
        '''Returns True if this user is Manager or GroupTimetracker on p_o.
           If p_orManager is True, it also returns True if the user is
           admin-group on p_o.'''
        attr = 'stmanagers' if orManager else 'stimetrackers'
        return self.hasRole(getattr(UserWorkflow, attr), o)

    def isCareManagerOn(self, o, orManager=True):
        '''Return True if p_self is a caremanager on p_o'''
        if orManager and self.hasRole('Manager'): return True
        return self.hasRole('GroupCaremanager', o)

    def isCaregiverOn(self, o):
        '''Return True if p_self is a caregiver on p_o'''
        return self.hasRole('GroupCaregiver', o)

    def caresOn(self, o):
        '''Returns True if p_self has at least on Care* role on this
           p_o(bject).'''
        return self.hasRole(Utils.careRoles, o)

    def isFaceted(self):
        '''Return True if p_self is tied to at least one facet'''
        # Being tied to at least one facet is the way to identify "external"
        # users. Indeed, it is not possible, for any user, to have both an
        # "internal" role within a HP group AND to be tied to a facet.
        return not self.isEmpty('facets')

    def isBillable(self):
        '''Is p_self a billable user ?'''
        # Inactive users, trainees and external users are not billable
        return self.state == 'active' and not self.trainee and \
               not self.isFaceted()

    def mayCreateMessages(self, group=None):
        '''r_ is True if this user may create messages in p_group if
           specified, or in at least one group else.'''
        # A user must be in some sub-group in order to create messages
        for name in Utils.creatorFields:
            if group:
                condition = group in getattr(self, name)
            else:
                condition = not self.isEmpty(name)
            if condition:
                return True

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

    def getTimespanIcon(self):
        '''Return the icon allowing to perform a timetrack-related action'''
        # Check first if this icon has sense for the current user, in the
        # current context. Indeed, the user must have a UserData, timetracker-
        # enabled instance for the currently selected group, and there must be
        # at least one contract defined for him in this group.
        group = self.guard.authObject
        if not group or self.isEmpty('data'): return
        # Find the UserData instance for which the icon can be produced
        userData = None
        for data in self.data:
            # Ignore inactive UserData instances
            if data.state != 'active': continue
            if group == data.tgroup:
                if data.timetracked and data.mayAddTimespan():
                    userData = data
                    break
        if userData is None: return
        return TimespanIcon(userData).get()

    #- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    #                              Page "data"
    #- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

    # This page stores UserData instances (see back ref from Ref UserData.tuser)

    # Info to display when no UserData instance exists yet for this user
    infoNoData = Info(focus=True, show=lambda o: o.isEmpty('data'),
      page=Page('data', show=lambda o: None if o.isFaceted() else 'view'))

    #- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    #                         Page "sent messages"
    #- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

    def getSentMessages(self):
        '''Creates and execute a search producing all the messages created by
           p_self.'''
        # Get the Message class
        class_ = self.model.classes['Message']
        # Use the standard "modified" sort key instead of "modifiedC". Indeed,
        # tasks, meets and mesages are mixed: "modified" seems more appropriate.
        params = class_.python.spc.copy()
        params['sortBy'] = 'modified'
        return Search(container=class_, creator=self.login, **params)

    sent = Computed(method=getSentMessages, layouts=Layouts.w,
      page=Page('sent', show=lambda o:'view' if o.isJourneyman() else None))

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

    def showBack(self, name):
        '''Show back reference p_name if not empty'''
        return None if self.isEmpty(name) else Show.E_

    def validate(self, new, errors):
        '''Inter-field validation'''
        page = self.req.page or 'main'
        if page == 'profile':
            # Ensure "email2" is different from the user's login or standard
            # email; else, it is useless to specify an additional email address.
            email2 = new.email2
            if email2:
                email2 = email2.strip()
                if email2 == self.email or email2 == self.login:
                    errors.email2 = self.translate('duplicate_email')

    def onEdit(self, created):
        login = self.login
        if created:
            # Don't do anything for a special user
            if self.isSpecial(): return
            # Keep the user itself as unique object owner
            initiator = self.initiator.o
            # Set group-related local roles if this user is local to an HP group
            if initiator and initiator.class_.name == 'HpGroup':
                initiator.setLocalRoles(self)
            else:
                # Only set this user as its own Owner
                self.localRoles.reset()
                self.localRoles.add(login, 'Owner')
            self.log(U_CREATED % login)
        else:
            # Ensure this user's main group has the appropriate local roles on
            # the user.
            container = self.container
            if container and not container.isTool:
                container.setLocalRoles(self, reset=False)
            self.log(U_EDITED % login)

    def mayDelete(self):
        '''One may not delete a user if he is not in use'''
        empty = self.isEmpty
        return empty('messages') and empty('data')
#- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
