Edit on GitHub

common.lib.user

User class

  1"""
  2User class
  3"""
  4import html2text
  5import hashlib
  6import smtplib
  7import socket
  8import bcrypt
  9import json
 10import time
 11import os
 12
 13from email.mime.multipart import MIMEMultipart
 14from email.mime.text import MIMEText
 15
 16from common.config_manager import ConfigWrapper
 17from common.lib.helpers import send_email
 18from common.lib.exceptions import DataSetException
 19
 20
 21class User:
 22    """
 23    User class
 24
 25    Compatible with Flask-Login
 26    """
 27    data = None
 28    userdata = None
 29    is_authenticated = False
 30    is_active = False
 31    is_anonymous = True
 32    config = None
 33    db = None
 34
 35    name = "anonymous"
 36
 37    @staticmethod
 38    def get_by_login(db, name, password, config=None):
 39        """
 40        Get user object, if login is correct
 41
 42        If the login data supplied to this method is correct, a new user object
 43        that is marked as authenticated is returned.
 44
 45        :param db:  Database connection object
 46        :param name:  User name
 47        :param password:  User password
 48        :param config:  Configuration manager. Can be used for request-aware user objects using ConfigWrapper. Empty to
 49        use a global configuration manager.
 50        :return:  User object, or `None` if login was invalid
 51        """
 52        user = db.fetchone("SELECT * FROM users WHERE name = %s", (name,))
 53        if not user or not user.get("password", None):
 54            # registration not finished yet
 55            return None
 56        elif not user or not bcrypt.checkpw(password.encode("ascii"), user["password"].encode("ascii")):
 57            # non-existing user or wrong password
 58            return None
 59        else:
 60            # valid login!
 61            return User(db, user, authenticated=True, config=config)
 62
 63    @staticmethod
 64    def get_by_name(db, name, config=None):
 65        """
 66        Get user object for given user name
 67
 68        :param db:  Database connection object
 69        :param str name:  Username to get object for
 70        :param config:  Configuration manager. Can be used for request-aware user objects using ConfigWrapper. Empty to
 71        use a global configuration manager.
 72        :return:  User object, or `None` for invalid user name
 73        """
 74        user = db.fetchone("SELECT * FROM users WHERE name = %s", (name,))
 75        if not user:
 76            return None
 77        else:
 78            return User(db, user, config=config)
 79
 80    @staticmethod
 81    def get_by_token(db, token, config=None):
 82        """
 83        Get user object for given token, if token is valid
 84
 85        :param db:  Database connection object
 86        :param str token:  Token to get object for
 87        :param config:  Configuration manager. Can be used for request-aware user objects using ConfigWrapper. Empty to
 88        use a global configuration manager.
 89        :return:  User object, or `None` for invalid token
 90        """
 91        user = db.fetchone(
 92            "SELECT * FROM users WHERE register_token = %s AND (timestamp_token = 0 OR timestamp_token > %s)",
 93            (token, int(time.time()) - (7 * 86400)))
 94        if not user:
 95            return None
 96        else:
 97            return User(db, user, config=config)
 98
 99    def __init__(self, db, data, authenticated=False, config=None):
