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)
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
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
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
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
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
133 def authenticate(self): 134 """ 135 Mark user object as authenticated. 136 """ 137 self.is_authenticated = True
Mark user object as authenticated.
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
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
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
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
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
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
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.
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
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
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
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
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
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
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
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.
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
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
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
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
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()