Module control.admin

Expand source code Browse git
import re

from control.generic import AttrDict


USERNAME_RE = re.compile(r"[^a-z0-9._-]")


class Admin:
    def __init__(self, Content):
        """Get the list of relevant projects, editions and users.

        Admin users get the list of all users.

        Normal users get the list of users associated with

        * the project of which they are organiser
        * the editions of which they are editor or reviewer

        Guests and not-logged-in users cannot see any user.

        If the user has rights to modify the association
        between users and projects/editions, he will get
        the controls to do so.

        Upon initialization the project/edition/user data will be read
        and assembled in a form ready for generating html.

        ## Overview of assembled data

        ### projects

        All project records in the system, keyed by id.
        If a project has editions, the editions are
        available under key `editions` as a dict of edition records keyed by id.
        If a project has users, the users are
        available under key `users` as a dict keyed by user id
        and valued by the user records.

        If an edition has users, the users are
        available under key `users` as a dict keyed by role and then by user id
        and valued by a tuple of the user record and his role.

        ### users

        All user records in the system, keyed by id.

        ### myIds

        All project and edition ids to which the current user has a relationship.
        It is a dict with keys `project` and `edition` and the values are sets
        of ids.
        """
        self.Content = Content

        Messages = Content.Messages
        Messages.debugAdd(self)

        Settings = Content.Settings
        H = Settings.H
        authSettings = Settings.auth
        roleInfo = authSettings.roles
        roleRank = authSettings.roleRank
        representations = Settings.representations
        css = Settings.css

        Mongo = Content.Mongo
        Auth = Content.Auth

        self.Settings = Settings
        self.Mongo = Mongo
        self.Auth = Auth
        self.H = H
        self.representations = representations
        self.css = css

        siteRoles = roleInfo.site
        projectRoles = roleInfo.project
        editionRoles = roleInfo.edition

        self.siteRoles = siteRoles
        self.projectRoles = projectRoles
        self.editionRoles = editionRoles
        self.roleRank = roleRank
        self.siteRolesList = tuple(sorted(siteRoles, key=roleRank))
        self.projectRolesList = tuple(sorted(projectRoles, key=roleRank))
        self.editionRolesList = tuple(sorted(editionRoles, key=roleRank))
        self.siteRolesSet = frozenset(siteRoles)
        self.projectRolesSet = frozenset(projectRoles)
        self.editionRolesSet = frozenset(editionRoles)

        self.update()

    def update(self):
        """Reread the tables of users, projects, editions.

        Typically needed when you have used an admin function to perform
        a user administration action.

        This may change the permissions and hence the visiblity of projects and editions.
        It also changes the possible user management actions in the future.
        """
        Mongo = self.Mongo
        Auth = self.Auth
        Auth.identify()
        User = Auth.myDetails()
        user = User.user

        self.User = User
        self.user = user

        if not user:
            self.myRole = None
            self.inPower = False
            return

        myRole = User.role
        inPower = myRole in {"root", "admin"}

        self.myRole = myRole
        self.inPower = inPower

        siteRecord = Mongo.getRecord("site")
        userList = Mongo.getList("user", sort="nickname")
        projectList = Mongo.getList("project", sort="title")
        editionList = Mongo.getList("edition", sort="title")
        projectLinks = Mongo.getList("projectUser")
        editionLinks = Mongo.getList("editionUser")

        users = AttrDict({x.user: x for x in userList})
        projects = AttrDict({x._id: x for x in projectList})
        editions = AttrDict({x._id: x for x in editionList})

        myIds = AttrDict()

        self.site = siteRecord
        self.users = users
        self.projects = projects
        self.editions = editions
        self.myIds = myIds

        for eRecord in editionList:
            eId = eRecord._id
            pId = eRecord.projectId
            projects[pId].setdefault("editions", {})[eId] = eRecord

        for pLink in projectLinks:
            role = pLink.role

            if role:
                u = pLink.user
                uRecord = users[u]
                if uRecord is None:
                    continue
                pId = pLink.projectId
                pRecord = projects[pId]
                if pRecord is None:
                    continue
                pRecord.setdefault("users", AttrDict())

                if user == u:
                    myIds.setdefault("project", set()).add(pId)
                    for eId in pRecord.editions or []:
                        myIds.setdefault("edition", set()).add(eId)

                pRecord.setdefault("users", AttrDict())[u] = (uRecord, role)

        for eLink in editionLinks:
            role = eLink.role
            if role:
                u = eLink.user
                uRecord = users[u]
                if uRecord is None:
                    continue
                eId = eLink.editionId
                eRecord = editions[eId]
                if eRecord is None:
                    continue
                pId = eRecord.projectId

                if user == u:
                    myIds.setdefault("project", set()).add(pId)
                    myIds.setdefault("edition", set()).add(eId)

                eRecord.setdefault("users", AttrDict())[u] = (uRecord, role)

    def authUser(self, otherUser, table=None, record=None):
        """Check whether a user may change the role of another user.

        The questions are:

        "which *other* site-wide roles can the current user assign to the other
        user?" (when no table or record is given).

        "which project/edition scoped roles can the current user assign to or
        remove from the other user
        with respect to the relevant record in the given table?".

        Note that the current site-wide role of the other user is never included
        in the set of resulting roles.

        There are also additional business rules.
        This function will return the empty set if these rules are violated.

        **Business rules**

        *   Users have exactly one site-wise role.
        *   Users may demote themselves.
        *   Users may not promote themselves unless ... see later.
        *   Users may have zero or one project/edition-scoped role per
            project/edition
        *   When assigning new site-wide or project/edition-scoped roles, these
            roles must be valid roles for that scope.
        *   When assigning a new site-wide role, None is not one
            of the possible new roles:
            you cannot change the status of an authenticated user to "not
            logged in".
        *   When assigning project/edition scoped roles, removing such a
            role from a user for a certain project/edition means that the
            other user is removed from that project or edition.
        *   Roles are ranked in power. Users with a higher role are also authorised
            to all things for which lower roles give authorisation.

            The site-wide roles are ranked as:

            ```
            root - admin - user - guest - not logged in
            ```

            The project/edition roles are ranked as:

            ```
            (project) organiser - (edition) editor - (edition) reviewer
            ```

            Site-wide power does not automatically carry over to project/edition-scoped
            power.

        *   Users cannot promote or demote people that are currently as powerful
            as themselves.
        *   In normal cases there is exactly one root, but:
            *   If a situation occurs that there is no root and no admin, any authenticated
                user my grab the role of admin.
            *   If a situation occurs that there is no root, any admin may
                grab the role of root.
        *   Roots may appoint admins.
        *   Roots and admins may change site-wide roles.
        *   Roots and admins may appoint project organisers, but may not assign
            edition-scoped roles.
        *   Project organisers may appoint edition editors and reviewers.
        *   Edition editors may appoint edition reviewers.
        *   However, roots and admins may also be project organisers and
            edition editors for some projects and some editions.
        *   Normal users and guests can not administer site-wide roles.
        *   Guests can not be put in project/edition-scoped roles.

        Parameters
        ----------
        otherUser: string | void
            the other user as string (eppn)
            If None, the question is: what are the roles in which an other
            user may be put wrt to this project/edition?
        table: string, optional None
            the relevant table: `project` or `edition`;
            this is the table in which the record sits
            relative to which the other user will be assigned a role.
            If None, the role to be assigned is a site wide role.
        record: ObjectId | AttrDict, optional None
            the relevant record;
            it is the record relative to which the other user will be
            assigned an other role.
            If None, the role to be assigned is a site wide role.

        Returns
        -------
        boolean, frozenset
            The boolean indicates whether the current user may modify the role
            of the target user.

            The frozenset is the set of assignable roles to the other user
            by the current user with respect to the given table and record or site-wide.

            If the boolean is false, the frozenset is empty.
            But if the frozenset is empty it might be the case that the current
            user is allowed to remove the role of the target user.
        """
        myRole = self.myRole

        if myRole in {None, "guest"}:
            return (False, frozenset())

        user = self.user
        users = self.users
        nRoots = sum(1 for u in users.values() if u.role == "root")
        nAdmins = sum(1 for u in users.values() if u.role == "admin")
        iAmInPower = self.inPower
        otherUserRecord = users[otherUser] or AttrDict()
        otherRole = otherUserRecord.role
        otherIsInPower = otherRole in {"admin", "root"}

        nope = (False, frozenset())

        # side-wide assignments

        if table is None or record is None:
            # nobody can add site-wide users

            if otherUser is None:
                return nope

            siteRolesSet = self.siteRolesSet

            # if there are no admins and no roots,
            #   any admin may promote himself to root
            #   if there are no admins
            #     any authenticated user may promote himself to admin

            remainingRoles = frozenset(siteRolesSet - {None, otherRole})

            if nRoots == 0:
                if user == otherUser:
                    if nAdmins == 0:
                        if myRole == "user":
                            fineAdmin = (True, frozenset(["admin"]) | remainingRoles)
                            return fineAdmin
                    else:
                        if myRole == "admin":
                            fineRoot = (True, frozenset(["root"]) | remainingRoles)
                            return fineRoot

            # from here on, only admins and roots can change roles
            if not iAmInPower:
                return nope

            fine = (True, remainingRoles)

            # root is all powerful, only limited by other roots
            if myRole == "root":
                if user == otherUser or otherRole != "root":
                    return fine
                else:
                    return nope

            # from here on, myRole is admin, so "root" cannot be assigned

            remainingRoles = frozenset(remainingRoles - {"root"})
            fine = (True, remainingRoles)
            fineNoAdmin = (True, remainingRoles - {"admin"})

            # when the user changes his own role: can only demote
            if user == otherUser:
                return fineNoAdmin

            # people cannot affect other more or equally powerful people
            if otherIsInPower:
                return nope

            # people cannot promote others beyond their own level
            return fine

        # not a project or edition, or not a real record: Not allowed!

        if table not in {"project", "edition"} or record is None:
            return nope

        # project-scoped assignments

        projectRolesSet = self.projectRolesSet
        fine = (True, projectRolesSet)

        if table == "project":
            # only admins and roots can assign a project-scoped role
            if not iAmInPower:
                return nope

            # remaining cases are allowed
            return fine

        # remaining case: only edition scoped.

        if table != "edition":
            return nope

        # edition-scoped assignments

        Mongo = self.Mongo
        (recordId, record) = Mongo.get(table, record)
        if recordId is None:
            return nope

        projects = self.projects
        editionRolesSet = self.editionRolesSet

        # check whether the role is a edition-scoped role
        pRecord = projects[record.projectId]
        pUsers = pRecord.users or AttrDict()
        eUsers = record.users or AttrDict()

        otherProjectRole = (pUsers[otherUser] or (None, None))[1]
        otherEditionRole = (eUsers[otherUser] or (None, None))[1]

        myProjectRole = (pUsers[user] or (None, None))[1]
        myEditionRole = (eUsers[user] or (None, None))[1]

        # only organisers of the parent project can (un)assign an
        # edition editor

        iAmOrganiser = "organiser" == myProjectRole
        otherIsOrganiser = "organiser" == otherProjectRole
        iAmEditor = "editor" == myEditionRole
        otherIsEditor = "editor" == otherEditionRole

        # what I can do to myself

        fine = (True, editionRolesSet)
        fineNoEditor = (True, editionRolesSet - {"editor"})

        if user == otherUser:
            if iAmOrganiser or iAmEditor:
                return fine
            return nope

        # what I can do to others

        if otherUser is None:
            if iAmOrganiser:
                return fine
            if iAmEditor:
                return fineNoEditor

        if otherIsOrganiser:
            return nope

        if otherIsEditor:
            if iAmOrganiser:
                return fine
            return nope

        if iAmOrganiser:
            return fine
        if iAmEditor:
            return fineNoEditor

        return nope

    def wrap(self):
        """Produce a list of projects and editions and users for root/admin usage.

        The first overview shows all projects and editions
        with their associated users and roles.

        Only items that are relevant to the user are shown.

        If the user is authorised to change associations between
        users and items, they will be editable.

        The second overview is for admin/roots only.
        It shows a list of users and their site-wide roles, which can be changed.

        """
        H = self.H
        user = self.user

        if not user:
            H = self.H
            return H.p(
                "Log in to view the projects and editions that you are working on."
            )

        projects = self.projects
        myIds = self.myIds
        siteRoles = self.siteRoles

        User = self.User
        inPower = self.inPower
        user = self.user

        projectsAll = sorted(
            projects.values(), key=lambda x: (1 if x.isVisible else 0, x.title, x._id)
        )
        projectsMy = [p for p in projectsAll if p._id in (myIds.project or set())]

        myDetails = H.div(
            [
                H.h(1, "My details"),
                self._wrapUsers(siteRoles, theseUsers={User.user: (User, User.role)}),
            ],
            id="mydetails",
        )

        wrapped = []
        wrapped.append(H.h(1, "My projects and editions"))
        wrapped.append(
            H.div([self._wrapProject(p) for p in projectsMy])
            if len(projectsMy)
            else H.div("You do not have a specific role w.r.t. projects and editions.")
        )
        myProjects = H.div(wrapped, id="myprojects")
        allProjects = ""
        allUsers = ""

        if inPower:
            wrapped = []
            wrapped.append(self._wrapPubProjects())

            wrapped.append(H.h(1, "All projects and editions"))
            wrapped.append(
                H.div([self._wrapProject(p, myOnly=False) for p in projectsAll])
                if len(projectsAll)
                else H.div("There are no projects and no editions")
            )
            allProjects = H.div(wrapped, id="allprojects")

            wrapped = []
            wrapped.append(H.h(1, "Manage users"))
            wrapped.append(
                H.div(self._wrapUsers(siteRoles, workIndicator=True), cls="susers")
            )
            allUsers = H.div(wrapped, id="allusers")

        return H.div([myDetails, myProjects, allProjects, allUsers], cls="myadmin")

    def _wrapPubProjects(self):
        """Generate HTML for the published projects in admin view.

        Currently, it provides

        *   a control to edit the list of featured published projects in a
            rather coarse manner.
        *   a control to regenerate the static pages

        Parameters
        ----------
        project: AttrDict
            A project record
        myOnly: boolean, optional False
            Whether to show only the editions in the project that are associated
            with the current user.

        Returns
        -------
        string
            The HTML
        """
        H = self.H

        Content = self.Content
        (table, siteId, site) = Content.relevant()

        wrapped = []
        wrapped.append(H.h(1, "Published projects"))
        wrapped.append(H.h(2, "Featured published projects"))
        wrapped.append(Content.getValue(table, site, "featured"))
        wrapped.append(H.h(2, "Regenerate HTML for published projects"))

        wrapped.append(
            H.a(
                "Regenerate",
                "/generate",
                title="Regenerate HTML for published projects",
                cls="button large",
            )
        )
        return H.div(wrapped, id="pubprojects")

    def _wrapProject(self, project, myOnly=True):
        """Generate HTML for a project in admin view.

        Parameters
        ----------
        project: AttrDict
            A project record
        myOnly: boolean, optional False
            Whether to show only the editions in the project that are associated
            with the current user.

        Returns
        -------
        string
            The HTML
        """
        H = self.H
        myIds = self.myIds
        projectRoles = self.projectRoles
        representations = self.representations
        css = self.css

        stat = project.isVisible or False
        status = representations.isVisible[stat]
        statusCls = css.isVisible[stat]

        editions = project.editions or AttrDict()

        theseEditions = sorted(
            (
                e
                for e in editions.values()
                if not myOnly or e._id in (myIds.edition or set())
            ),
            key=lambda x: (x.title, x._id),
        )
        title = project.title
        if not title:
            title = H.i("no title")

        return H.div(
            [
                H.div(
                    [
                        H.div(status, cls=f"pestatus {statusCls}"),
                        H.a(title, f"project/{project._id}", cls="ptitle"),
                        H.div(
                            self._wrapUsers(
                                projectRoles, table="project", record=project
                            ),
                            cls="pusers",
                        ),
                    ],
                    cls="phead",
                ),
                H.div(
                    "no editions"
                    if len(theseEditions) == 0
                    else [self._wrapEdition(e) for e in theseEditions],
                    cls="peditions",
                ),
            ],
            cls="pentry",
        )

    def _wrapEdition(self, edition):
        """Generate HTML for an edition in admin view.

        Parameters
        ----------
        edition: AttrDict
            An edition record

        Returns
        -------
        string
            The HTML
        """
        H = self.H
        editionRoles = self.editionRoles
        representations = self.representations
        css = self.css

        stat = edition.isPublished or False
        status = representations.isPublished[stat]
        statusCls = css.isPublished[stat]

        title = edition.title
        if not title:
            title = H.i("no title")

        return H.div(
            [
                H.div(status, cls=f"pestatus {statusCls}"),
                H.a(title, f"edition/{edition._id}", cls="etitle"),
                H.div(
                    self._wrapUsers(editionRoles, table="edition", record=edition),
                    cls="eusers",
                ),
            ],
            cls="eentry",
        )

    def _wrapUsers(
        self, itemRoles, workIndicator=False, table=None, record=None, theseUsers=None
    ):
        """Generate HTML for a list of users.

        It is dependent on the value of table/record whether it is about the users
        of a specific project/edition or the site-wide users.

        Parameters
        ----------
        itemRoles: dict
            Dictionary keyed by the possible roles and valued by the description
            of that role.
        workIndicator: boolean, optional False
            Whether to mention the number of projects and editions the user is
            involved in.
        table: string, optional None
            Either `project` or `edition`, indicates what users we are listing:
            related to a project or to an edition.
        record: AttrDict, optional None
            If `table` is passed and not None, here is the specific project or edition
            whose users should be listed.
        theseUsers: dict, optional None
            If table/record is not specified, you can specify users here.
            If this parameter is also None, then all users in the system are taken.
            Otherwise you have to specify a dict, keyed by user eppns and valued by
            tuples consisting of a user record and a role.

        Returns
        -------
        string
            The HTML
        """
        H = self.H
        Settings = self.Settings
        runProd = Settings.runProd
        users = self.users
        inPower = self.inPower
        doingAllUsers = theseUsers is None

        if record is None:
            if theseUsers is None:
                theseUsers = {
                    u: (uRecord, uRecord.role) for (u, uRecord) in users.items()
                }
        else:
            theseUsers = record.users

        recordId = record._id if record else None
        wrapped = []

        if theseUsers is None:
            rolesRep = ", ".join(f"{itemRoles[r]}s" for r in itemRoles if r)
            tableRep = table if table else "site"
            wrapped.append(f"No {rolesRep} for this {tableRep}")

        else:
            for u, (uRecord, role) in sorted(
                theseUsers.items(), key=lambda x: (x[1][1], x[1][0].nickname, x[0])
            ):
                (editable, otherRoles) = self.authUser(u, table=table, record=record)
                wrapped.append(
                    self._wrapUser(
                        u,
                        uRecord,
                        role,
                        editable,
                        otherRoles,
                        itemRoles,
                        table,
                        recordId,
                        workIndicator,
                    )
                )

        (editable, otherRoles) = self.authUser(None, table=table, record=record)

        if editable:
            wrapped.append(
                self._wrapLinkUser(otherRoles - {None}, itemRoles, table, recordId)
            )

        if record is None and not runProd and inPower and doingAllUsers:
            wrapped.append(
                H.div(
                    H.content(
                        H.input(
                            "", "text", placeholder="new test user name", cls="narrow"
                        ),
                        H.iconx(
                            "create",
                            title="add a new test user",
                            href="/user/create",
                            cls="button small",
                        ),
                    ),
                    cls="createuser",
                )
            )

        return "".join(wrapped)

    def _wrapLinkUser(self, roles, itemRoles, table, recordId):
        """Generate HTML to add a user in a specified role.

        Parameters
        ----------
        roles: string | void
            The choice of roles that a new user can get.
        itemRoles: dict
            Dictionary keyed by the possible roles and valued by the description
            of that role.
        table: string
            Either None or `project` or `edition`, indicates to what we are linking
            users: site-wide users or users related to a project or to an edition.
        recordId: ObjectId or None
            Either None or the id of a project or edition, corresponding to the
            `table` parameter.

        Returns
        -------
        string
            The HTML
        """
        H = self.H
        users = self.users

        linkButton = H.actionButton("edit_link")
        cancelButton = H.actionButton("edit_cancel")
        saveButton = H.actionButton("edit_save")
        messages = H.div("", cls="editmsgs")

        roleChoice = H.div(
            [H.div(itemRoles[r], cls="role button", role=r) for r in roles],
            cls="chooseroles",
        )
        userChoice = H.div(
            [
                H.div(uRecord.nickname, cls="user button", user=u)
                for (u, uRecord) in users.items()
            ],
            cls="chooseusers",
        )

        return H.div(
            [linkButton, cancelButton, saveButton, messages, roleChoice, userChoice],
            cls="linkusers",
            saveurl=f"/link/user/{table}/{recordId}",
        )

    def _wrapUser(
        self,
        u,
        uRecord,
        role,
        editable,
        otherRoles,
        itemRoles,
        table,
        recordId,
        workIndicator,
    ):
        """Generate HTML for a single user and his role.

        Parameters
        ----------
        u: string
            The eppn of the user.
        uRecord: AttrDict
            The user record.
        role: string | void
            The actual role of the user, or None if the user has no role.
        editable: boolean
            Whether the current user may change the role of this user.
        otherRoles: frozenset
            The other roles that the user may get from the current user.
        itemRoles: dict
            Dictionary keyed by the possible roles and valued by the description
            of that role.
        table: string
            Either None or `project` or `edition`, indicates what users we
            are listing: site-wide users or users related to a project or to an edition.
        recordId: ObjectId or None
            Either None or the id of a project or edition, corresponding to the
            `table` parameter.
        workIndicator: boolean
            Whether to mention the number of projects and editions the user is
            involved in.

        Returns
        -------
        string
            The HTML
        """
        H = self.H
        Content = self.Content

        if workIndicator:
            user = uRecord.user
            (nProjects, nEditions) = Content.getUserWork(user)
            indicator = [
                H.span(f"projects: {nProjects},", cls="dreport"),
                H.nbsp,
                H.span(f"editions: {nEditions}", cls="dreport"),
            ]
            if nProjects == 0 and nEditions == 0 and role == "user":
                indicator.extend(
                    [
                        H.nbsp,
                        H.iconx(
                            "delete",
                            title="delete this user",
                            href=f"/user/delete/{user}",
                            cls="button small",
                        ),
                    ]
                )
        else:
            indicator = []

        return H.div(
            [
                H.div(uRecord.nickname, cls="user"),
                *self._wrapRole(
                    u, itemRoles, role, editable, otherRoles, table, recordId
                ),
                *indicator,
            ],
            cls="userroles",
        )

    def _wrapRole(self, u, itemRoles, role, editable, otherRoles, table, recordId):
        """Generate HTML for a role.

        This may or may not be an editable widget, depending on whether there
        are options to choose from.

        Site-wide users have a single site-wide role. But project/edition users
        can have zero or one role wrt projects/editions.

        Parameters
        ----------
        u: string
            The eppn of the user.
        itemRoles: dict
            Dictionary keyed by the possible roles and valued by the description
            of that role.
        role: string | void
            The actual role of the user, or None if the user has no role.
        editable: boolean
            Whether the current user may change the role of this user.
        otherRoles: frozenset
            The other roles that the target user may be assigned by the current user.
        table: string
            Either None or `project` or `edition`, indicates what users we
            are listing: site-wide users or users related to a project or to an edition.
        recordId: ObjectId or None
            Either None or the id of a project or edition, corresponding to the
            `table` parameter.

        Returns
        -------
        string
            The HTML
        """
        roleRank = self.roleRank
        H = self.H

        actualRole = H.div(itemRoles[role], role=role, cls="role")
        tableRep = f"/{table}" if table else ""
        recordRep = f"/{recordId}" if table else ""

        allRoles = sorted({role} | otherRoles, key=roleRank)

        if editable:
            saveUrl = f"/save/role/{u}/{tableRep}{recordRep}"
            updateButton = H.actionButton("edit_assign")
            cancelButton = H.actionButton("edit_cancel")
            saveButton = H.actionButton("edit_save")
            messages = H.div("", cls="editmsgs")

            widget = H.div(
                [
                    updateButton,
                    saveButton,
                    cancelButton,
                    messages,
                    H.div(
                        [
                            H.div(
                                itemRoles[r],
                                cls="role button " + ("on" if r == role else ""),
                                role=r,
                            )
                            for r in allRoles
                        ],
                        cls="edit roles",
                        saveurl=saveUrl,
                        origvalue=role,
                    ),
                ],
                cls="editroles",
            )
        else:
            widget = ""

        return [actualRole, widget]

    def saveRole(self, u, newRole, table=None, recordId=None):
        """Saves a role into a user or cross table record.

        It will be checked whether the new role is valid, and whether the user
        has permission to perform this role assignment.

        Parameters
        ----------
        u: string
            The eppn of the user.
        newRole: string | void
            The new role for the target user. None means: the target user will
            lose his role.
        table: string
            Either None or `project` or `edition`, indicates what users we
            are listing: site-wide users or users related to a project or to an edition.
        recordId: ObjectId or None
            Either None or the id of a project or edition, corresponding to the
            `table` parameter.

        Returns
        -------
        dict
            with keys:

            * `stat`: indicates whether the save may proceed;
            * `messages`: list of messages for the user,
            * `updated`: new content for the user managment div.
        """
        Mongo = self.Mongo
        siteRoles = self.siteRoles
        projectRoles = self.projectRoles
        editionRoles = self.editionRoles
        itemRoles = (
            siteRoles
            if table is None
            else projectRoles
            if table == "edition"
            else editionRoles
        )
        newRoleRep = itemRoles[newRole]

        (editable, otherRoles) = self.authUser(u, table=table, record=recordId)
        if not editable:
            return dict(stat=False, messages=[["error", "update not allowed"]])

        if newRole not in otherRoles:
            return dict(stat=False, messages=[["error", f"invalid role: {newRoleRep}"]])

        if table is None:
            result = Mongo.updateRecord("user", dict(role=newRole), user=u)
        else:
            (recordId, record) = Mongo.get(table, recordId)
            if recordId is None:
                return dict(stat=False, messages=[["error", "record does not exist"]])

            criteria = {"user": u, f"{table}Id": recordId}
            if newRole is None:
                result = Mongo.deleteRecord(f"{table}User", **criteria)
                if not result:
                    msg = f"could not unlink this user from the {table}"
            else:
                result = Mongo.updateRecord(
                    f"{table}User", dict(role=newRole), **criteria
                )
                if not result:
                    msg = (
                        "could not change this user's role to "
                        f"{newRoleRep} wrt. the {table}"
                    )

        if not result:
            return dict(stat=False, messages=[["error", msg]])

        self.update()
        return dict(stat=True, messages=[], updated=self.wrap())

    def linkUser(self, u, newRole, table, recordId):
        """Links a user in certain role to a project/edition record.

        It will be checked whether the new role is valid, and whether the user
        has permission to perform this role assignment.

        If the user is already linked to that project/edition, his role
        will be updated, otherwise a new link will be created.

        Parameters
        ----------
        u: string
            The eppn of the target user.
        newRole: string
            The new role for the target user.
        table: string
            Either `project` or `edition`.
        recordId: ObjectId
            The id of a project or edition, corresponding to the
            `table` parameter.

        Returns
        -------
        dict
            with keys:

            * `stat`: indicates whether the save may proceed;
            * `messages`: list of messages for the user,
            * `updated`: new content for the user managment div.
        """
        Mongo = self.Mongo
        siteRoles = self.siteRoles
        projectRoles = self.projectRoles
        editionRoles = self.editionRoles
        itemRoles = (
            siteRoles
            if table is None
            else projectRoles
            if table == "edition"
            else editionRoles
        )
        newRoleRep = itemRoles[newRole]

        (editable, otherRoles) = self.authUser(None, table=table, record=recordId)
        if not editable:
            return dict(stat=False, messages=[["error", "update not allowed"]])

        if newRole not in otherRoles:
            return dict(stat=False, messages=[["error", f"invalid role: {newRoleRep}"]])

        (recordId, record) = Mongo.get(table, recordId)
        if recordId is None:
            return dict(stat=False, messages=[["error", "record does not exist"]])

        criteria = {"user": u, f"{table}Id": recordId}
        crossRecord = Mongo.getRecord(table, warn=False, stop=False, **criteria)

        if crossRecord:
            result = Mongo.updateRecord(f"{table}User", dict(role=newRole), **criteria)
            if not result:
                msg = (
                    "could not change this user's role to "
                    f"{newRoleRep} wrt. the {table}"
                )
        else:
            fields = {"user": u, f"{table}Id": recordId, "role": newRole}
            result = Mongo.insertRecord(f"{table}User", **fields)
            if not result:
                msg = f"could not link this user to {table} as {newRoleRep}"

        if not result:
            return dict(stat=False, messages=[["error", msg]])

        self.update()
        return dict(stat=True, messages=[], updated=self.wrap())

    def createUser(self, user):
        """Creates new user.

        This action is only valid in test, pilot or custom mode.
        The current user must be an admin or root.

        Parameters
        ----------
        user: string
            The user name of the user.
            This should be different from the user names of existing users.
            The name may only contain the ASCII digits and lower case letters,
            plus dash, dot, and underscore.

            Spaces will be replaced by dots; all other illegal characters by
            underscores.

        Returns
        -------
        dict
            Contains the following keys:

            * `status`: whether the create action was successful
            * `messages`: messages issued during the process
        """
        Mongo = self.Mongo
        Settings = self.Settings
        runProd = Settings.runProd
        inPower = self.inPower

        status = True
        messages = []

        if inPower and not runProd:
            if len(user) == 0:
                status = False
                messages.append(("error", "name should not be empty"))

            else:
                name = USERNAME_RE.sub("_", user.lower().replace(" ", "."))
                if name != user:
                    messages.append(("warning", f"user {user} to be saved as {name}"))

                userLong = f"{name:0>16}"
                userInfo = dict(
                    nickname=name,
                    user=userLong,
                    role="user",
                    isSpecial=True,
                )
                userId = Mongo.insertRecord("user", **userInfo)

                if not userId:
                    status = False
                    messages.append(
                        ("error", f"could not add {name} to the user table")
                    )
        else:
            status = False

            if not inPower:
                messages.append(("error", "adding a user needs admin privileges"))
            if runProd:
                messages.append(
                    ("error", "adding a user not allowed in production mode")
                )

        self.update()
        return dict(status=status, messages=messages, name=user)

    def deleteUser(self, user):
        """Deletes a test user.

        This action is only valid in test, pilot or custom mode.
        The current user must be an admin or root.
        The user to be deleted should be a test user, not linked to any project or
        edition.

        Parameters
        ----------
        user: string
            The user name of the user.

        Returns
        -------
        dict
            Contains the following keys:

            * `status`: whether the create action was successful
            * `messages`: messages issued during the process
        """
        Mongo = self.Mongo
        Settings = self.Settings
        runProd = Settings.runProd
        inPower = self.inPower

        status = True
        messages = []

        if inPower and not runProd:
            if len(user) == 0:
                status = False
                messages.append(("error", "name should not be empty"))
            else:
                good = Mongo.deleteRecord("user", isSpecial=True, stop=False, user=user)

                if not good:
                    status = False
                    messages.append(
                        ("error", f"could not delete {user} from the user table")
                    )
        else:
            status = False

            if not inPower:
                messages.append(("error", "deleting a user needs admin privileges"))
            if runProd:
                messages.append(
                    ("error", "deleting a user not allowed in production mode")
                )

        self.update()
        return dict(status=status, messages=messages)

