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        current_notifications = [n["id"] for n in self.get_notifications() if n["allow_dismiss"]]
436        if notification_id not in current_notifications:
437            return
438
439        self.db.delete("users_notifications", where={"id": notification_id})
440
441    def get_notifications(self):
442        """
443        Get all notifications for this user
444
445        That is all the user's own notifications, plus those for the groups of
446        users this user belongs to
447
448        :return list:  Notifications, as a list of dictionaries
449        """
450        if not self.config:
451            # User not logged in; only view notifications for everyone
452            tag_recipients = ["!everyone"]
453        else:
454            tag_recipients = ["!everyone", *[f"!{tag}" for tag in self.config.get_active_tags(self)]]
455
456        if self.is_admin:
457            # for backwards compatibility - used to be called '!admins' even if the tag is 'admin'
458            tag_recipients.append("!admins")
459
460        notifications = self.db.fetchall(
461            "SELECT n.* FROM users_notifications AS n, users AS u "
462            "WHERE u.name = %s "
463            "AND (u.name = n.username OR n.username IN %s)", (self.get_id(), tuple(tag_recipients)))
464
465        return notifications
466
467    def add_tag(self, tag):
468        """
469        Add tag to user
470
471        If the tag is already in the tag list, nothing happens.
472
473        :param str tag:  Tag
474        """
475        if tag not in self.data["tags"]:
476            self.data["tags"].append(tag)
477            self.sort_user_tags()
478
479    def remove_tag(self, tag):
480        """
481        Remove tag from user
482
483        If the tag is not part of the tag list, nothing happens.
484
485        :param str tag:  Tag
486        """
487        if tag in self.data["tags"]:
488            self.data["tags"].remove(tag)
489            self.sort_user_tags()
490
491    def sort_user_tags(self):
492        """
493        Ensure user tags are stored in the correct order
494
495        The order of the tags matters, since it decides which one will get to
496        override the global configuration. To avoid having to cross-reference
497        the canonical order every time the tags are queried, we ensure that the
498        tags are stored in the database in the right order to begin with. This
499        method ensures that.
500        """
501        if not self.config:
502            raise ValueError("User not instantiated with a configuration reader. Provide a ConfigManager at "
503                             "instantiation or use with_config().")
504
505        tags = self.data["tags"]
506        sorted_tags = []
507
508        for tag in self.config.get("flask.tag_order"):
509            if tag in tags:
510                sorted_tags.append(tag)
511
512        for tag in tags:
513            if tag not in sorted_tags:
514                sorted_tags.append(tag)
515
516        # whitespace isn't a tag
517        sorted_tags = [tag for tag in sorted_tags if tag.strip()]
518
519        self.data["tags"] = sorted_tags
520        self.db.update("users", where={"name": self.get_id()}, data={"tags": json.dumps(sorted_tags)})
521
522    def delete(self, also_datasets=True):
523        from common.lib.dataset import DataSet
524
525        username = self.data["name"]
526
527        self.db.delete("users_favourites", where={"name": username}, commit=False),
528        self.db.delete("users_notifications", where={"username": username}, commit=False)
529        self.db.delete("access_tokens", where={"name": username}, commit=False)
530
531        # find datasets and delete
532        datasets = self.db.fetchall("SELECT key FROM datasets_owners WHERE name = %s", (username,))
533
534        # delete any datasets and jobs related to deleted datasets
535        if datasets:
536            for dataset in datasets:
537                try:
538                    dataset = DataSet(key=dataset["key"], db=self.db)
539                except DataSetException:
540                    # dataset already deleted?
541                    continue
542
543                if len(dataset.get_owners()) == 1 and also_datasets:
544                    dataset.delete(commit=False)
545                    self.db.delete("jobs", where={"remote_id": dataset.key}, commit=False)
546                else:
547                    dataset.remove_owner(self)
548
549        # and finally the user
550        self.db.delete("users", where={"name": username}, commit=False)
551        self.db.commit()
552
553    def __getattr__(self, attr):
554        """
555        Getter so we don't have to use .data all the time
556
557        :param attr:  Data key to get
558        :return:  Value
559        """
560
561        if attr in dir(self):
562            # an explicitly defined attribute should always be called in favour
563            # of this passthrough
564            attribute = getattr(self, attr)
565            return attribute
566        elif attr in self.data:
567            return self.data[attr]
568        else:
569            raise AttributeError("User object has no attribute %s" % attr)
570
571    def __setattr__(self, attr, value):
572        """
573        Setter so we can flexibly update the database
574
575        Also updates internal data stores (.data etc). If the attribute is
576        unknown, it is stored within the 'userdata' attribute.
577
578        :param str attr:  Attribute to update
579        :param value:  New value
580        """
581
582        # don't override behaviour for *actual* class attributes
583        if attr in dir(self):
584            super().__setattr__(attr, value)
585            return
586
587        if attr not in self.data:
588            self.userdata[attr] = value
589            attr = "userdata"
590            value = self.userdata
591
592        if attr == "userdata":
593            value = json.dumps(value)
594
595        self.db.update("users", where={"name": self.get_id()}, data={attr: value})
596
597        self.data[attr] = value
598
599        if attr == "userdata":
600            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        current_notifications = [n["id"] for n in self.get_notifications() if n["allow_dismiss"]]
437        if notification_id not in current_notifications:
438            return
439
440        self.db.delete("users_notifications", where={"id": notification_id})
441
442    def get_notifications(self):
443        """
444        Get all notifications for this user
445
446        That is all the user's own notifications, plus those for the groups of
447        users this user belongs to
448
449        :return list:  Notifications, as a list of dictionaries
450        """
451        if not self.config:
452            # User not logged in; only view notifications for everyone
453            tag_recipients = ["!everyone"]
454        else:
455            tag_recipients = ["!everyone", *[f"!{tag}" for tag in self.config.get_active_tags(self)]]
456
457        if self.is_admin:
458            # for backwards compatibility - used to be called '!admins' even if the tag is 'admin'
459            tag_recipients.append("!admins")
460
461        notifications = self.db.fetchall(
462            "SELECT n.* FROM users_notifications AS n, users AS u "
463            "WHERE u.name = %s "
464            "AND (u.name = n.username OR n.username IN %s)", (self.get_id(), tuple(tag_recipients)))
465
466        return notifications
467
468    def add_tag(self, tag):
469        """
470        Add tag to user
471
472        If the tag is already in the tag list, nothing happens.
473
474        :param str tag:  Tag
475        """
476        if tag not in self.data["tags"]:
477            self.data["tags"].append(tag)
478            self.sort_user_tags()
479
480    def remove_tag(self, tag):
481        """
482        Remove tag from user
483
484        If the tag is not part of the tag list, nothing happens.
485
486        :param str tag:  Tag
487        """
488        if tag in self.data["tags"]:
489            self.data["tags"].remove(tag)
490            self.sort_user_tags()
491
492    def sort_user_tags(self):
493        """
494        Ensure user tags are stored in the correct order
495
496        The order of the tags matters, since it decides which one will get to
497        override the global configuration. To avoid having to cross-reference
498        the canonical order every time the tags are queried, we ensure that the
499        tags are stored in the database in the right order to begin with. This
500        method ensures that.
501        """
502        if not self.config:
503            raise ValueError("User not instantiated with a configuration reader. Provide a ConfigManager at "
504                             "instantiation or use with_config().")
505
506        tags = self.data["tags"]
507        sorted_tags = []
508
509        for tag in self.config.get("flask.tag_order"):
510            if tag in tags:
511                sorted_tags.append(tag)
512
513        for tag in tags:
514            if tag not in sorted_tags:
515                sorted_tags.append(tag)
516
517        # whitespace isn't a tag
518        sorted_tags = [tag for tag in sorted_tags if tag.strip()]
519
520        self.data["tags"] = sorted_tags
521        self.db.update("users", where={"name": self.get_id()}, data={"tags": json.dumps(sorted_tags)})
522
523    def delete(self, also_datasets=True):
524        from common.lib.dataset import DataSet
525
526        username = self.data["name"]
527
528        self.db.delete("users_favourites", where={"name": username}, commit=False),
529        self.db.delete("users_notifications", where={"username": username}, commit=False)
530        self.db.delete("access_tokens", where={"name": username}, commit=False)
531
532        # find datasets and delete
533        datasets = self.db.fetchall("SELECT key FROM datasets_owners WHERE name = %s", (username,))
534
535        # delete any datasets and jobs related to deleted datasets
536        if datasets:
537            for dataset in datasets:
538                try:
539                    dataset = DataSet(key=dataset["key"], db=self.db)
540                except DataSetException:
541                    # dataset already deleted?
542                    continue
543
544                if len(dataset.get_owners()) == 1 and also_datasets:
545                    dataset.delete(commit=False)
546                    self.db.delete("jobs", where={"remote_id": dataset.key}, commit=False)
547                else:
548                    dataset.remove_owner(self)
549
550        # and finally the user
551        self.db.delete("users", where={"name": username}, commit=False)
552        self.db.commit()
553
554    def __getattr__(self, attr):
555        """
556        Getter so we don't have to use .data all the time
557
558        :param attr:  Data key to get
559        :return:  Value
560        """
561
562        if attr in dir(self):
563            # an explicitly defined attribute should always be called in favour
564            # of this passthrough
565            attribute = getattr(self, attr)
566            return attribute
567        elif attr in self.data:
568            return self.data[attr]
569        else:
570            raise AttributeError("User object has no attribute %s" % attr)
571
572    def __setattr__(self, attr, value):
573        """
574        Setter so we can flexibly update the database
575
576        Also updates internal data stores (.data etc). If the attribute is
577        unknown, it is stored within the 'userdata' attribute.
578
579        :param str attr:  Attribute to update
580        :param value:  New value
581        """
582
583        # don't override behaviour for *actual* class attributes
584        if attr in dir(self):
585            super().__setattr__(attr, value)
586            return
587
588        if attr not in self.data:
589            self.userdata[attr] = value
590            attr = "userdata"
591            value = self.userdata
592
593        if attr == "userdata":
594            value = json.dumps(value)
595
596        self.db.update("users", where={"name": self.get_id()}, data={attr: value})
597
598        self.data[attr] = value
599
600        if attr == "userdata":
601            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        current_notifications = [n["id"] for n in self.get_notifications() if n["allow_dismiss"]]
437        if notification_id not in current_notifications:
438            return
439
440        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):
442    def get_notifications(self):
443        """
444        Get all notifications for this user
445
446        That is all the user's own notifications, plus those for the groups of
447        users this user belongs to
448
449        :return list:  Notifications, as a list of dictionaries
450        """
451        if not self.config:
452            # User not logged in; only view notifications for everyone
453            tag_recipients = ["!everyone"]
454        else:
455            tag_recipients = ["!everyone", *[f"!{tag}" for tag in self.config.get_active_tags(self)]]
456
457        if self.is_admin:
458            # for backwards compatibility - used to be called '!admins' even if the tag is 'admin'
459            tag_recipients.append("!admins")
460
461        notifications = self.db.fetchall(
462            "SELECT n.* FROM users_notifications AS n, users AS u "
463            "WHERE u.name = %s "
464            "AND (u.name = n.username OR n.username IN %s)", (self.get_id(), tuple(tag_recipients)))
465
466        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