100        """
101        Instantiate user object
102
103        Also sets the properties required by Flask-Login.
104
105        :param db:  Database connection object
106        :param data:  User data
107        :param authenticated:  Whether the user should be marked as authenticated
108        """
109        self.db = db
110        self.data = data
111
112        try:
113            self.userdata = json.loads(self.data["userdata"])
114        except (TypeError, json.JSONDecodeError):
115            self.userdata = {}
116
117        if self.data["name"] != "anonymous":
118            self.is_anonymous = False
119            self.is_active = True
120
121        self.name = self.data["name"]
122        self.is_authenticated = authenticated
123
124        self.userdata = json.loads(self.data.get("userdata", "{}"))
125
126        if not self.is_anonymous and self.is_authenticated:
127            self.db.update("users", where={"name": self.data["name"]}, data={"timestamp_seen": int(time.time())})
128
129        if config:
130            self.with_config(config)
131
132    def authenticate(self):
133        """
134        Mark user object as authenticated.
135        """
136        self.is_authenticated = True
137
138    def get_id(self):
139        """
140        Get user ID
141
142        :return:  User ID
143        """
144        return self.data["name"]
145
146    def get_name(self):
147        """
148        Get user name
149
150        This is usually the user ID. For the two special users, provide a nicer
151        name to display in interfaces, etc.
152
153        :return: User name
154        """
155        if self.data["name"] == "anonymous":
156            return "Anonymous"
157        elif self.data["name"] == "autologin":
158            return self.config.get("flask.autologin.name") if self.config else "Autologin"
159        else:
160            return self.data["name"]
161
162    def get_token(self):
163        """
164        Get password reset token
165
166        May be empty or invalid!
167
168        :return str: Password reset token
169        """
170        return self.generate_token(regenerate=False)
171
172    def with_config(self, config, rewrap=True):
173        """
174        Connect user to configuration manager
175
176        By default, the user object reads from the global configuration
177        manager. For frontend operations it may be desireable to use a
178        request-aware configuration manager, but this is only available after
179        the user has been instantiated. This method can thus be used to connect
180        the user to that config manager later when it is available.
181
182        :param config:  Configuration manager object
183        :param bool rewrap:  Re-wrap with this user as the user context?
184        :return:
185        """
186        if rewrap:
187            self.config = ConfigWrapper(config, user=self)
188        else:
189            self.config = config
190
191    def clear_token(self):
192        """
193        Reset password rest token
194
195        Clears the token and token timestamp. This allows requesting a new one
196        even if the old one had not expired yet.
197
198        :return:
199        """
200        self.db.update("users", data={"register_token": "", "timestamp_token": 0}, where={"name": self.get_id()})
201
202    def can_access_dataset(self, dataset, role=None):
203        """
204        Check if this user should be able to access a given dataset.
205
206        This depends mostly on the dataset's owner, which should match the
207        user if the dataset is private. If the dataset is not private, or
208        if the user is an admin or the dataset is private but assigned to
209        an anonymous user, the dataset can be accessed.
210
211        :param dataset:  The dataset to check access to
212        :return bool:
213        """
214        if not dataset.is_private:
215            return True
216
217        elif self.is_admin:
218            return True
219        
220        elif self.config.get("privileges.can_view_private_datasets", user=self):
221            # Allowed to see dataset, but perhaps not run processors (need privileges.admin.can_manipulate_all_datasets or dataset ownership)
222            return True
223
224        elif dataset.is_accessible_by(self, role=role):
225            return True
226
227        elif dataset.get_owners == ("anonymous",):
228            return True
229
230        else:
231            return False
232
233    @property
234    def is_special(self):
235        """
236        Check if user is special user
237
238        :return:  Whether the user is the anonymous user, or the automatically
239        logged in user.
240        """
241        return self.get_id() in ("autologin", "anonymous")
242
243    @property
244    def is_admin(self):
245        """
246        Check if user is an administrator
247
248        :return bool:
249        """
250        try:
251            return "admin" in self.data["tags"]
252        except (ValueError, TypeError):
253            # invalid JSON?
254            return False
255
256    @property
257    def is_deactivated(self):
258        """
259        Check if user has been deactivated
260
261        :return bool:
262        """
263        return self.data.get("is_deactivated", False)
264
265    def email_token(self, new=False):
266        """
267        Send user an e-mail with a password reset link
268
269        Generates a token that the user can use to reset their password. The
270        token is valid for 72 hours.
271
272        Note that this requires a mail server to be configured, or a
273        `RuntimeError` will be raised. If a server is configured but the mail
274        still fails to send, it will also raise a `RuntimeError`. Note that
275        in these cases a token is still created and valid (the user just gets
276        no notification, but an admin could forward the correct link).
277
278        If the user is a 'special' user, a `ValueError` is raised.
279
280        :param bool new:  Is this the first time setting a password for this
281                          account?
282        :return str:  Link for the user to set their password with
283        """
284        if not self.config:
285            raise ValueError("User not instantiated with a configuration reader. Provide a ConfigManager at "
286                             "instantiation or use with_config().")
287
288        if not self.config.get('mail.server'):
289            raise RuntimeError("No e-mail server configured. 4CAT cannot send any e-mails.")
290
291        if self.is_special:
292            raise ValueError("Cannot send password reset e-mails for a special user.")
293
294        username = self.get_id()
295
296        # generate a password reset token
297        register_token = self.generate_token(regenerate=True)
298
299        # prepare welcome e-mail
300        sender = self.config.get('mail.noreply')
301        message = MIMEMultipart("alternative")
302        message["From"] = sender
303        message["To"] = username
304
305        # the actual e-mail...
306        url_base = self.config.get("flask.server_name")
307        protocol = "https" if self.config.get("flask.https") else "http"
308        url = "%s://%s/reset-password/?token=%s" % (protocol, url_base, register_token)
309
310        # we use slightly different e-mails depending on whether this is the first time setting a password
311        if new:
312
313            message["Subject"] = "Account created"
314            mail = """
315			<p>Hello %s,</p>
316			<p>A 4CAT account has been created for you. You can now log in to 4CAT at <a href="%s://%s">%s</a>.</p>
317			<p>Note that before you log in, you will need to set a password. You can do so via the following link:</p>
318			<p><a href="%s">%s</a></p> 
319			<p>Please complete your registration within 72 hours as the link above will become invalid after this time.</p>
320			""" % (username, protocol, url_base, url_base, url, url)
321        else:
322
323            message["Subject"] = "Password reset"
324            mail = """
325			<p>Hello %s,</p>
326			<p>Someone has requested a password reset for your 4CAT account. If that someone is you, great! If not, feel free to ignore this e-mail.</p>
327			<p>You can change your password via the following link:</p>
328			<p><a href="%s">%s</a></p> 
329			<p>Please do this within 72 hours as the link above will become invalid after this time.</p>
330			""" % (username, url, url)
331
332        # provide a plain-text alternative as well
333        html_parser = html2text.HTML2Text()
334        message.attach(MIMEText(html_parser.handle(mail), "plain"))
335        message.attach(MIMEText(mail, "html"))
336
337        # try to send it
338        try:
339            send_email([username], message, self.config)
340            return url
341        except (smtplib.SMTPException, ConnectionRefusedError, socket.timeout, socket.gaierror) as e:
342            raise RuntimeError("Could not send password reset e-mail: %s" % e)
343
344    def generate_token(self, username=None, regenerate=True):
345        """
346        Generate and store a new registration token for this user
347
348        Tokens are not re-generated if they exist already
349
350        :param username:  Username to generate for: if left empty, it will be
351        inferred from self.data
352        :param regenerate:  Force regenerating even if token exists
353        :return str:  The token
354        """
355        if self.data.get("register_token", None) and not regenerate:
356            return self.data["register_token"]
357
358        if not username:
359            username = self.data["name"]
360
361        register_token = hashlib.sha256()
362        register_token.update(os.urandom(128))
363        register_token = register_token.hexdigest()
364        self.db.update("users", data={"register_token": register_token, "timestamp_token": int(time.time())},
365                       where={"name": username})
366
367        return register_token
368
369    def get_value(self, key, default=None):
370        """
371        Get persistently stored user property
372
373        :param key:  Name of item to get
374        :param default:  What to return if key is not avaiable (default None)
375        :return:
376        """
377        return self.userdata.get(key, default)
378
379    def set_value(self, key, value):
380        """
381        Set persistently stored user property
382
383        :param key:  Name of item to store
384        :param value:  Value
385        :return:
386        """
387        self.userdata[key] = value
388        self.data["userdata"] = json.dumps(self.userdata)
389
390        self.db.update("users", where={"name": self.get_id()}, data={"userdata": json.dumps(self.userdata)})
391
392    def set_password(self, password):
393        """
394        Set user password
395
396        :param password:  Password to set
397        """
398        if self.is_anonymous:
399            raise Exception("Cannot set password for anonymous user")
400
401        salt = bcrypt.gensalt()
402        password_hash = bcrypt.hashpw(password.encode("ascii"), salt)
403
404        self.db.update("users", where={"name": self.data["name"]}, data={"password": password_hash.decode("utf-8")})
405
406    def add_notification(self, notification, expires=None, allow_dismiss=True):
407        """
408        Add a notification for this user
409
410        Notifications that already exist with the same parameters are not added
411        again.
412
413        :param str notification:  The content of the notification. Can contain
414        Markdown.
415        :param int expires:  Timestamp when the notification should expire. If
416        not provided, the notification does not expire
417        :param bool allow_dismiss:  Whether to allow a user to dismiss the
418        notification.
419        """
420        self.db.insert("users_notifications", {
421            "username": self.get_id(),
422            "notification": notification,
423            "timestamp_expires": expires,
424            "allow_dismiss": allow_dismiss
425        }, safe=True)
426
427    def dismiss_notification(self, notification_id):
428        """
429        Dismiss a notification
430
431        The user can only dismiss notifications they can see!
432
433        :param int notification_id:  ID of the notification to dismiss
434        """
435        all_notifications = {n["id"]: n for n in self.get_notifications() if n["allow_dismiss"]}
436        if notification_id not in all_notifications:
437            return
438
439        # notifications with a canonical ID are just hidden, not deleted
440        # this is because they are received from a notification server, and
441        # deleting them would just re-add them on the next sync
442        if all_notifications[notification_id]["canonical_id"]:
443            self.db.update("users_notifications", data={"is_dismissed": True}, where={"id": notification_id})
444        else:
445            self.db.delete("users_notifications", where={"id": notification_id})
446
447    def get_notifications(self):
448        """
449        Get all notifications for this user
450
451        That is all the user's own notifications, plus those for the groups of
452        users this user belongs to. Dismissed notifications are ignored.
453
454        :return list:  Notifications, as a list of dictionaries
455        """
456        if not self.config:
457            # User not logged in; only view notifications for everyone
458            tag_recipients = ["!everyone"]
459        else:
460            tag_recipients = ["!everyone", *[f"!{tag}" for tag in self.config.get_active_tags(self)]]
461
462        if self.is_admin:
463            # for backwards compatibility - used to be called '!admins' even if the tag is 'admin'
464            tag_recipients.append("!admins")
465
466        notifications = self.db.fetchall(
467            "SELECT n.* FROM users_notifications AS n, users AS u "
468            "WHERE u.name = %s "
469            "AND (u.name = n.username OR n.username IN %s)"
470            "AND n.is_dismissed = FALSE", (self.get_id(), tuple(tag_recipients)))
471
472        return notifications
473
474    def add_tag(self, tag):
475        """
476        Add tag to user
477
478        If the tag is already in the tag list, nothing happens.
479
480        :param str tag:  Tag
481        """
482        if tag not in self.data["tags"]:
483            self.data["tags"].append(tag)
484            self.sort_user_tags()
485            self.config.uncache_user_tags([self.get_id()])
486
487    def remove_tag(self, tag):
488        """
489        Remove tag from user
490
491        If the tag is not part of the tag list, nothing happens.
492
493        :param str tag:  Tag
494        """
495        if tag in self.data["tags"]:
496            self.data["tags"].remove(tag)
497            self.sort_user_tags()
498            self.config.uncache_user_tags([self.get_id()])
499
500    def sort_user_tags(self):
501        """
502        Ensure user tags are stored in the correct order
503
504        The order of the tags matters, since it decides which one will get to
505        override the global configuration. To avoid having to cross-reference
506        the canonical order every time the tags are queried, we ensure that the
507        tags are stored in the database in the right order to begin with. This
508        method ensures that.
509        """
510        if not self.config:
511            raise ValueError("User not instantiated with a configuration reader. Provide a ConfigManager at "
512                             "instantiation or use with_config().")
513
514        tags = self.data["tags"]
515        sorted_tags = []
516
517        for tag in self.config.get("flask.tag_order"):
518            if tag in tags:
519                sorted_tags.append(tag)
520
521        for tag in tags:
522            if tag not in sorted_tags:
523                sorted_tags.append(tag)
524
525        # whitespace isn't a tag
526        sorted_tags = [tag for tag in sorted_tags if tag.strip()]
527
528        self.data["tags"] = sorted_tags
529        self.db.update("users", where={"name": self.get_id()}, data={"tags": json.dumps(sorted_tags)})
530
531    def delete(self, also_datasets=True, modules=None):
532        from common.lib.dataset import DataSet
533
534        username = self.data["name"]
535
536        self.db.delete("users_favourites", where={"name": username}, commit=False),
537        self.db.delete("users_notifications", where={"username": username}, commit=False)
538        self.db.delete("access_tokens", where={"name": username}, commit=False)
539
540        # find datasets and delete
541        datasets = self.db.fetchall("SELECT key FROM datasets_owners WHERE name = %s", (username,))
542
543        # delete any datasets and jobs related to deleted datasets
544        if datasets:
545            if modules is None:
546                raise ValueError("To delete a user and their datasets, the 'modules' parameter must be provided.")
547            for dataset in datasets:
548                try:
549                    dataset = DataSet(key=dataset["key"], db=self.db, modules=modules)
550                except DataSetException:
551                    # dataset already deleted?
552                    continue
553
554                if len(dataset.get_owners()) == 1 and also_datasets:
555                    dataset.delete(commit=False)
556                    self.db.delete("jobs", where={"remote_id": dataset.key}, commit=False)
557                else:
558                    dataset.remove_owner(self)
559
560        # and finally the user
561        self.db.delete("users", where={"name": username}, commit=False)
562        self.db.commit()
563
564    def __getattr__(self, attr):
565        """
566        Getter so we don't have to use .data all the time
567
568        :param attr:  Data key to get
569        :return:  Value
570        """
571
572        if attr in dir(self):
573            # an explicitly defined attribute should always be called in favour
574            # of this passthrough
575            attribute = getattr(self, attr)
576            return attribute
577        elif attr in self.data:
578            return self.data[attr]
579        else:
580            raise AttributeError("User object has no attribute %s" % attr)
581
582    def __setattr__(self, attr, value):
583        """
584        Setter so we can flexibly update the database
585
586        Also updates internal data stores (.data etc). If the attribute is
587        unknown, it is stored within the 'userdata' attribute.
588
589        :param str attr:  Attribute to update
590        :param value:  New value
591        """
592
593        # don't override behaviour for *actual* class attributes
594        if attr in dir(self):
595            super().__setattr__(attr, value)
596            return
597
598        if attr not in self.data:
599            self.userdata[attr] = value
600            attr = "userdata"
601            value = self.userdata
602
603        if attr == "userdata":
604            value = json.dumps(value)
605
606        self.db.update("users", where={"name": self.get_id()}, data={attr: value})
607
608        self.data[attr] = value
609
610        if attr == "userdata":
611            self.userdata = json.loads(value)
class User:
 22class User:
 23    """
 24    User class
 25
 26    Compatible with Flask-Login
 27    """
 28    data = None
 29    userdata = None
 30    is_authenticated = False
 31    is_active = False
 32    is_anonymous = True
 33    config = None
 34    db = None
 35
 36    name = "anonymous"
 37
 38    @staticmethod
 39    def get_by_login(db, name, password, config=None):
 40        """
 41        Get user object, if login is correct
 42
 43        If the login data supplied to this method is correct, a new user object
 44        that is marked as authenticated is returned.
 45
 46        :param db:  Database connection object
 47        :param name:  User name
 48        :param password:  User password
 49        :param config:  Configuration manager. Can be used for request-aware user objects using ConfigWrapper. Empty to
 50        use a global configuration manager.
 51        :return:  User object, or `None` if login was invalid
 52        """
 53        user = db.fetchone("SELECT * FROM users WHERE name = %s", (name,))
 54        if not user or not user.get("password", None):
 55            # registration not finished yet
 56            return None
 57        elif not user or not bcrypt.checkpw(password.encode("ascii"), user["password"].encode("ascii")):
 58            # non-existing user or wrong password
 59            return None
 60        else:
 61            # valid login!
 62            return User(db, user, authenticated=True, config=config)
 63
 64    @staticmethod
 65    def get_by_name(db, name, config=None):
 66        """
 67        Get user object for given user name
 68
 69        :param db:  Database connection object
 70        :param str name:  Username to get object for
 71        :param config:  Configuration manager. Can be used for request-aware user objects using ConfigWrapper. Empty to
 72        use a global configuration manager.
 73        :return:  User object, or `None` for invalid user name
 74        """
 75        user = db.fetchone("SELECT * FROM users WHERE name = %s", (name,))
 76        if not user:
 77            return None
 78        else:
 79            return User(db, user, config=config)
 80
 81    @staticmethod
 82    def get_by_token(db, token, config=None):
 83        """
 84        Get user object for given token, if token is valid
 85
 86        :param db:  Database connection object
 87        :param str token:  Token to get object for
 88        :param config:  Configuration manager. Can be used for request-aware user objects using ConfigWrapper. Empty to
 89        use a global configuration manager.
 90        :return:  User object, or `None` for invalid token
 91        """
 92        user = db.fetchone(
 93            "SELECT * FROM users WHERE register_token = %s AND (timestamp_token = 0 OR timestamp_token > %s)",
 94            (token, int(time.time()) - (7 * 86400)))
 95        if not user:
 96            return None
 97        else:
 98            return User(db, user, config=config)
 99