Classes

class Admin (Content)

Get the list of relevant projects, editions and users.

Admin users get the list of all users.

Normal users get the list of users associated with

  • the project of which they are organiser
  • the editions of which they are editor or reviewer

Guests and not-logged-in users cannot see any user.

If the user has rights to modify the association between users and projects/editions, he will get the controls to do so.

Upon initialization the project/edition/user data will be read and assembled in a form ready for generating html.

Overview of assembled data

projects

All project records in the system, keyed by id. If a project has editions, the editions are available under key editions as a dict of edition records keyed by id. If a project has users, the users are available under key users as a dict keyed by user id and valued by the user records.

If an edition has users, the users are available under key users as a dict keyed by role and then by user id and valued by a tuple of the user record and his role.

users

All user records in the system, keyed by id.

myIds

All project and edition ids to which the current user has a relationship. It is a dict with keys project and edition and the values are sets of ids.

Expand source code Browse git
class Admin:
    def __init__(self, Content):
        """Get the list of relevant projects, editions and users.

        Admin users get the list of all users.

        Normal users get the list of users associated with

        * the project of which they are organiser
        * the editions of which they are editor or reviewer

        Guests and not-logged-in users cannot see any user.

        If the user has rights to modify the association
        between users and projects/editions, he will get
        the controls to do so.

        Upon initialization the project/edition/user data will be read
        and assembled in a form ready for generating html.

        ## Overview of assembled data

        ### projects

        All project records in the system, keyed by id.
        If a project has editions, the editions are
        available under key `editions` as a dict of edition records keyed by id.
        If a project has users, the users are
        available under key `users` as a dict keyed by user id
        and valued by the user records.

        If an edition has users, the users are
        available under key `users` as a dict keyed by role and then by user id
        and valued by a tuple of the user record and his role.

        ### users

        All user records in the system, keyed by id.

        ### myIds

        All project and edition ids to which the current user has a relationship.
        It is a dict with keys `project` and `edition` and the values are sets
        of ids.
        """
        self.Content = Content

        Messages = Content.Messages
        Messages.debugAdd(self)

        Settings = Content.Settings
        H = Settings.H
        authSettings = Settings.auth
        roleInfo = authSettings.roles
        roleRank = authSettings.roleRank
        representations = Settings.representations
        css = Settings.css

        Mongo = Content.Mongo
        Auth = Content.Auth

        self.Settings = Settings
        self.Mongo = Mongo
        self.Auth = Auth
        self.H = H
        self.representations = representations
        self.css = css

        siteRoles = roleInfo.site
        projectRoles = roleInfo.project
        editionRoles = roleInfo.edition

        self.siteRoles = siteRoles
        self.projectRoles = projectRoles
        self.editionRoles = editionRoles
        self.roleRank = roleRank
        self.siteRolesList = tuple(sorted(siteRoles, key=roleRank))
        self.projectRolesList = tuple(sorted(projectRoles, key=roleRank))
        self.editionRolesList = tuple(sorted(editionRoles, key=roleRank))
        self.siteRolesSet = frozenset(siteRoles)
        self.projectRolesSet = frozenset(projectRoles)
        self.editionRolesSet = frozenset(editionRoles)

        self.update()

    def update(self):
        """Reread the tables of users, projects, editions.

        Typically needed when you have used an admin function to perform
        a user administration action.

        This may change the permissions and hence the visiblity of projects and editions.
        It also changes the possible user management actions in the future.
        """
        Mongo = self.Mongo
        Auth = self.Auth
        Auth.identify()
        User = Auth.myDetails()
        user = User.user

        self.User = User
        self.user = user

        if not user:
            self.myRole = None
            self.inPower = False
            return

        myRole = User.role
        inPower = myRole in {"root", "admin"}

        self.myRole = myRole
        self.inPower = inPower

        siteRecord = Mongo.getRecord("site")
        userList = Mongo.getList("user", sort="nickname")
        projectList = Mongo.getList("project", sort="title")
        editionList = Mongo.getList("edition", sort="title")
        projectLinks = Mongo.getList("projectUser")
        editionLinks = Mongo.getList("editionUser")

        users = AttrDict({x.user: x for x in userList})
        projects = AttrDict({x._id: x for x in projectList})
        editions = AttrDict({x._id: x for x in editionList})

        myIds = AttrDict()

        self.site = siteRecord
        self.users = users
        self.projects = projects
        self.editions = editions
        self.myIds = myIds

        for eRecord in editionList:
            eId = eRecord._id
            pId = eRecord.projectId
            projects[pId].setdefault("editions", {})[eId] = eRecord

        for pLink in projectLinks:
            role = pLink.role

            if role:
                u = pLink.user
                uRecord = users[u]
                if uRecord is None:
                    continue
                pId = pLink.projectId
                pRecord = projects[pId]
                if pRecord is None:
                    continue
                pRecord.setdefault("users", AttrDict())

                if user == u:
                    myIds.setdefault("project", set()).add(pId)
                    for eId in pRecord.editions or []:
                        myIds.setdefault("edition", set()).add(eId)

                pRecord.setdefault("users", AttrDict())[u] = (uRecord, role)

        for eLink in editionLinks:
            role = eLink.role
            if role:
                u = eLink.user
                uRecord = users[u]
                if uRecord is None:
                    continue
                eId = eLink.editionId
                eRecord = editions[eId]
                if eRecord is None:
                    continue
                pId = eRecord.projectId

                if user == u:
                    myIds.setdefault("project", set()).add(pId)
                    myIds.setdefault("edition", set()).add(eId)

                eRecord.setdefault("users", AttrDict())[u] = (uRecord, role)

    def authUser(self, otherUser, table=None, record=None):
        """Check whether a user may change the role of another user.

        The questions are:

        "which *other* site-wide roles can the current user assign to the other
        user?" (when no table or record is given).

        "which project/edition scoped roles can the current user assign to or
        remove from the other user
        with respect to the relevant record in the given table?".

        Note that the current site-wide role of the other user is never included
        in the set of resulting roles.

        There are also additional business rules.
        This function will return the empty set if these rules are violated.

        **Business rules**

        *   Users have exactly one site-wise role.
        *   Users may demote themselves.
        *   Users may not promote themselves unless ... see later.
        *   Users may have zero or one project/edition-scoped role per
            project/edition
        *   When assigning new site-wide or project/edition-scoped roles, these
            roles must be valid roles for that scope.
        *   When assigning a new site-wide role, None is not one
            of the possible new roles:
            you cannot change the status of an authenticated user to "not
            logged in".
        *   When assigning project/edition scoped roles, removing such a
            role from a user for a certain project/edition means that the
            other user is removed from that project or edition.
        *   Roles are ranked in power. Users with a higher role are also authorised
            to all things for which lower roles give authorisation.

            The site-wide roles are ranked as:

            ```
            root - admin - user - guest - not logged in
            ```

            The project/edition roles are ranked as:

            ```
            (project) organiser - (edition) editor - (edition) reviewer
            ```

            Site-wide power does not automatically carry over to project/edition-scoped
            power.

        *   Users cannot promote or demote people that are currently as powerful
            as themselves.
        *   In normal cases there is exactly one root, but:
            *   If a situation occurs that there is no root and no admin, any authenticated
                user my grab the role of admin.
            *   If a situation occurs that there is no root, any admin may
                grab the role of root.
        *   Roots may appoint admins.
        *   Roots and admins may change site-wide roles.
        *   Roots and admins may appoint project organisers, but may not assign
            edition-scoped roles.
        *   Project organisers may appoint edition editors and reviewers.
        *   Edition editors may appoint edition reviewers.
        *   However, roots and admins may also be project organisers and
            edition editors for some projects and some editions.
        *   Normal users and guests can not administer site-wide roles.
        *   Guests can not be put in project/edition-scoped roles.

        Parameters
        ----------
        otherUser: string | void
            the other user as string (eppn)
            If None, the question is: what are the roles in which an other
            user may be put wrt to this project/edition?
        table: string, optional None
            the relevant table: `project` or `edition`;
            this is the table in which the record sits
            relative to which the other user will be assigned a role.
            If None, the role to be assigned is a site wide role.
        record: ObjectId | AttrDict, optional None
            the relevant record;
            it is the record relative to which the other user will be
            assigned an other role.
            If None, the role to be assigned is a site wide role.

        Returns
        -------
        boolean, frozenset
            The boolean indicates whether the current user may modify the role
            of the target user.

            The frozenset is the set of assignable roles to the other user
            by the current user with respect to the given table and record or site-wide.

            If the boolean is false, the frozenset is empty.
            But if the frozenset is empty it might be the case that the current
            user is allowed to remove the role of the target user.
        """
        myRole = self.myRole

        if myRole in {None, "guest"}:
            return (False, frozenset())

        user = self.user
        users = self.users
        nRoots = sum(1 for u in users.values() if u.role == "root")
        nAdmins = sum(1 for u in users.values() if u.role == "admin")
        iAmInPower = self.inPower
        otherUserRecord = users[otherUser] or AttrDict()
        otherRole = otherUserRecord.role
        otherIsInPower = otherRole in {"admin", "root"}

        nope = (False, frozenset())

        # side-wide assignments

        if table is None or record is None:
            # nobody can add site-wide users

            if otherUser is None:
                return nope

            siteRolesSet = self.siteRolesSet

            # if there are no admins and no roots,
            #   any admin may promote himself to root
            #   if there are no admins
            #     any authenticated user may promote himself to admin

            remainingRoles = frozenset(siteRolesSet - {None, otherRole})

            if nRoots == 0:
                if user == otherUser:
                    if nAdmins == 0:
                        if myRole == "user":
                            fineAdmin = (True, frozenset(["admin"]) | remainingRoles)
                            return fineAdmin
                    else:
                        if myRole == "admin":
                            fineRoot = (True, frozenset(["root"]) | remainingRoles)
                            return fineRoot

            # from here on, only admins and roots can change roles
            if not iAmInPower:
                return nope

            fine = (True, remainingRoles)

            # root is all powerful, only limited by other roots
            if myRole == "root":
                if user == otherUser or otherRole != "root":
                    return fine
                else:
                    return nope

            # from here on, myRole is admin, so "root" cannot be assigned

            remainingRoles = frozenset(remainingRoles - {"root"})
            fine = (True, remainingRoles)
            fineNoAdmin = (True, remainingRoles - {"admin"})

            # when the user changes his own role: can only demote
            if user == otherUser:
                return fineNoAdmin

            # people cannot affect other more or equally powerful people
            if otherIsInPower:
                return nope

            # people cannot promote others beyond their own level
            return fine

        # not a project or edition, or not a real record: Not allowed!

        if table not in {"project", "edition"} or record is None:
            return nope

        # project-scoped assignments

        projectRolesSet = self.projectRolesSet
        fine = (True, projectRolesSet)

        if table == "project":
            # only admins and roots can assign a project-scoped role
            if not iAmInPower:
                return nope

            # remaining cases are allowed
            return fine

        # remaining case: only edition scoped.

        if table != "edition":
            return nope

        # edition-scoped assignments

        Mongo = self.Mongo
        (recordId, record) = Mongo.get(table, record)
        if recordId is None:
            return nope

        projects = self.projects
        editionRolesSet = self.editionRolesSet

        # check whether the role is a edition-scoped role
        pRecord = projects[record.projectId]
        pUsers = pRecord.users or AttrDict()
        eUsers = record.users or AttrDict()

        otherProjectRole = (pUsers[otherUser] or (None, None))[1]
        otherEditionRole = (eUsers[otherUser] or (None, None))[1]

        myProjectRole = (pUsers[user] or (None, None))[1]
        myEditionRole = (eUsers[user] or (None, None))[1]

        # only organisers of the parent project can (un)assign an
        # edition editor

        iAmOrganiser = "organiser" == myProjectRole
        otherIsOrganiser = "organiser" == otherProjectRole
        iAmEditor = "editor" == myEditionRole
        otherIsEditor = "editor" == otherEditionRole

        # what I can do to myself

        fine = (True, editionRolesSet)
        fineNoEditor = (True, editionRolesSet - {"editor"})

        if user == otherUser:
            if iAmOrganiser or iAmEditor:
                return fine
            return nope

        # what I can do to others

        if otherUser is None:
            if iAmOrganiser:
                return fine
            if iAmEditor:
                return fineNoEditor

        if otherIsOrganiser:
            return nope

        if otherIsEditor:
            if iAmOrganiser:
                return fine
            return nope

        if iAmOrganiser:
            return fine
        if iAmEditor:
            return fineNoEditor

        return nope

    def wrap(self):
        """Produce a list of projects and editions and users for root/admin usage.

        The first overview shows all projects and editions
        with their associated users and roles.

        Only items that are relevant to the user are shown.

        If the user is authorised to change associations between
        users and items, they will be editable.

        The second overview is for admin/roots only.
        It shows a list of users and their site-wide roles, which can be changed.

        """
        H = self.H
        user = self.user

        if not user:
            H = self.H
            return H.p(
                "Log in to view the projects and editions that you are working on."
            )

        projects = self.projects
        myIds = self.myIds
        siteRoles = self.siteRoles

        User = self.User
        inPower = self.inPower
        user = self.user

        projectsAll = sorted(
            projects.values(), key=lambda x: (1 if x.isVisible else 0, x.title, x._id)
        )
        projectsMy = [p for p in projectsAll if p._id in (myIds.project or set())]

        myDetails = H.div(
            [
                H.h(1, "My details"),
                self._wrapUsers(siteRoles, theseUsers={User.user: (User, User.role)}),
            ],
            id="mydetails",
        )

        wrapped = []
        wrapped.append(H.h(1, "My projects and editions"))
        wrapped.append(
            H.div([self._wrapProject(p) for p in projectsMy])
            if len(projectsMy)
            else H.div("You do not have a specific role w.r.t. projects and editions.")
        )
        myProjects = H.div(wrapped, id="myprojects")
        allProjects = ""
        allUsers = ""

        if inPower:
            wrapped = []
            wrapped.append(self._wrapPubProjects())

            wrapped.append(H.h(1, "All projects and editions"))
            wrapped.append(
                H.div([self._wrapProject(p, myOnly=False) for p in projectsAll])
                if len(projectsAll)
                else H.div("There are no projects and no editions")
            )
            allProjects = H.div(wrapped, id="allprojects")

            wrapped = []
            wrapped.append(H.h(1, "Manage users"))
            wrapped.append(
                H.div(self._wrapUsers(siteRoles, workIndicator=True), cls="susers")
            )
            allUsers = H.div(wrapped, id="allusers")

        return H.div([myDetails, myProjects, allProjects, allUsers], cls="myadmin")

    def _wrapPubProjects(self):
        """Generate HTML for the published projects in admin view.

        Currently, it provides

        *   a control to edit the list of featured published projects in a
            rather coarse manner.
        *   a control to regenerate the static pages

        Parameters
        ----------
        project: AttrDict
            A project record
        myOnly: boolean, optional False
            Whether to show only the editions in the project that are associated
            with the current user.

        Returns
        -------
        string
            The HTML
        """
        H = self.H

        Content = self.Content
        (table, siteId, site) = Content.relevant()

        wrapped = []
        wrapped.append(H.h(1, "Published projects"))
        wrapped.append(H.h(2, "Featured published projects"))
        wrapped.append(Content.getValue(table, site, "featured"))
        wrapped.append(H.h(2, "Regenerate HTML for published projects"))

        wrapped.append(
            H.a(
                "Regenerate",
                "/generate",
                title="Regenerate HTML for published projects",
                cls="button large",
            )
        )
        return H.div(wrapped, id="pubprojects")

    def _wrapProject(self, project, myOnly=True):
        """Generate HTML for a project in admin view.

        Parameters
        ----------
        project: AttrDict
            A project record
        myOnly: boolean, optional False
            Whether to show only the editions in the project that are associated
            with the current user.

        Returns
        -------
        string
            The HTML
        """
        H = self.H
        myIds = self.myIds
        projectRoles = self.projectRoles
        representations = self.representations
        css = self.css

        stat = project.isVisible or False
        status = representations.isVisible[stat]
        statusCls = css.isVisible[stat]

        editions = project.editions or AttrDict()

        theseEditions = sorted(
            (
                e
                for e in editions.values()
                if not myOnly or e._id in (myIds.edition or set())
            ),
            key=lambda x: (x.title, x._id),
        )
        title = project.title
        if not title:
            title = H.i("no title")

        return H.div(
            [
                H.div(
                    [
                        H.div(status, cls=f"pestatus {statusCls}"),
                        H.a(title, f"project/{project._id}", cls="ptitle"),
                        H.div(
                            self._wrapUsers(
                                projectRoles, table="project", record=project
                            ),
                            cls="pusers",
                        ),
                    ],
                    cls="phead",
                ),
                H.div(
                    "no editions"
                    if len(theseEditions) == 0
                    else [self._wrapEdition(e) for e in theseEditions],
                    cls="peditions",
                ),
            ],
            cls="pentry",
        )

    def _wrapEdition(self, edition):
        """Generate HTML for an edition in admin view.

        Parameters
        ----------
        edition: AttrDict
            An edition record

        Returns
        -------
        string
            The HTML
        """
        H = self.H
        editionRoles = self.editionRoles
        representations = self.representations
        css = self.css

        stat = edition.isPublished or False
        status = representations.isPublished[stat]
        statusCls = css.isPublished[stat]

        title = edition.title
        if not title:
            title = H.i("no title")

        return H.div(
            [
                H.div(status, cls=f"pestatus {statusCls}"),
                H.a(title, f"edition/{edition._id}", cls="etitle"),
                H.div(
                    self._wrapUsers(editionRoles, table="edition", record=edition),
                    cls="eusers",
                ),
            ],
            cls="eentry",
        )

    def _wrapUsers(
        self, itemRoles, workIndicator=False, table=None, record=None, theseUsers=None
    ):
        """Generate HTML for a list of users.

        It is dependent on the value of table/record whether it is about the users
        of a specific project/edition or the site-wide users.

        Parameters
        ----------
        itemRoles: dict
            Dictionary keyed by the possible roles and valued by the description
            of that role.
        workIndicator: boolean, optional False
            Whether to mention the number of projects and editions the user is
            involved in.
        table: string, optional None
            Either `project` or `edition`, indicates what users we are listing:
            related to a project or to an edition.
        record: AttrDict, optional None
            If `table` is passed and not None, here is the specific project or edition
            whose users should be listed.
        theseUsers: dict, optional None
            If table/record is not specified, you can specify users here.
            If this parameter is also None, then all users in the system are taken.
            Otherwise you have to specify a dict, keyed by user eppns and valued by
            tuples consisting of a user record and a role.

        Returns
        -------
        string
            The HTML
        """
        H = self.H
        Settings = self.Settings
        runProd = Settings.runProd
        users = self.users
        inPower = self.inPower
        doingAllUsers = theseUsers is None

        if record is None:
            if theseUsers is None:
                theseUsers = {
                    u: (uRecord, uRecord.role) for (u, uRecord) in users.items()
                }
        else:
            theseUsers = record.users

        recordId = record._id if record else None
        wrapped = []

        if theseUsers is None:
            rolesRep = ", ".join(f"{itemRoles[r]}s" for r in itemRoles if r)
            tableRep = table if table else "site"
            wrapped.append(f"No {rolesRep} for this {tableRep}")

        else:
            for u, (uRecord, role) in sorted(
                theseUsers.items(), key=lambda x: (x[1][1], x[1][0].nickname, x[0])
            ):
                (editable, otherRoles) = self.authUser(u, table=table, record=record)
                wrapped.append(
                    self._wrapUser(
                        u,
                        uRecord,
                        role,
                        editable,
                        otherRoles,
                        itemRoles,
                        table,
                        recordId,
                        workIndicator,
                    )
                )

        (editable, otherRoles) = self.authUser(None, table=table, record=record)

        if editable:
            wrapped.append(
                self._wrapLinkUser(otherRoles - {None}, itemRoles, table, recordId)
            )

        if record is None and not runProd and inPower and doingAllUsers:
            wrapped.append(
                H.div(
                    H.content(
                        H.input(
                            "", "text", placeholder="new test user name", cls="narrow"
                        ),
                        H.iconx(
                            "create",
                            title="add a new test user",
                            href="/user/create",
                            cls="button small",
                        ),
                    ),
                    cls="createuser",
                )
            )

        return "".join(wrapped)

    def _wrapLinkUser(self, roles, itemRoles, table, recordId):
        """Generate HTML to add a user in a specified role.

        Parameters
        ----------
        roles: string | void
            The choice of roles that a new user can get.
        itemRoles: dict
            Dictionary keyed by the possible roles and valued by the description
            of that role.
        table: string
            Either None or `project` or `edition`, indicates to what we are linking
            users: site-wide users or users related to a project or to an edition.
        recordId: ObjectId or None
            Either None or the id of a project or edition, corresponding to the
            `table` parameter.

        Returns
        -------
        string
            The HTML
        """
        H = self.H
        users = self.users

        linkButton = H.actionButton("edit_link")
        cancelButton = H.actionButton("edit_cancel")
        saveButton = H.actionButton("edit_save")
        messages = H.div("", cls="editmsgs")

        roleChoice = H.div(
            [H.div(itemRoles[r], cls="role button", role=r) for r in roles],
            cls="chooseroles",
        )
        userChoice = H.div(
            [
                H.div(uRecord.nickname, cls="user button", user=u)
                for (u, uRecord) in users.items()
            ],
            cls="chooseusers",
        )

        return H.div(
            [linkButton, cancelButton, saveButton, messages, roleChoice, userChoice],
            cls="linkusers",
            saveurl=f"/link/user/{table}/{recordId}",
        )

    def _wrapUser(
        self,
        u,
        uRecord,
        role,
        editable,
        otherRoles,
        itemRoles,
        table,
        recordId,
        workIndicator,
    ):
        """Generate HTML for a single user and his role.

        Parameters
        ----------
        u: string
            The eppn of the user.
        uRecord: AttrDict
            The user record.
        role: string | void
            The actual role of the user, or None if the user has no role.
        editable: boolean
            Whether the current user may change the role of this user.
        otherRoles: frozenset
            The other roles that the user may get from the current user.
        itemRoles: dict
            Dictionary keyed by the possible roles and valued by the description
            of that role.
        table: string
            Either None or `project` or `edition`, indicates what users we
            are listing: site-wide users or users related to a project or to an edition.
        recordId: ObjectId or None
            Either None or the id of a project or edition, corresponding to the
            `table` parameter.
        workIndicator: boolean
            Whether to mention the number of projects and editions the user is
            involved in.

        Returns
        -------
        string
            The HTML
        """
        H = self.H
        Content = self.Content

        if workIndicator:
            user = uRecord.user
            (nProjects, nEditions) = Content.getUserWork(user)
            indicator = [
                H.span(f"projects: {nProjects},", cls="dreport"),
                H.nbsp,
                H.span(f"editions: {nEditions}", cls="dreport"),
            ]
            if nProjects == 0 and nEditions == 0 and role == "user":
                indicator.extend(
                    [
                        H.nbsp,
                        H.iconx(
                            "delete",
                            title="delete this user",
                            href=f"/user/delete/{user}",
                            cls="button small",
                        ),
                    ]
                )
        else:
            indicator = []

        return H.div(
            [
                H.div(uRecord.nickname, cls="user"),
                *self._wrapRole(
                    u, itemRoles, role, editable, otherRoles, table, recordId
                ),
                *indicator,
            ],
            cls="userroles",
        )

    def _wrapRole(self, u, itemRoles, role, editable, otherRoles, table, recordId):
        """Generate HTML for a role.

        This may or may not be an editable widget, depending on whether there
        are options to choose from.

        Site-wide users have a single site-wide role. But project/edition users
        can have zero or one role wrt projects/editions.

        Parameters
        ----------
        u: string
            The eppn of the user.
        itemRoles: dict
            Dictionary keyed by the possible roles and valued by the description
            of that role.
        role: string | void
            The actual role of the user, or None if the user has no role.
        editable: boolean
            Whether the current user may change the role of this user.
        otherRoles: frozenset
            The other roles that the target user may be assigned by the current user.
        table: string
            Either None or `project` or `edition`, indicates what users we
            are listing: site-wide users or users related to a project or to an edition.
        recordId: ObjectId or None
            Either None or the id of a project or edition, corresponding to the
            `table` parameter.

        Returns
        -------
        string
            The HTML
        """
        roleRank = self.roleRank
        H = self.H

        actualRole = H.div(itemRoles[role], role=role, cls="role")
        tableRep = f"/{table}" if table else ""
        recordRep = f"/{recordId}" if table else ""

        allRoles = sorted({role} | otherRoles, key=roleRank)

        if editable:
            saveUrl = f"/save/role/{u}/{tableRep}{recordRep}"
            updateButton = H.actionButton("edit_assign")
            cancelButton = H.actionButton("edit_cancel")
            saveButton = H.actionButton("edit_save")
            messages = H.div("", cls="editmsgs")

            widget = H.div(
                [
                    updateButton,
                    saveButton,
                    cancelButton,
                    messages,
                    H.div(
                        [
                            H.div(
                                itemRoles[r],
                                cls="role button " + ("on" if r == role else ""),
                                role=r,
                            )
                            for r in allRoles
                        ],
                        cls="edit roles",
                        saveurl=saveUrl,
                        origvalue=role,
                    ),
                ],
                cls="editroles",
            )
        else:
            widget = ""

        return [actualRole, widget]

    def saveRole(self, u, newRole, table=None, recordId=None):
        """Saves a role into a user or cross table record.

        It will be checked whether the new role is valid, and whether the user
        has permission to perform this role assignment.

        Parameters
        ----------
        u: string
            The eppn of the user.
        newRole: string | void
            The new role for the target user. None means: the target user will
            lose his role.
        table: string
            Either None or `project` or `edition`, indicates what users we
            are listing: site-wide users or users related to a project or to an edition.
        recordId: ObjectId or None
            Either None or the id of a project or edition, corresponding to the
            `table` parameter.

        Returns
        -------
        dict
            with keys:

            * `stat`: indicates whether the save may proceed;
            * `messages`: list of messages for the user,
            * `updated`: new content for the user managment div.
        """
        Mongo = self.Mongo
        siteRoles = self.siteRoles
        projectRoles = self.projectRoles
        editionRoles = self.editionRoles
        itemRoles = (
            siteRoles
            if table is None
            else projectRoles
            if table == "edition"
            else editionRoles
        )
        newRoleRep = itemRoles[newRole]

        (editable, otherRoles) = self.authUser(u, table=table, record=recordId)
        if not editable:
            return dict(stat=False, messages=[["error", "update not allowed"]])

        if newRole not in otherRoles:
            return dict(stat=False, messages=[["error", f"invalid role: {newRoleRep}"]])

        if table is None:
            result = Mongo.updateRecord("user", dict(role=newRole), user=u)
        else:
            (recordId, record) = Mongo.get(table, recordId)
            if recordId is None:
                return dict(stat=False, messages=[["error", "record does not exist"]])

            criteria = {"user": u, f"{table}Id": recordId}
            if newRole is None:
                result = Mongo.deleteRecord(f"{table}User", **criteria)
                if not result:
                    msg = f"could not unlink this user from the {table}"
            else:
                result = Mongo.updateRecord(
                    f"{table}User", dict(role=newRole), **criteria
                )
                if not result:
                    msg = (
                        "could not change this user's role to "
                        f"{newRoleRep} wrt. the {table}"
                    )

        if not result:
            return dict(stat=False, messages=[["error", msg]])

        self.update()
        return dict(stat=True, messages=[], updated=self.wrap())

    def linkUser(self, u, newRole, table, recordId):
        """Links a user in certain role to a project/edition record.

        It will be checked whether the new role is valid, and whether the user
        has permission to perform this role assignment.

        If the user is already linked to that project/edition, his role
        will be updated, otherwise a new link will be created.

        Parameters
        ----------
        u: string
            The eppn of the target user.
        newRole: string
            The new role for the target user.
        table: string
            Either `project` or `edition`.
        recordId: ObjectId
            The id of a project or edition, corresponding to the
            `table` parameter.

        Returns
        -------
        dict
            with keys:

            * `stat`: indicates whether the save may proceed;
            * `messages`: list of messages for the user,
            * `updated`: new content for the user managment div.
        """
        Mongo = self.Mongo
        siteRoles = self.siteRoles
        projectRoles = self.projectRoles
        editionRoles = self.editionRoles
        itemRoles = (
            siteRoles
            if table is None
            else projectRoles
            if table == "edition"
            else editionRoles
        )
        newRoleRep = itemRoles[newRole]

        (editable, otherRoles) = self.authUser(None, table=table, record=recordId)
        if not editable:
            return dict(stat=False, messages=[["error", "update not allowed"]])

        if newRole not in otherRoles:
            return dict(stat=False, messages=[["error", f"invalid role: {newRoleRep}"]])

        (recordId, record) = Mongo.get(table, recordId)
        if recordId is None:
            return dict(stat=False, messages=[["error", "record does not exist"]])

        criteria = {"user": u, f"{table}Id": recordId}
        crossRecord = Mongo.getRecord(table, warn=False, stop=False, **criteria)

        if crossRecord:
            result = Mongo.updateRecord(f"{table}User", dict(role=newRole), **criteria)
            if not result:
                msg = (
                    "could not change this user's role to "
                    f"{newRoleRep} wrt. the {table}"
                )
        else:
            fields = {"user": u, f"{table}Id": recordId, "role": newRole}
            result = Mongo.insertRecord(f"{table}User", **fields)
            if not result:
                msg = f"could not link this user to {table} as {newRoleRep}"

        if not result:
            return dict(stat=False, messages=[["error", msg]])

        self.update()
        return dict(stat=True, messages=[], updated=self.wrap())

    def createUser(self, user):
        """Creates new user.

        This action is only valid in test, pilot or custom mode.
        The current user must be an admin or root.

        Parameters
        ----------
        user: string
            The user name of the user.
            This should be different from the user names of existing users.
            The name may only contain the ASCII digits and lower case letters,
            plus dash, dot, and underscore.

            Spaces will be replaced by dots; all other illegal characters by
            underscores.

        Returns
        -------
        dict
            Contains the following keys:

            * `status`: whether the create action was successful
            * `messages`: messages issued during the process
        """
        Mongo = self.Mongo
        Settings = self.Settings
        runProd = Settings.runProd
        inPower = self.inPower

        status = True
        messages = []

        if inPower and not runProd:
            if len(user) == 0:
                status = False
                messages.append(("error", "name should not be empty"))

            else:
                name = USERNAME_RE.sub("_", user.lower().replace(" ", "."))
                if name != user:
                    messages.append(("warning", f"user {user} to be saved as {name}"))

                userLong = f"{name:0>16}"
                userInfo = dict(
                    nickname=name,
                    user=userLong,
                    role="user",
                    isSpecial=True,
                )
                userId = Mongo.insertRecord("user", **userInfo)

                if not userId:
                    status = False
                    messages.append(
                        ("error", f"could not add {name} to the user table")
                    )
        else:
            status = False

            if not inPower:
                messages.append(("error", "adding a user needs admin privileges"))
            if runProd:
                messages.append(
                    ("error", "adding a user not allowed in production mode")
                )

        self.update()
        return dict(status=status, messages=messages, name=user)

    def deleteUser(self, user):
        """Deletes a test user.

        This action is only valid in test, pilot or custom mode.
        The current user must be an admin or root.
        The user to be deleted should be a test user, not linked to any project or
        edition.

        Parameters
        ----------
        user: string
            The user name of the user.

        Returns
        -------
        dict
            Contains the following keys:

            * `status`: whether the create action was successful
            * `messages`: messages issued during the process
        """
        Mongo = self.Mongo
        Settings = self.Settings
        runProd = Settings.runProd
        inPower = self.inPower

        status = True
        messages = []

        if inPower and not runProd:
            if len(user) == 0:
                status = False
                messages.append(("error", "name should not be empty"))
            else:
                good = Mongo.deleteRecord("user", isSpecial=True, stop=False, user=user)

                if not good:
                    status = False
                    messages.append(
                        ("error", f"could not delete {user} from the user table")
                    )
        else:
            status = False

            if not inPower:
                messages.append(("error", "deleting a user needs admin privileges"))
            if runProd:
                messages.append(
                    ("error", "deleting a user not allowed in production mode")
                )

        self.update()
        return dict(status=status, messages=messages)