Returns

Notifications, as a list of dictionaries

def add_tag(self, tag):
468    def add_tag(self, tag):
469        """
470        Add tag to user
471
472        If the tag is already in the tag list, nothing happens.
473
474        :param str tag:  Tag
475        """
476        if tag not in self.data["tags"]:
477            self.data["tags"].append(tag)
478            self.sort_user_tags()

Add tag to user

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

Parameters
  • str tag: Tag
def remove_tag(self, tag):
480    def remove_tag(self, tag):
481        """
482        Remove tag from user
483
484        If the tag is not part of the tag list, nothing happens.
485
486        :param str tag:  Tag
487        """
488        if tag in self.data["tags"]:
489            self.data["tags"].remove(tag)
490            self.sort_user_tags()

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):
492    def sort_user_tags(self):
493        """
494        Ensure user tags are stored in the correct order
495
496        The order of the tags matters, since it decides which one will get to
497        override the global configuration. To avoid having to cross-reference
498        the canonical order every time the tags are queried, we ensure that the
499        tags are stored in the database in the right order to begin with. This
500        method ensures that.
501        """
502        if not self.config:
503            raise ValueError("User not instantiated with a configuration reader. Provide a ConfigManager at "
504                             "instantiation or use with_config().")
505
506        tags = self.data["tags"]
507        sorted_tags = []
508
509        for tag in self.config.get("flask.tag_order"):
510            if tag in tags:
511                sorted_tags.append(tag)
512
513        for tag in tags:
514            if tag not in sorted_tags:
515                sorted_tags.append(tag)
516
517        # whitespace isn't a tag
518        sorted_tags = [tag for tag in sorted_tags if tag.strip()]
519
520        self.data["tags"] = sorted_tags
521        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):
523    def delete(self, also_datasets=True):
524        from common.lib.dataset import DataSet
525
526        username = self.data["name"]
527
528        self.db.delete("users_favourites", where={"name": username}, commit=False),
529        self.db.delete("users_notifications", where={"username": username}, commit=False)
530        self.db.delete("access_tokens", where={"name": username}, commit=False)
531
532        # find datasets and delete
533        datasets = self.db.fetchall("SELECT key FROM datasets_owners WHERE name = %s", (username,))
534
535        # delete any datasets and jobs related to deleted datasets
536        if datasets:
537            for dataset in datasets:
538                try:
539                    dataset = DataSet(key=dataset["key"], db=self.db)
540                except DataSetException:
541                    # dataset already deleted?
542                    continue
543
544                if len(dataset.get_owners()) == 1 and also_datasets:
545                    dataset.delete(commit=False)
546                    self.db.delete("jobs", where={"remote_id": dataset.key}, commit=False)
547                else:
548                    dataset.remove_owner(self)
549
550        # and finally the user
551        self.db.delete("users", where={"name": username}, commit=False)
552        self.db.commit()