100    def __init__(self, db, data, authenticated=False, config=None):
101        """
102        Instantiate user object
103
104        Also sets the properties required by Flask-Login.
105
106        :param db:  Database connection object
107        :param data:  User data
108        :param authenticated:  Whether the user should be marked as authenticated
109        """
110        self.db = db
111        self.data = data
112
113        try:
114            self.userdata = json.loads(self.data["userdata"])
115        except (TypeError, json.JSONDecodeError):
116            self.userdata = {}
117
118        if self.data["name"] != "anonymous":
119            self.is_anonymous = False
120            self.is_active = True
121
122        self.name = self.data["name"]
123        self.is_authenticated = authenticated
124
125        self.userdata = json.loads(self.data.get("userdata", "{}"))
126
127        if not self.is_anonymous and self.is_authenticated:
128            self.db.update("users", where={"name": self.data["name"]}, data={"timestamp_seen": int(time.time())})
129
130        if config:
131            self.with_config(config)
132
133    def authenticate(self):
134        """
135        Mark user object as authenticated.
136        """
137        self.is_authenticated = True
138
139    def get_id(self):
140        """
141        Get user ID
142
143        :return:  User ID
144        """
145        return self.data["name"]
146
147    def get_name(self):
148        """
149        Get user name
150
151        This is usually the user ID. For the two special users, provide a nicer
152        name to display in interfaces, etc.
153
154        :return: User name
155        """
156        if self.data["name"] == "anonymous":
157            return "Anonymous"
158        elif self.data["name"] == "autologin":
159            return self.config.get("flask.autologin.name") if self.config else "Autologin"
160        else:
161            return self.data["name"]
162
163    def get_token(self):
164        """
165        Get password reset token
166
167        May be empty or invalid!
168
169        :return str: Password reset token
170        """
171        return self.generate_token(regenerate=False)
172
173    def with_config(self, config, rewrap=True):
174        """
175        Connect user to configuration manager
176
177        By default, the user object reads from the global configuration
178        manager. For frontend operations it may be desireable to use a
179        request-aware configuration manager, but this is only available after
180        the user has been instantiated. This method can thus be used to connect
181        the user to that config manager later when it is available.
182
183        :param config:  Configuration manager object
184        :param bool rewrap:  Re-wrap with this user as the user context?
185        :return:
186        """
187        if rewrap:
188            self.config = ConfigWrapper(config, user=self)
189        else:
190            self.config = config
191
192    def clear_token(self):
193        """
194        Reset password rest token
195
196        Clears the token and token timestamp. This allows requesting a new one
197        even if the old one had not expired yet.
198
199        :return:
200        """
201        self.db.update("users", data={"register_token": "", "timestamp_token": 0}, where={"name": self.get_id()})
202
203    def can_access_dataset(self, dataset, role=None):
204        """
205        Check if this user should be able to access a given dataset.
206
207        This depends mostly on the dataset's owner, which should match the
208        user if the dataset is private. If the dataset is not private, or
209        if the user is an admin or the dataset is private but assigned to
210        an anonymous user, the dataset can be accessed.
211
212        :param dataset:  The dataset to check access to
213        :return bool:
214        """
215        if not dataset.is_private:
216            return True
217
218        elif self.is_admin:
219            return True
220        
221        elif self.config.get("privileges.can_view_private_datasets", user=self):
222            # Allowed to see dataset, but perhaps not run processors (need privileges.admin.can_manipulate_all_datasets or dataset ownership)
223            return True
224
225        elif dataset.is_accessible_by(self, role=role):
226            return True
227
228        elif dataset.get_owners == ("anonymous",):
229            return True
230
231        else:
232            return False
233
234    @property
235    def is_special(self):
236        """
237        Check if user is special user
238
239        :return:  Whether the user is the anonymous user, or the automatically
240        logged in user.
241        """
242        return self.get_id() in ("autologin", "anonymous")
243
244    @property
245    def is_admin(self):
246        """
247        Check if user is an administrator
248
249        :return bool:
250        """
251        try:
252            return "admin" in self.data["tags"]
253        except (ValueError, TypeError):
254            # invalid JSON?
255            return False
256
257    @property
258    def is_deactivated(self):
259        """
260        Check if user has been deactivated
261
262        :return bool:
263        """
264        return self.data.get("is_deactivated", False)
265
266    def email_token(self, new=False):
267        """
268        Send user an e-mail with a password reset link
269
270        Generates a token that the user can use to reset their password. The
271        token is valid for 72 hours.
272
273        Note that this requires a mail server to be configured, or a
274        `RuntimeError` will be raised. If a server is configured but the mail
275        still fails to send, it will also raise a `RuntimeError`. Note that
276        in these cases a token is still created and valid (the user just gets
277        no notification, but an admin could forward the correct link).
278
279        If the user is a 'special' user, a `ValueError` is raised.
280
281        :param bool new:  Is this the first time setting a password for this
282                          account?
283        :return str:  Link for the user to set their password with
284        """
285        if not self.config:
286            raise ValueError("User not instantiated with a configuration reader. Provide a ConfigManager at "
287                             "instantiation or use with_config().")
288
289        if not self.config.get('mail.server'):
290            raise RuntimeError("No e-mail server configured. 4CAT cannot send any e-mails.")
291
292        if self.is_special:
293            raise ValueError("Cannot send password reset e-mails for a special user.")
294
295        username = self.get_id()
296
297        # generate a password reset token
298        register_token = self.generate_token(regenerate=True)
299
300        # prepare welcome e-mail
301        sender = self.config.get('mail.noreply')
302        message = MIMEMultipart("alternative")
303        message["From"] = sender
304        message["To"] = username
305
306        # the actual e-mail...
307        url_base = self.config.get("flask.server_name")
308        protocol = "https" if self.config.get("flask.https") else "http"
309        url = "%s://%s/reset-password/?token=%s" % (protocol, url_base, register_token)
310
311        # we use slightly different e-mails depending on whether this is the first time setting a password
312        if new:
313
314            message["Subject"] = "Account created"
315            mail = """
316			<p>Hello %s,</p>
317			<p>A 4CAT account has been created for you. You can now log in to 4CAT at <a href="%s://%s">%s</a>.</p>
318			<p>Note that before you log in, you will need to set a password. You can do so via the following link:</p>
319			<p><a href="%s">%s</a></p> 
320			<p>Please complete your registration within 72 hours as the link above will become invalid after this time.</p>
321			""" % (username, protocol, url_base, url_base, url, url)
322        else:
323
324            message["Subject"] = "Password reset"
325            mail = """
326			<p>Hello %s,</p>
327			<p>Someone has requested a password reset for your 4CAT account. If that someone is you, great! If not, feel free to ignore this e-mail.</p>
328			<p>You can change your password via the following link:</p>
329			<p><a href="%s">%s</a></p> 
330			<p>Please do this within 72 hours as the link above will become invalid after this time.</p>
331			""" % (username, url, url)
332
333        # provide a plain-text alternative as well
334        html_parser = html2text.HTML2Text()
335        message.attach(MIMEText(html_parser.handle(mail), "plain"))
336        message.attach(MIMEText(mail, "html"))
337
338        # try to send it
339        try:
340            send_email([username], message, self.config)
341            return url
342        except (smtplib.SMTPException, ConnectionRefusedError, socket.timeout, socket.gaierror) as e:
343            raise RuntimeError("Could not send password reset e-mail: %s" % e)
344
345    def generate_token(self, username=None, regenerate=True):
346        """
347        Generate and store a new registration token for this user
348
349        Tokens are not re-generated if they exist already
350
351        :param username:  Username to generate for: if left empty, it will be
352        inferred from self.data
353        :param regenerate:  Force regenerating even if token exists
354        :return str:  The token
355        """
356        if self.data.get("register_token", None) and not regenerate:
357            return self.data["register_token"]
358
359        if not username:
360            username = self.data["name"]
361
362        register_token = hashlib.sha256()
363        register_token.update(os.urandom(128))
364        register_token = register_token.hexdigest()
365        self.db.update("users", data={"register_token": register_token, "timestamp_token": int(time.time())},
366                       where={"name": username})
367
368        return register_token
369
370    def get_value(self, key, default=None):
371        """
372        Get persistently stored user property
373
374        :param key:  Name of item to get
375        :param default:  What to return if key is not avaiable (default None)
376        :return:
377        """
378        return self.userdata.get(key, default)
379
380    def set_value(self, key, value):
381        """
382        Set persistently stored user property
383
384        :param key:  Name of item to store
385        :param value:  Value
386        :return:
387        """
388        self.userdata[key] = value
389        self.data["userdata"] = json.dumps(self.userdata)
390
391        self.db.update("users", where={"name": self.get_id()}, data={"userdata": json.dumps(self.userdata)})
392
393    def set_password(self, password):
394        """
395        Set user password
396
397        :param password:  Password to set
398        """
399        if self.is_anonymous:
400            raise Exception("Cannot set password for anonymous user")
401
402        salt = bcrypt.gensalt()
403        password_hash = bcrypt.hashpw(password.encode("ascii"), salt)
404
405        self.db.update("users", where={"name": self.data["name"]}, data={"password": password_hash.decode("utf-8")})
406
407    def add_notification(self, notification, expires=None, allow_dismiss=True):
408        """
409        Add a notification for this user
410
411        Notifications that already exist with the same parameters are not added
412        again.
413
414        :param str notification:  The content of the notification. Can contain
415        Markdown.
416        :param int expires:  Timestamp when the notification should expire. If
417        not provided, the notification does not expire
418        :param bool allow_dismiss:  Whether to allow a user to dismiss the
419        notification.
420        """
421        self.db.insert("users_notifications", {
422            "username": self.get_id(),
423            "notification": notification,
424            "timestamp_expires": expires,
425            "allow_dismiss": allow_dismiss
426        }, safe=True)
427
428    def dismiss_notification(self, notification_id):
429        """
430        Dismiss a notification
431
432        The user can only dismiss notifications they can see!
433
434        :param int notification_id:  ID of the notification to dismiss
435        """
436        all_notifications = {n["id"]: n for n in self.get_notifications() if n["allow_dismiss"]}
437        if notification_id not in all_notifications:
438            return
439
440        # notifications with a canonical ID are just hidden, not deleted
441        # this is because they are received from a notification server, and
442        # deleting them would just re-add them on the next sync
443        if all_notifications[notification_id]["canonical_id"]:
444            self.db.update("users_notifications", data={"is_dismissed": True}, where={"id": notification_id})
445        else:
446            self.db.delete("users_notifications", where={"id": notification_id})
447
448    def get_notifications(self):
449        """
450        Get all notifications for this user
451
452        That is all the user's own notifications, plus those for the groups of
453        users this user belongs to. Dismissed notifications are ignored.
454
455        :return list:  Notifications, as a list of dictionaries
456        """
457        if not self.config:
458            # User not logged in; only view notifications for everyone
459            tag_recipients = ["!everyone"]
460        else:
461            tag_recipients = ["!everyone", *[f"!{tag}" for tag in self.config.get_active_tags(self)]]
462
463        if self.is_admin:
464            # for backwards compatibility - used to be called '!admins' even if the tag is 'admin'
465            tag_recipients.append("!admins")
466
467        notifications = self.db.fetchall(
468            "SELECT n.* FROM users_notifications AS n, users AS u "
469            "WHERE u.name = %s "
470            "AND (u.name = n.username OR n.username IN %s)"
471            "AND n.is_dismissed = FALSE", (self.get_id(), tuple(tag_recipients)))
472
473        return notifications
474
475    def add_tag(self, tag):
476        """
477        Add tag to user
478
479        If the tag is already in the tag list, nothing happens.
480
481        :param str tag:  Tag
482        """
483        if tag not in self.data["tags"]:
484            self.data["tags"].append(tag)
485            self.sort_user_tags()
486            self.config.uncache_user_tags([self.get_id()])
487
488    def remove_tag(self, tag):
489        """
490        Remove tag from user
491
492        If the tag is not part of the tag list, nothing happens.
493
494        :param str tag:  Tag
495        """
496        if tag in self.data["tags"]:
497            self.data["tags"].remove(tag)
498            self.sort_user_tags()
499            self.config.uncache_user_tags([self.get_id()])
500
501    def sort_user_tags(self):
502        """
503        Ensure user tags are stored in the correct order
504
505        The order of the tags matters, since it decides which one will get to
506        override the global configuration. To avoid having to cross-reference
507        the canonical order every time the tags are queried, we ensure that the
508        tags are stored in the database in the right order to begin with. This
509        method ensures that.
510        """
511        if not self.config:
512            raise ValueError("User not instantiated with a configuration reader. Provide a ConfigManager at "
513                             "instantiation or use with_config().")
514
515        tags = self.data["tags"]
516        sorted_tags = []
517
518        for tag in self.config.get("flask.tag_order"):
519            if tag in tags:
520                sorted_tags.append(tag)
521
522        for tag in tags:
523            if tag not in sorted_tags:
524                sorted_tags.append(tag)
525
526        # whitespace isn't a tag
527        sorted_tags = [tag for tag in sorted_tags if tag.strip()]
528
529        self.data["tags"] = sorted_tags
530        self.db.update("users", where={"name": self.get_id()}, data={"tags": json.dumps(sorted_tags)})
531
532    def delete(self, also_datasets=True, modules=None):
533        from common.lib.dataset import DataSet
534
535        username = self.data["name"]
536
537        self.db.delete("users_favourites", where={"name": username}, commit=False),
538        self.db.delete("users_notifications", where={"username": username}, commit=False)
539        self.db.delete("access_tokens", where={"name": username}, commit=False)
540
541        # find datasets and delete
542        datasets = self.db.fetchall("SELECT key FROM datasets_owners WHERE name = %s", (username,))
543
544        # delete any datasets and jobs related to deleted datasets
545        if datasets:
546            if modules is None:
547                raise ValueError("To delete a user and their datasets, the 'modules' parameter must be provided.")
548            for dataset in datasets:
549                try:
550                    dataset = DataSet(key=dataset["key"], db=self.db, modules=modules)
551                except DataSetException:
552                    # dataset already deleted?
553                    continue
554
555                if len(dataset.get_owners()) == 1 and also_datasets:
556                    dataset.delete(commit=False)
557                    self.db.delete("jobs", where={"remote_id": dataset.key}, commit=False)
558                else:
559                    dataset.remove_owner(self)
560
561        # and finally the user
562        self.db.delete("users", where={"name": username}, commit=False)
563        self.db.commit()
564
565    def __getattr__(self, attr):
566        """
567        Getter so we don't have to use .data all the time
568
569        :param attr:  Data key to get
570        :return:  Value
571        """
572
573        if attr in dir(self):
574            # an explicitly defined attribute should always be called in favour
575            # of this passthrough
576            attribute = getattr(self, attr)
577            return attribute
578        elif attr in self.data:
579            return self.data[attr]
580        else:
581            raise AttributeError("User object has no attribute %s" % attr)
582
583    def __setattr__(self, attr, value):
584        """
585        Setter so we can flexibly update the database
586
587        Also updates internal data stores (.data etc). If the attribute is
588        unknown, it is stored within the 'userdata' attribute.
589
590        :param str attr:  Attribute to update
591        :param value:  New value
592        """
593
594        # don't override behaviour for *actual* class attributes
595        if attr in dir(self):
596            super().__setattr__(attr, value)
597            return
598
599        if attr not in self.data:
600            self.userdata[attr] = value
601            attr = "userdata"
602            value = self.userdata
603
604        if attr == "userdata":
605            value = json.dumps(value)
606
607        self.db.update("users", where={"name": self.get_id()}, data={attr: value})
608
609        self.data[attr] = value
610
611        if attr == "userdata":
612            self.userdata = json.loads(value)