Methods

def authUser(self, otherUser, table=None, record=None)

Check whether a user may change the role of another user.

The questions are:

"which other site-wide roles can the current user assign to the other user?" (when no table or record is given).

"which project/edition scoped roles can the current user assign to or remove from the other user with respect to the relevant record in the given table?".

Note that the current site-wide role of the other user is never included in the set of resulting roles.

There are also additional business rules. This function will return the empty set if these rules are violated.

Business rules

  • Users have exactly one site-wise role.
  • Users may demote themselves.
  • Users may not promote themselves unless … see later.
  • Users may have zero or one project/edition-scoped role per project/edition
  • When assigning new site-wide or project/edition-scoped roles, these roles must be valid roles for that scope.
  • When assigning a new site-wide role, None is not one of the possible new roles: you cannot change the status of an authenticated user to "not logged in".
  • When assigning project/edition scoped roles, removing such a role from a user for a certain project/edition means that the other user is removed from that project or edition.
  • Roles are ranked in power. Users with a higher role are also authorised to all things for which lower roles give authorisation.

    The site-wide roles are ranked as:

    root - admin - user - guest - not logged in

    The project/edition roles are ranked as:

    (project) organiser - (edition) editor - (edition) reviewer

    Site-wide power does not automatically carry over to project/edition-scoped power.

  • Users cannot promote or demote people that are currently as powerful as themselves.

  • In normal cases there is exactly one root, but:
    • If a situation occurs that there is no root and no admin, any authenticated user my grab the role of admin.
    • If a situation occurs that there is no root, any admin may grab the role of root.
  • Roots may appoint admins.
  • Roots and admins may change site-wide roles.
  • Roots and admins may appoint project organisers, but may not assign edition-scoped roles.
  • Project organisers may appoint edition editors and reviewers.
  • Edition editors may appoint edition reviewers.
  • However, roots and admins may also be project organisers and edition editors for some projects and some editions.
  • Normal users and guests can not administer site-wide roles.
  • Guests can not be put in project/edition-scoped roles.

