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)
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
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 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
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
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
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
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()