User class

Compatible with Flask-Login

User(db, data, authenticated=False, config=None)
100    def __init__(self, db, data, authenticated=False, config=None):
101        """
102        Instantiate user object
103
104        Also sets the properties required by Flask-Login.
105
106        :param db:  Database connection object
107        :param data:  User data
108        :param authenticated:  Whether the user should be marked as authenticated
109        """
110        self.db = db
111        self.data = data
112
113        try:
114            self.userdata = json.loads(self.data["userdata"])
115        except (TypeError, json.JSONDecodeError):
116            self.userdata = {}
117
118        if self.data["name"] != "anonymous":
119            self.is_anonymous = False
120            self.is_active = True
121
122        self.name = self.data["name"]
123        self.is_authenticated = authenticated
124
125        self.userdata = json.loads(self.data.get("userdata", "{}"))
126
127        if not self.is_anonymous and self.is_authenticated:
128            self.db.update("users", where={"name": self.data["name"]}, data={"timestamp_seen": int(time.time())})
129
130        if config:
131            self.with_config(config)

Instantiate user object

Also sets the properties required by Flask-Login.

Parameters
  • db: Database connection object
  • data: User data
  • authenticated: Whether the user should be marked as authenticated
data = None
userdata = None
is_authenticated = False
is_active = False
is_anonymous = True
config = None
db = None
name = 'anonymous'
@staticmethod
def get_by_login(db, name, password, config=None):
38    @staticmethod
39    def get_by_login(db, name, password, config=None):
40        """
41        Get user object, if login is correct
42
43        If the login data supplied to this method is correct, a new user object
44        that is marked as authenticated is returned.
45
46        :param db:  Database connection object
47        :param name:  User name
48        :param password:  User password
49        :param config:  Configuration manager. Can be used for request-aware user objects using ConfigWrapper. Empty to
50        use a global configuration manager.
51        :return:  User object, or `None` if login was invalid
52        """
53        user = db.fetchone("SELECT * FROM users WHERE name = %s", (name,))
54        if not user or not user.get("password", None):
55            # registration not finished yet
56            return None
57        elif not user or not bcrypt.checkpw(password.encode("ascii"), user["password"].encode("ascii")):
58            # non-existing user or wrong password
59            return None
60        else:
61            # valid login!
62            return User(db, user, authenticated=True, config=config)