Parameters

otherUser : string | void
the other user as string (eppn) If None, the question is: what are the roles in which an other user may be put wrt to this project/edition?
table : string, optional None
the relevant table: project or edition; this is the table in which the record sits relative to which the other user will be assigned a role. If None, the role to be assigned is a site wide role.
record : ObjectId | AttrDict, optional None
the relevant record; it is the record relative to which the other user will be assigned an other role. If None, the role to be assigned is a site wide role.

Returns

boolean, frozenset

The boolean indicates whether the current user may modify the role of the target user.

The frozenset is the set of assignable roles to the other user by the current user with respect to the given table and record or site-wide.

If the boolean is false, the frozenset is empty. But if the frozenset is empty it might be the case that the current user is allowed to remove the role of the target user.

Expand source code Browse git
def authUser(self, otherUser, table=None, record=None):
    """Check whether a user may change the role of another user.

    The questions are:

    "which *other* site-wide roles can the current user assign to the other
    user?" (when no table or record is given).

    "which project/edition scoped roles can the current user assign to or
    remove from the other user
    with respect to the relevant record in the given table?".

    Note that the current site-wide role of the other user is never included
    in the set of resulting roles.

    There are also additional business rules.
    This function will return the empty set if these rules are violated.

    **Business rules**

    *   Users have exactly one site-wise role.
    *   Users may demote themselves.
    *   Users may not promote themselves unless ... see later.
    *   Users may have zero or one project/edition-scoped role per
        project/edition
    *   When assigning new site-wide or project/edition-scoped roles, these
        roles must be valid roles for that scope.
    *   When assigning a new site-wide role, None is not one
        of the possible new roles:
        you cannot change the status of an authenticated user to "not
        logged in".
    *   When assigning project/edition scoped roles, removing such a
        role from a user for a certain project/edition means that the
        other user is removed from that project or edition.
    *   Roles are ranked in power. Users with a higher role are also authorised
        to all things for which lower roles give authorisation.

        The site-wide roles are ranked as:

        ```
        root - admin - user - guest - not logged in
        ```

        The project/edition roles are ranked as:

        ```
        (project) organiser - (edition) editor - (edition) reviewer
        ```

        Site-wide power does not automatically carry over to project/edition-scoped
        power.

    *   Users cannot promote or demote people that are currently as powerful
        as themselves.
    *   In normal cases there is exactly one root, but:
        *   If a situation occurs that there is no root and no admin, any authenticated
            user my grab the role of admin.
        *   If a situation occurs that there is no root, any admin may
            grab the role of root.
    *   Roots may appoint admins.
    *   Roots and admins may change site-wide roles.
    *   Roots and admins may appoint project organisers, but may not assign
        edition-scoped roles.
    *   Project organisers may appoint edition editors and reviewers.
    *   Edition editors may appoint edition reviewers.
    *   However, roots and admins may also be project organisers and
        edition editors for some projects and some editions.
    *   Normal users and guests can not administer site-wide roles.
    *   Guests can not be put in project/edition-scoped roles.

    Parameters
    ----------
    otherUser: string | void
        the other user as string (eppn)
        If None, the question is: what are the roles in which an other
        user may be put wrt to this project/edition?
    table: string, optional None
        the relevant table: `project` or `edition`;
        this is the table in which the record sits
        relative to which the other user will be assigned a role.
        If None, the role to be assigned is a site wide role.
    record: ObjectId | AttrDict, optional None
        the relevant record;
        it is the record relative to which the other user will be
        assigned an other role.
        If None, the role to be assigned is a site wide role.

    Returns
    -------
    boolean, frozenset
        The boolean indicates whether the current user may modify the role
        of the target user.

        The frozenset is the set of assignable roles to the other user
        by the current user with respect to the given table and record or site-wide.

        If the boolean is false, the frozenset is empty.
        But if the frozenset is empty it might be the case that the current
        user is allowed to remove the role of the target user.
    """
    myRole = self.myRole

    if myRole in {None, "guest"}:
        return (False, frozenset())

    user = self.user
    users = self.users
    nRoots = sum(1 for u in users.values() if u.role == "root")
    nAdmins = sum(1 for u in users.values() if u.role == "admin")
    iAmInPower = self.inPower
    otherUserRecord = users[otherUser] or AttrDict()
    otherRole = otherUserRecord.role
    otherIsInPower = otherRole in {"admin", "root"}

    nope = (False, frozenset())

    # side-wide assignments

    if table is None or record is None:
        # nobody can add site-wide users

        if otherUser is None:
            return nope

        siteRolesSet = self.siteRolesSet

        # if there are no admins and no roots,
        #   any admin may promote himself to root
        #   if there are no admins
        #     any authenticated user may promote himself to admin

        remainingRoles = frozenset(siteRolesSet - {None, otherRole})

        if nRoots == 0:
            if user == otherUser:
                if nAdmins == 0:
                    if myRole == "user":
                        fineAdmin = (True, frozenset(["admin"]) | remainingRoles)
                        return fineAdmin
                else:
                    if myRole == "admin":
                        fineRoot = (True, frozenset(["root"]) | remainingRoles)
                        return fineRoot

        # from here on, only admins and roots can change roles
        if not iAmInPower:
            return nope

        fine = (True, remainingRoles)

        # root is all powerful, only limited by other roots
        if myRole == "root":
            if user == otherUser or otherRole != "root":
                return fine
            else:
                return nope

        # from here on, myRole is admin, so "root" cannot be assigned

        remainingRoles = frozenset(remainingRoles - {"root"})
        fine = (True, remainingRoles)
        fineNoAdmin = (True, remainingRoles - {"admin"})

        # when the user changes his own role: can only demote
        if user == otherUser:
            return fineNoAdmin

        # people cannot affect other more or equally powerful people
        if otherIsInPower:
            return nope

        # people cannot promote others beyond their own level
        return fine

    # not a project or edition, or not a real record: Not allowed!

    if table not in {"project", "edition"} or record is None:
        return nope

    # project-scoped assignments

    projectRolesSet = self.projectRolesSet
    fine = (True, projectRolesSet)

    if table == "project":
        # only admins and roots can assign a project-scoped role
        if not iAmInPower:
            return nope

        # remaining cases are allowed
        return fine

    # remaining case: only edition scoped.

    if table != "edition":
        return nope

    # edition-scoped assignments

    Mongo = self.Mongo
    (recordId, record) = Mongo.get(table, record)
    if recordId is None:
        return nope

    projects = self.projects
    editionRolesSet = self.editionRolesSet

    # check whether the role is a edition-scoped role
    pRecord = projects[record.projectId]
    pUsers = pRecord.users or AttrDict()
    eUsers = record.users or AttrDict()

    otherProjectRole = (pUsers[otherUser] or (None, None))[1]
    otherEditionRole = (eUsers[otherUser] or (None, None))[1]

    myProjectRole = (pUsers[user] or (None, None))[1]
    myEditionRole = (eUsers[user] or (None, None))[1]

    # only organisers of the parent project can (un)assign an
    # edition editor

    iAmOrganiser = "organiser" == myProjectRole
    otherIsOrganiser = "organiser" == otherProjectRole
    iAmEditor = "editor" == myEditionRole
    otherIsEditor = "editor" == otherEditionRole

    # what I can do to myself

    fine = (True, editionRolesSet)
    fineNoEditor = (True, editionRolesSet - {"editor"})

    if user == otherUser:
        if iAmOrganiser or iAmEditor:
            return fine
        return nope

    # what I can do to others

    if otherUser is None:
        if iAmOrganiser:
            return fine
        if iAmEditor:
            return fineNoEditor

    if otherIsOrganiser:
        return nope

    if otherIsEditor:
        if iAmOrganiser:
            return fine
        return nope

    if iAmOrganiser:
        return fine
    if iAmEditor:
        return fineNoEditor

    return nope
def createUser(self, user)

Creates new user.

This action is only valid in test, pilot or custom mode. The current user must be an admin or root.

Parameters

user : string

The user name of the user. This should be different from the user names of existing users. The name may only contain the ASCII digits and lower case letters, plus dash, dot, and underscore.

Spaces will be replaced by dots; all other illegal characters by underscores.

Returns

dict

Contains the following keys:

  • status: whether the create action was successful
  • messages: messages issued during the process
Expand source code Browse git
def createUser(self, user):
    """Creates new user.

    This action is only valid in test, pilot or custom mode.
    The current user must be an admin or root.

    Parameters
    ----------
    user: string
        The user name of the user.
        This should be different from the user names of existing users.
        The name may only contain the ASCII digits and lower case letters,
        plus dash, dot, and underscore.

        Spaces will be replaced by dots; all other illegal characters by
        underscores.

    Returns
    -------
    dict
        Contains the following keys:

        * `status`: whether the create action was successful
        * `messages`: messages issued during the process
    """
    Mongo = self.Mongo
    Settings = self.Settings
    runProd = Settings.runProd
    inPower = self.inPower

    status = True
    messages = []

    if inPower and not runProd:
        if len(user) == 0:
            status = False
            messages.append(("error", "name should not be empty"))

        else:
            name = USERNAME_RE.sub("_", user.lower().replace(" ", "."))
            if name != user:
                messages.append(("warning", f"user {user} to be saved as {name}"))

            userLong = f"{name:0>16}"
            userInfo = dict(
                nickname=name,
                user=userLong,
                role="user",
                isSpecial=True,
            )
            userId = Mongo.insertRecord("user", **userInfo)

            if not userId:
                status = False
                messages.append(
                    ("error", f"could not add {name} to the user table")
                )
    else:
        status = False

        if not inPower:
            messages.append(("error", "adding a user needs admin privileges"))
        if runProd:
            messages.append(
                ("error", "adding a user not allowed in production mode")
            )

    self.update()
    return dict(status=status, messages=messages, name=user)