Get user object, if login is correct

If the login data supplied to this method is correct, a new user object that is marked as authenticated is returned.

Parameters
  • db: Database connection object
  • name: User name
  • password: User password
  • config: Configuration manager. Can be used for request-aware user objects using ConfigWrapper. Empty to use a global configuration manager.
Returns

User object, or None if login was invalid

@staticmethod
def get_by_name(db, name, config=None):
64    @staticmethod
65    def get_by_name(db, name, config=None):
66        """
67        Get user object for given user name
68
69        :param db:  Database connection object
70        :param str name:  Username to get object for
71        :param config:  Configuration manager. Can be used for request-aware user objects using ConfigWrapper. Empty to
72        use a global configuration manager.
73        :return:  User object, or `None` for invalid user name
74        """
75        user = db.fetchone("SELECT * FROM users WHERE name = %s", (name,))
76        if not user:
77            return None
78        else:
79            return User(db, user, config=config)

Get user object for given user name

Parameters
  • db: Database connection object
  • str name: Username to get object for
  • config: Configuration manager. Can be used for request-aware user objects using ConfigWrapper. Empty to use a global configuration manager.
Returns

User object, or None for invalid user name

@staticmethod
def get_by_token(db, token, config=None):
81    @staticmethod
82    def get_by_token(db, token, config=None):
83        """
84        Get user object for given token, if token is valid
85
86        :param db:  Database connection object
87        :param str token:  Token to get object for
88        :param config:  Configuration manager. Can be used for request-aware user objects using ConfigWrapper. Empty to
89        use a global configuration manager.
90        :return:  User object, or `None` for invalid token
91        """
92        user = db.fetchone(
93            "SELECT * FROM users WHERE register_token = %s AND (timestamp_token = 0 OR timestamp_token > %s)",
94            (token, int(time.time()) - (7 * 86400)))
95        if not user:
96            return None
97        else:
98            return User(db, user, config=config)