def deleteUser(self, user)

Deletes a test user.

This action is only valid in test, pilot or custom mode. The current user must be an admin or root. The user to be deleted should be a test user, not linked to any project or edition.

Parameters

user : string
The user name of the user.

Returns

dict

Contains the following keys:

  • status: whether the create action was successful
  • messages: messages issued during the process
Expand source code Browse git
def deleteUser(self, user):
    """Deletes a test user.

    This action is only valid in test, pilot or custom mode.
    The current user must be an admin or root.
    The user to be deleted should be a test user, not linked to any project or
    edition.

    Parameters
    ----------
    user: string
        The user name of the user.

    Returns
    -------
    dict
        Contains the following keys:

        * `status`: whether the create action was successful
        * `messages`: messages issued during the process
    """
    Mongo = self.Mongo
    Settings = self.Settings
    runProd = Settings.runProd
    inPower = self.inPower

    status = True
    messages = []

    if inPower and not runProd:
        if len(user) == 0:
            status = False
            messages.append(("error", "name should not be empty"))
        else:
            good = Mongo.deleteRecord("user", isSpecial=True, stop=False, user=user)

            if not good:
                status = False
                messages.append(
                    ("error", f"could not delete {user} from the user table")
                )
    else:
        status = False

        if not inPower:
            messages.append(("error", "deleting a user needs admin privileges"))
        if runProd:
            messages.append(
                ("error", "deleting a user not allowed in production mode")
            )

    self.update()
    return dict(status=status, messages=messages)
def linkUser(self, u, newRole, table, recordId)

Links a user in certain role to a project/edition record.

It will be checked whether the new role is valid, and whether the user has permission to perform this role assignment.

If the user is already linked to that project/edition, his role will be updated, otherwise a new link will be created.

Parameters

u : string
The eppn of the target user.
newRole : string
The new role for the target user.
table : string
Either project or edition.
recordId : ObjectId
The id of a project or edition, corresponding to the table parameter.

Returns

dict

with keys:

  • stat: indicates whether the save may proceed;
  • messages: list of messages for the user,
  • updated: new content for the user managment div.
Expand source code Browse git
def linkUser(self, u, newRole, table, recordId):
    """Links a user in certain role to a project/edition record.

    It will be checked whether the new role is valid, and whether the user
    has permission to perform this role assignment.

    If the user is already linked to that project/edition, his role
    will be updated, otherwise a new link will be created.

    Parameters
    ----------
    u: string
        The eppn of the target user.
    newRole: string
        The new role for the target user.
    table: string
        Either `project` or `edition`.
    recordId: ObjectId
        The id of a project or edition, corresponding to the
        `table` parameter.

    Returns
    -------
    dict
        with keys:

        * `stat`: indicates whether the save may proceed;
        * `messages`: list of messages for the user,
        * `updated`: new content for the user managment div.
    """
    Mongo = self.Mongo
    siteRoles = self.siteRoles
    projectRoles = self.projectRoles
    editionRoles = self.editionRoles
    itemRoles = (
        siteRoles
        if table is None
        else projectRoles
        if table == "edition"
        else editionRoles
    )
    newRoleRep = itemRoles[newRole]

    (editable, otherRoles) = self.authUser(None, table=table, record=recordId)
    if not editable:
        return dict(stat=False, messages=[["error", "update not allowed"]])

    if newRole not in otherRoles:
        return dict(stat=False, messages=[["error", f"invalid role: {newRoleRep}"]])

    (recordId, record) = Mongo.get(table, recordId)
    if recordId is None:
        return dict(stat=False, messages=[["error", "record does not exist"]])

    criteria = {"user": u, f"{table}Id": recordId}
    crossRecord = Mongo.getRecord(table, warn=False, stop=False, **criteria)

    if crossRecord:
        result = Mongo.updateRecord(f"{table}User", dict(role=newRole), **criteria)
        if not result:
            msg = (
                "could not change this user's role to "
                f"{newRoleRep} wrt. the {table}"
            )
    else:
        fields = {"user": u, f"{table}Id": recordId, "role": newRole}
        result = Mongo.insertRecord(f"{table}User", **fields)
        if not result:
            msg = f"could not link this user to {table} as {newRoleRep}"

    if not result:
        return dict(stat=False, messages=[["error", msg]])

    self.update()
    return dict(stat=True, messages=[], updated=self.wrap())