Get user object for given token, if token is valid

Parameters
  • db: Database connection object
  • str token: Token to get object for
  • config: Configuration manager. Can be used for request-aware user objects using ConfigWrapper. Empty to use a global configuration manager.
Returns

User object, or None for invalid token

def authenticate(self):
133    def authenticate(self):
134        """
135        Mark user object as authenticated.
136        """
137        self.is_authenticated = True

Mark user object as authenticated.

def get_id(self):
139    def get_id(self):
140        """
141        Get user ID
142
143        :return:  User ID
144        """
145        return self.data["name"]

Get user ID

Returns

User ID

def get_name(self):
147    def get_name(self):
148        """
149        Get user name
150
151        This is usually the user ID. For the two special users, provide a nicer
152        name to display in interfaces, etc.
153
154        :return: User name
155        """
156        if self.data["name"] == "anonymous":
157            return "Anonymous"
158        elif self.data["name"] == "autologin":
159            return self.config.get("flask.autologin.name") if self.config else "Autologin"
160        else:
161            return self.data["name"]

Get user name

This is usually the user ID. For the two special users, provide a nicer name to display in interfaces, etc.

Returns

User name

def get_token(self):
163    def get_token(self):
164        """
165        Get password reset token
166
167        May be empty or invalid!
168
169        :return str: Password reset token
170        """
171        return self.generate_token(regenerate=False)

Get password reset token

May be empty or invalid!

Returns

Password reset token

def with_config(self, config, rewrap=True):
173    def with_config(self, config, rewrap=True):
174        """
175        Connect user to configuration manager
176
177        By default, the user object reads from the global configuration
178        manager. For frontend operations it may be desireable to use a
179        request-aware configuration manager, but this is only available after
180        the user has been instantiated. This method can thus be used to connect
181        the user to that config manager later when it is available.
182
183        :param config:  Configuration manager object
184        :param bool rewrap:  Re-wrap with this user as the user context?
185        :return:
186        """
187        if rewrap:
188            self.config = ConfigWrapper(config, user=self)
189        else:
190            self.config = config

Connect user to configuration manager

By default, the user object reads from the global configuration manager. For frontend operations it may be desireable to use a request-aware configuration manager, but this is only available after the user has been instantiated. This method can thus be used to connect the user to that config manager later when it is available.

Parameters
  • config: Configuration manager object
  • bool rewrap: Re-wrap with this user as the user context?
Returns
def clear_token(self):
192    def clear_token(self):
193        """
194        Reset password rest token
195
196        Clears the token and token timestamp. This allows requesting a new one
197        even if the old one had not expired yet.
198
199        :return:
200        """
201        self.db.update("users", data={"register_token": "", "timestamp_token": 0}, where={"name": self.get_id()})

Reset password rest token

Clears the token and token timestamp. This allows requesting a new one even if the old one had not expired yet.