def saveRole(self, u, newRole, table=None, recordId=None)

Saves a role into a user or cross table record.

It will be checked whether the new role is valid, and whether the user has permission to perform this role assignment.

Parameters

u : string
The eppn of the user.
newRole : string | void
The new role for the target user. None means: the target user will lose his role.
table : string
Either None or project or edition, indicates what users we are listing: site-wide users or users related to a project or to an edition.
recordId : ObjectId or None
Either None or the id of a project or edition, corresponding to the table parameter.

Returns

dict

with keys:

  • stat: indicates whether the save may proceed;
  • messages: list of messages for the user,
  • updated: new content for the user managment div.
Expand source code Browse git
def saveRole(self, u, newRole, table=None, recordId=None):
    """Saves a role into a user or cross table record.

    It will be checked whether the new role is valid, and whether the user
    has permission to perform this role assignment.

    Parameters
    ----------
    u: string
        The eppn of the user.
    newRole: string | void
        The new role for the target user. None means: the target user will
        lose his role.
    table: string
        Either None or `project` or `edition`, indicates what users we
        are listing: site-wide users or users related to a project or to an edition.
    recordId: ObjectId or None
        Either None or the id of a project or edition, corresponding to the
        `table` parameter.

    Returns
    -------
    dict
        with keys:

        * `stat`: indicates whether the save may proceed;
        * `messages`: list of messages for the user,
        * `updated`: new content for the user managment div.
    """
    Mongo = self.Mongo
    siteRoles = self.siteRoles
    projectRoles = self.projectRoles
    editionRoles = self.editionRoles
    itemRoles = (
        siteRoles
        if table is None
        else projectRoles
        if table == "edition"
        else editionRoles
    )
    newRoleRep = itemRoles[newRole]

    (editable, otherRoles) = self.authUser(u, table=table, record=recordId)
    if not editable:
        return dict(stat=False, messages=[["error", "update not allowed"]])

    if newRole not in otherRoles:
        return dict(stat=False, messages=[["error", f"invalid role: {newRoleRep}"]])

    if table is None:
        result = Mongo.updateRecord("user", dict(role=newRole), user=u)
    else:
        (recordId, record) = Mongo.get(table, recordId)
        if recordId is None:
            return dict(stat=False, messages=[["error", "record does not exist"]])

        criteria = {"user": u, f"{table}Id": recordId}
        if newRole is None:
            result = Mongo.deleteRecord(f"{table}User", **criteria)
            if not result:
                msg = f"could not unlink this user from the {table}"
        else:
            result = Mongo.updateRecord(
                f"{table}User", dict(role=newRole), **criteria
            )
            if not result:
                msg = (
                    "could not change this user's role to "
                    f"{newRoleRep} wrt. the {table}"
                )

    if not result:
        return dict(stat=False, messages=[["error", msg]])

    self.update()
    return dict(stat=True, messages=[], updated=self.wrap())
def update(self)

Reread the tables of users, projects, editions.

Typically needed when you have used an admin function to perform a user administration action.

This may change the permissions and hence the visiblity of projects and editions. It also changes the possible user management actions in the future.

Expand source code Browse git
def update(self):
    """Reread the tables of users, projects, editions.

    Typically needed when you have used an admin function to perform
    a user administration action.

    This may change the permissions and hence the visiblity of projects and editions.
    It also changes the possible user management actions in the future.
    """
    Mongo = self.Mongo
    Auth = self.Auth
    Auth.identify()
    User = Auth.myDetails()
    user = User.user

    self.User = User
    self.user = user

    if not user:
        self.myRole = None
        self.inPower = False
        return

    myRole = User.role
    inPower = myRole in {"root", "admin"}

    self.myRole = myRole
    self.inPower = inPower

    siteRecord = Mongo.getRecord("site")
    userList = Mongo.getList("user", sort="nickname")
    projectList = Mongo.getList("project", sort="title")
    editionList = Mongo.getList("edition", sort="title")
    projectLinks = Mongo.getList("projectUser")
    editionLinks = Mongo.getList("editionUser")

    users = AttrDict({x.user: x for x in userList})
    projects = AttrDict({x._id: x for x in projectList})
    editions = AttrDict({x._id: x for x in editionList})

    myIds = AttrDict()

    self.site = siteRecord
    self.users = users
    self.projects = projects
    self.editions = editions
    self.myIds = myIds

    for eRecord in editionList:
        eId = eRecord._id
        pId = eRecord.projectId
        projects[pId].setdefault("editions", {})[eId] = eRecord

    for pLink in projectLinks:
        role = pLink.role

        if role:
            u = pLink.user
            uRecord = users[u]
            if uRecord is None:
                continue
            pId = pLink.projectId
            pRecord = projects[pId]
            if pRecord is None:
                continue
            pRecord.setdefault("users", AttrDict())

            if user == u:
                myIds.setdefault("project", set()).add(pId)
                for eId in pRecord.editions or []:
                    myIds.setdefault("edition", set()).add(eId)

            pRecord.setdefault("users", AttrDict())[u] = (uRecord, role)

    for eLink in editionLinks:
        role = eLink.role
        if role:
            u = eLink.user
            uRecord = users[u]
            if uRecord is None:
                continue
            eId = eLink.editionId
            eRecord = editions[eId]
            if eRecord is None:
                continue
            pId = eRecord.projectId

            if user == u:
                myIds.setdefault("project", set()).add(pId)
                myIds.setdefault("edition", set()).add(eId)

            eRecord.setdefault("users", AttrDict())[u] = (uRecord, role)
def wrap(self)

Produce a list of projects and editions and users for root/admin usage.

The first overview shows all projects and editions with their associated users and roles.

Only items that are relevant to the user are shown.

If the user is authorised to change associations between users and items, they will be editable.

The second overview is for admin/roots only. It shows a list of users and their site-wide roles, which can be changed.

Expand source code Browse git
def wrap(self):
    """Produce a list of projects and editions and users for root/admin usage.

    The first overview shows all projects and editions
    with their associated users and roles.

    Only items that are relevant to the user are shown.

    If the user is authorised to change associations between
    users and items, they will be editable.

    The second overview is for admin/roots only.
    It shows a list of users and their site-wide roles, which can be changed.

    """
    H = self.H
    user = self.user

    if not user:
        H = self.H
        return H.p(
            "Log in to view the projects and editions that you are working on."
        )

    projects = self.projects
    myIds = self.myIds
    siteRoles = self.siteRoles

    User = self.User
    inPower = self.inPower
    user = self.user

    projectsAll = sorted(
        projects.values(), key=lambda x: (1 if x.isVisible else 0, x.title, x._id)
    )
    projectsMy = [p for p in projectsAll if p._id in (myIds.project or set())]

    myDetails = H.div(
        [
            H.h(1, "My details"),
            self._wrapUsers(siteRoles, theseUsers={User.user: (User, User.role)}),
        ],
        id="mydetails",
    )

    wrapped = []
    wrapped.append(H.h(1, "My projects and editions"))
    wrapped.append(
        H.div([self._wrapProject(p) for p in projectsMy])
        if len(projectsMy)
        else H.div("You do not have a specific role w.r.t. projects and editions.")
    )
    myProjects = H.div(wrapped, id="myprojects")
    allProjects = ""
    allUsers = ""

    if inPower:
        wrapped = []
        wrapped.append(self._wrapPubProjects())

        wrapped.append(H.h(1, "All projects and editions"))
        wrapped.append(
            H.div([self._wrapProject(p, myOnly=False) for p in projectsAll])
            if len(projectsAll)
            else H.div("There are no projects and no editions")
        )
        allProjects = H.div(wrapped, id="allprojects")

        wrapped = []
        wrapped.append(H.h(1, "Manage users"))
        wrapped.append(
            H.div(self._wrapUsers(siteRoles, workIndicator=True), cls="susers")
        )
        allUsers = H.div(wrapped, id="allusers")

    return H.div([myDetails, myProjects, allProjects, allUsers], cls="myadmin")