Returns
def can_access_dataset(self, dataset, role=None):
203    def can_access_dataset(self, dataset, role=None):
204        """
205        Check if this user should be able to access a given dataset.
206
207        This depends mostly on the dataset's owner, which should match the
208        user if the dataset is private. If the dataset is not private, or
209        if the user is an admin or the dataset is private but assigned to
210        an anonymous user, the dataset can be accessed.
211
212        :param dataset:  The dataset to check access to
213        :return bool:
214        """
215        if not dataset.is_private:
216            return True
217
218        elif self.is_admin:
219            return True
220        
221        elif self.config.get("privileges.can_view_private_datasets", user=self):
222            # Allowed to see dataset, but perhaps not run processors (need privileges.admin.can_manipulate_all_datasets or dataset ownership)
223            return True
224
225        elif dataset.is_accessible_by(self, role=role):
226            return True
227
228        elif dataset.get_owners == ("anonymous",):
229            return True
230
231        else:
232            return False

Check if this user should be able to access a given dataset.

This depends mostly on the dataset's owner, which should match the user if the dataset is private. If the dataset is not private, or if the user is an admin or the dataset is private but assigned to an anonymous user, the dataset can be accessed.

Parameters
  • dataset: The dataset to check access to
Returns
is_special
234    @property
235    def is_special(self):
236        """
237        Check if user is special user
238
239        :return:  Whether the user is the anonymous user, or the automatically
240        logged in user.
241        """
242        return self.get_id() in ("autologin", "anonymous")

Check if user is special user

Returns

Whether the user is the anonymous user, or the automatically logged in user.

is_admin
244    @property
245    def is_admin(self):
246        """
247        Check if user is an administrator
248
249        :return bool:
250        """
251        try:
252            return "admin" in self.data["tags"]
253        except (ValueError, TypeError):
254            # invalid JSON?
255            return False

Check if user is an administrator

Returns
is_deactivated
257    @property
258    def is_deactivated(self):
259        """
260        Check if user has been deactivated
261
262        :return bool:
263        """
264        return self.data.get("is_deactivated", False)

Check if user has been deactivated

Returns
def email_token(self, new=False):
266    def email_token(self, new=False):
267        """
268        Send user an e-mail with a password reset link
269
270        Generates a token that the user can use to reset their password. The
271        token is valid for 72 hours.
272
273        Note that this requires a mail server to be configured, or a
274        `RuntimeError` will be raised. If a server is configured but the mail
275        still fails to send, it will also raise a `RuntimeError`. Note that
276        in these cases a token is still created and valid (the user just gets
277        no notification, but an admin could forward the correct link).
278
279        If the user is a 'special' user, a `ValueError` is raised.
280
281        :param bool new:  Is this the first time setting a password for this
282                          account?
283        :return str:  Link for the user to set their password with
284        """
285        if not self.config:
286            raise ValueError("User not instantiated with a configuration reader. Provide a ConfigManager at "
287                             "instantiation or use with_config().")
288
289        if not self.config.get('mail.server'):
290            raise RuntimeError("No e-mail server configured. 4CAT cannot send any e-mails.")
291
292        if self.is_special:
293            raise ValueError("Cannot send password reset e-mails for a special user.")
294
295        username = self.get_id()
296
297        # generate a password reset token
298        register_token = self.generate_token(regenerate=True)
299
300        # prepare welcome e-mail
301        sender = self.config.get('mail.noreply')
302        message = MIMEMultipart("alternative")
303        message["From"] = sender
304        message["To"] = username
305
306        # the actual e-mail...
307        url_base = self.config.get("flask.server_name")
308        protocol = "https" if self.config.get("flask.https") else "http"
309        url = "%s://%s/reset-password/?token=%s" % (protocol, url_base, register_token)
310
311        # we use slightly different e-mails depending on whether this is the first time setting a password
312        if new:
313
314            message["Subject"] = "Account created"
315            mail = """
316			<p>Hello %s,</p>
317			<p>A 4CAT account has been created for you. You can now log in to 4CAT at <a href="%s://%s">%s</a>.</p>
318			<p>Note that before you log in, you will need to set a password. You can do so via the following link:</p>
319			<p><a href="%s">%s</a></p> 
320			<p>Please complete your registration within 72 hours as the link above will become invalid after this time.</p>
321			""" % (username, protocol, url_base, url_base, url, url)
322        else:
323
324            message["Subject"] = "Password reset"
325            mail = """
326			<p>Hello %s,</p>
327			<p>Someone has requested a password reset for your 4CAT account. If that someone is you, great! If not, feel free to ignore this e-mail.</p>
328			<p>You can change your password via the following link:</p>
329			<p><a href="%s">%s</a></p> 
330			<p>Please do this within 72 hours as the link above will become invalid after this time.</p>
331			""" % (username, url, url)
332
333        # provide a plain-text alternative as well
334        html_parser = html2text.HTML2Text()
335        message.attach(MIMEText(html_parser.handle(mail), "plain"))
336        message.attach(MIMEText(mail, "html"))
337
338        # try to send it
339        try:
340            send_email([username], message, self.config)
341            return url
342        except (smtplib.SMTPException, ConnectionRefusedError, socket.timeout, socket.gaierror) as e:
343            raise RuntimeError("Could not send password reset e-mail: %s" % e)

Send user an e-mail with a password reset link

Generates a token that the user can use to reset their password. The token is valid for 72 hours.

Note that this requires a mail server to be configured, or a RuntimeError will be raised. If a server is configured but the mail still fails to send, it will also raise a RuntimeError. Note that in these cases a token is still created and valid (the user just gets no notification, but an admin could forward the correct link).

If the user is a 'special' user, a ValueError is raised.

Parameters
  • bool new: Is this the first time setting a password for this account?
Returns

Link for the user to set their password with

def generate_token(self, username=None, regenerate=True):
345    def generate_token(self, username=None, regenerate=True):
346        """
347        Generate and store a new registration token for this user
348
349        Tokens are not re-generated if they exist already
350
351        :param username:  Username to generate for: if left empty, it will be
352        inferred from self.data
353        :param regenerate:  Force regenerating even if token exists
354        :return str:  The token
355        """
356        if self.data.get("register_token", None) and not regenerate:
357            return self.data["register_token"]
358
359        if not username:
360            username = self.data["name"]
361
362        register_token = hashlib.sha256()
363        register_token.update(os.urandom(128))
364        register_token = register_token.hexdigest()
365        self.db.update("users", data={"register_token": register_token, "timestamp_token": int(time.time())},
366                       where={"name": username})
367
368        return register_token

Generate and store a new registration token for this user

Tokens are not re-generated if they exist already

Parameters
  • username: Username to generate for: if left empty, it will be inferred from self.data
  • regenerate: Force regenerating even if token exists
Returns

The token

def get_value(self, key, default=None):
370    def get_value(self, key, default=None):
371        """
372        Get persistently stored user property
373
374        :param key:  Name of item to get
375        :param default:  What to return if key is not avaiable (default None)
376        :return:
377        """
378        return self.userdata.get(key, default)

Get persistently stored user property

Parameters
  • key: Name of item to get
  • default: What to return if key is not avaiable (default None)
Returns
def set_value(self, key, value):
380    def set_value(self, key, value):
381        """
382        Set persistently stored user property
383
384        :param key:  Name of item to store
385        :param value:  Value
386        :return:
387        """
388        self.userdata[key] = value
389        self.data["userdata"] = json.dumps(self.userdata)
390
391        self.db.update("users", where={"name": self.get_id()}, data={"userdata": json.dumps(self.userdata)})

Set persistently stored user property

Parameters
  • key: Name of item to store
  • value: Value
Returns
def set_password(self, password):
393    def set_password(self, password):
394        """
395        Set user password
396
397        :param password:  Password to set
398        """
399        if self.is_anonymous:
400            raise Exception("Cannot set password for anonymous user")
401
402        salt = bcrypt.gensalt()
403        password_hash = bcrypt.hashpw(password.encode("ascii"), salt)
404
405        self.db.update("users", where={"name": self.data["name"]}, data={"password": password_hash.decode("utf-8")})

Set user password

Parameters
  • password: Password to set
def add_notification(self, notification, expires=None, allow_dismiss=True):
407    def add_notification(self, notification, expires=None, allow_dismiss=True):
408        """
409        Add a notification for this user
410
411        Notifications that already exist with the same parameters are not added
412        again.
413
414        :param str notification:  The content of the notification. Can contain
415        Markdown.
416        :param int expires:  Timestamp when the notification should expire. If
417        not provided, the notification does not expire
418        :param bool allow_dismiss:  Whether to allow a user to dismiss the
419        notification.
420        """
421        self.db.insert("users_notifications", {
422            "username": self.get_id(),
423            "notification": notification,
424            "timestamp_expires": expires,
425            "allow_dismiss": allow_dismiss
426        }, safe=True)

Add a notification for this user

Notifications that already exist with the same parameters are not added again.

Parameters
  • str notification: The content of the notification. Can contain Markdown.
  • int expires: Timestamp when the notification should expire. If not provided, the notification does not expire
  • bool allow_dismiss: Whether to allow a user to dismiss the notification.
def dismiss_notification(self, notification_id):
428    def dismiss_notification(self, notification_id):
429        """
430        Dismiss a notification
431
432        The user can only dismiss notifications they can see!
433
434        :param int notification_id:  ID of the notification to dismiss
435        """
436        all_notifications = {n["id"]: n for n in self.get_notifications() if n["allow_dismiss"]}
437        if notification_id not in all_notifications:
438            return
439
440        # notifications with a canonical ID are just hidden, not deleted
441        # this is because they are received from a notification server, and
442        # deleting them would just re-add them on the next sync
443        if all_notifications[notification_id]["canonical_id"]:
444            self.db.update("users_notifications", data={"is_dismissed": True}, where={"id": notification_id})
445        else:
446            self.db.delete("users_notifications", where={"id": notification_id})

Dismiss a notification

The user can only dismiss notifications they can see!

Parameters
  • int notification_id: ID of the notification to dismiss
def get_notifications(self):
448    def get_notifications(self):
449        """
450        Get all notifications for this user
451
452        That is all the user's own notifications, plus those for the groups of
453        users this user belongs to. Dismissed notifications are ignored.
454
455        :return list:  Notifications, as a list of dictionaries
456        """
457        if not self.config:
458            # User not logged in; only view notifications for everyone
459            tag_recipients = ["!everyone"]
460        else:
461            tag_recipients = ["!everyone", *[f"!{tag}" for tag in self.config.get_active_tags(self)]]
462
463        if self.is_admin:
464            # for backwards compatibility - used to be called '!admins' even if the tag is 'admin'
465            tag_recipients.append("!admins")
466
467        notifications = self.db.fetchall(
468            "SELECT n.* FROM users_notifications AS n, users AS u "
469            "WHERE u.name = %s "
470            "AND (u.name = n.username OR n.username IN %s)"
471            "AND n.is_dismissed = FALSE", (self.get_id(), tuple(tag_recipients)))
472
473        return notifications

Get all notifications for this user

That is all the user's own notifications, plus those for the groups of users this user belongs to. Dismissed notifications are ignored.

Returns

Notifications, as a list of dictionaries

def add_tag(self, tag):
475    def add_tag(self, tag):
476        """
477        Add tag to user
478
479        If the tag is already in the tag list, nothing happens.
480
481        :param str tag:  Tag
482        """
483        if tag not in self.data["tags"]:
484            self.data["tags"].append(tag)
485            self.sort_user_tags()
486            self.config.uncache_user_tags([self.get_id()])

Add tag to user

If the tag is already in the tag list, nothing happens.

Parameters
  • str tag: Tag
def remove_tag(self, tag):
488    def remove_tag(self, tag):
489        """
490        Remove tag from user
491
492        If the tag is not part of the tag list, nothing happens.
493
494        :param str tag:  Tag
495        """
496        if tag in self.data["tags"]:
497            self.data["tags"].remove(tag)
498            self.sort_user_tags()
499            self.config.uncache_user_tags([self.get_id()])

Remove tag from user

If the tag is not part of the tag list, nothing happens.

Parameters
  • str tag: Tag
def sort_user_tags(self):
501    def sort_user_tags(self):
502        """
503        Ensure user tags are stored in the correct order
504
505        The order of the tags matters, since it decides which one will get to
506        override the global configuration. To avoid having to cross-reference
507        the canonical order every time the tags are queried, we ensure that the
508        tags are stored in the database in the right order to begin with. This
509        method ensures that.
510        """
511        if not self.config:
512            raise ValueError("User not instantiated with a configuration reader. Provide a ConfigManager at "
513                             "instantiation or use with_config().")
514
515        tags = self.data["tags"]
516        sorted_tags = []
517
518        for tag in self.config.get("flask.tag_order"):
519            if tag in tags:
520                sorted_tags.append(tag)
521
522        for tag in tags:
523            if tag not in sorted_tags:
524                sorted_tags.append(tag)
525
526        # whitespace isn't a tag
527        sorted_tags = [tag for tag in sorted_tags if tag.strip()]
528
529        self.data["tags"] = sorted_tags
530        self.db.update("users", where={"name": self.get_id()}, data={"tags": json.dumps(sorted_tags)})

Ensure user tags are stored in the correct order

The order of the tags matters, since it decides which one will get to override the global configuration. To avoid having to cross-reference the canonical order every time the tags are queried, we ensure that the tags are stored in the database in the right order to begin with. This method ensures that.

def delete(self, also_datasets=True, modules=None):
532    def delete(self, also_datasets=True, modules=None):
533        from common.lib.dataset import DataSet
534
535        username = self.data["name"]
536
537        self.db.delete("users_favourites", where={"name": username}, commit=False),
538        self.db.delete("users_notifications", where={"username": username}, commit=False)
539        self.db.delete("access_tokens", where={"name": username}, commit=False)
540
541        # find datasets and delete
542        datasets = self.db.fetchall("SELECT key FROM datasets_owners WHERE name = %s", (username,))
543
544        # delete any datasets and jobs related to deleted datasets
545        if datasets:
546            if modules is None:
547                raise ValueError("To delete a user and their datasets, the 'modules' parameter must be provided.")
548            for dataset in datasets:
549                try:
550                    dataset = DataSet(key=dataset["key"], db=self.db, modules=modules)
551                except DataSetException:
552                    # dataset already deleted?
553                    continue
554
555                if len(dataset.get_owners()) == 1 and also_datasets:
556                    dataset.delete(commit=False)
557                    self.db.delete("jobs", where={"remote_id": dataset.key}, commit=False)
558                else:
559                    dataset.remove_owner(self)
560
561        # and finally the user
562        self.db.delete("users", where={"name": username}, commit=False)
563        self.db.commit()