def has_card(self, username, temp, study_id, *args, **kwargs): """ Check whether the participant with the given username has a card. :param username: The participant's unique username. :type username: str :param temp: A boolean indicating whether the query should look at the temporary card. If it is set to false, the credential-ready card is queried instead. The boolean is actually provided as a string and converted. :type temp: str :param study_id: The ID of the study that is being considered. Depending on the configuration, an identity will be sought for the participant used for this study. :type study_id: str :return: A response containing a boolean indicating whether the participant has a card. Any errors that may arise are included. :rtype: :class:`oauth2.web.Response` """ response = Response() temp = temp.lower() == "true" exists = self._card_exists(username, temp, study_id, *args, **kwargs) response.status_code = 200 response.add_header("Content-Type", "application/json") response.body = json.dumps({"data": exists}) return response
def create_participant(self, env): response = Response() response.add_header("Content-Type", "application/json") if self.provider.is_authorized(env.get("HTTP_AUTHORIZATION"), "authentication"): try: request_body_size = int(env.get('CONTENT_LENGTH', 0)) except (ValueError): request_body_size = 0 print(env.get("CONTENT_TYPE", "No content type")) request_body = env['wsgi.input'].read(request_body_size).decode() request_body = dict(parse.parse_qsl(request_body)) self._pcon.insert_participant(request_body["username"]) response.status_code = 200 response.body = json.dumps({ "status": "success", }) return response else: response.status_code = 403 response.body = json.dumps({ "status": "denied", }) return response
def get_participant(self, username=None, *args, **kwargs): """ Filter participants using the given arguments. If no arguments are given, all participants are returned. :param username: The user's username. :type username: str :return: A response with any errors that may arise. :rtype: :class:`oauth2.web.Response` """ username = self._sanitize( username) if username is not None else username if username is None: rows = self._connector.select(""" SELECT * FROM participants """) total = self._connector.count(""" SELECT COUNT(*) FROM participants """) else: rows = self._connector.select(""" SELECT * FROM participants WHERE user_id = '%s' """ % username) total = self._connector.count(""" SELECT COUNT(*) FROM participants WHERE user_id = '%s' """) decrypted_data = [self._decrypt_participant(row) for row in rows] response = Response() response.status_code = 200 response.add_header("Content-Type", "application/json") response.body = json.dumps({"data": decrypted_data, "total": total}) return response
def is_authenticated(self, env): response = Response() response.add_header("Content-Type", "application/json") if self.provider.is_authorized(env.get("HTTP_AUTHORIZATION"), "authentication"): response.status_code = 200 response.body = json.dumps({ "status": "success", }) return response else: response.status_code = 403 response.body = json.dumps({ "status": "denied", }) return response
def _404_page_not_found(self, arguments): """ Page not found error. :param arguments: The environment arguments. :type arguments: list :return: A 404 response with an error. :rtype: :class:`oauth2.web.Response` """ response = Response() response.add_header("Content-Type", "application/json") response.status_code = 404 response.body = json.dumps({"error": "Page Not Found"}) return response
def remove_participant_by_username(self, username, *args, **kwargs): """ Remove a participant that has the given username. :param username: The user's username. :type username: str :return: A response with any errors that may arise. :rtype: :class:`oauth2.web.Response` """ response = Response() try: username = self._sanitize(username) if not self._participant_exists(username): raise user_exceptions.ParticipantDoesNotExistException() self._connector.execute([ """ DELETE FROM users WHERE user_id = '%s' AND role = 'PARTICIPANT';""" % (username), ]) """ Remove the participant from the backups. """ if erasure.script and erasure.backups: bashCommand = f"{erasure.script} -p {erasure.backups} {username}" print(f"Running: {bashCommand}", file=sys.stderr) process = subprocess.call(bashCommand, shell=True, stdout=subprocess.PIPE) response.status_code = 200 response.add_header("Content-Type", "application/json") response.body = json.dumps({}) except (general_exceptions.InputException, user_exceptions.ParticipantDoesNotExistException) as e: response.status_code = 500 response.add_header("Content-Type", "application/json") response.body = json.dumps({ "error": str(e), "exception": e.__class__.__name__ }) except Exception as e: response.status_code = 500 response.add_header("Content-Type", "application/json") response.body = json.dumps({ "error": "Internal Server Error: %s" % str(e), "exception": e.__class__.__name__ }) return response
def get_researchers(self, *args, **kwargs): """ Retrieve a list of all researchers. :return: A response containing a list of researcher objects and any errors that may arise. :rtype: :class:`oauth2.web.Response` """ rows = self._connector.select(""" SELECT * FROM researchers """) total = self._connector.count(""" SELECT COUNT(*) FROM researchers """) response = Response() response.status_code = 200 response.add_header("Content-Type", "application/json") response.body = json.dumps({"data": rows, "total": total}) return response
def give_consent(self, study_id, address, *args, **kwargs): """ Set the consent of a participant. :param study_id: The unique ID of the study. :type study_id: str :param address: The unique address of the participant on the blockchain. :type address: str :return: A response with any errors that may arise. :rtype: :class:`oauth2.web.Response` """ response = Response() try: if not self._study_exists(study_id): raise study_exceptions.StudyDoesNotExistException() if not self._participant_address_exists(address): raise user_exceptions.ParticipantAddressDoesNotExistException() thread = threading.Thread(target=self._set_consent, args=(study_id, address, True, *args), kwargs=kwargs) thread.start() self._threads.append(thread) response.status_code = 200 response.add_header("Content-Type", "application/json") response.body = json.dumps({}) except (study_exceptions.AttributeNotLinkedException, study_exceptions.MissingAttributesException, study_exceptions.StudyDoesNotExistException, study_exceptions.StudyExpiredException, user_exceptions.ParticipantAddressDoesNotExistException, user_exceptions.ParticipantDoesNotExistException) as e: response.status_code = 500 response.add_header("Content-Type", "application/json") response.body = json.dumps({ "error": str(e), "exception": e.__class__.__name__ }) except Exception as e: traceback.print_exc() response.status_code = 500 response.add_header("Content-Type", "application/json") response.body = json.dumps({ "error": "Internal Server Error: %s" % str(e), "exception": e.__class__.__name__ }) return response
def get_next_email(self, *args, **kwargs): """ Get the next unsent email. The `max_recipients` parameter can be used to limit the number of recipients to deliver to. :return: A response with any errors that may arise. The email and the recipients to whom the email should be sent are returned. :rtype: :class:`oauth2.web.Response` """ response = Response() try: """ Get the next email and its recipients. The results are returned in the same row. The recipients are returned as an array. """ email = self._get_next_email(*args, **kwargs) if email: recipients = email['recipients'] email['created_at'] = email['created_at'].timestamp() del email['recipients'] response.status_code = 200 response.add_header("Content-Type", "application/json") if email: response.body = json.dumps( {'data': { 'email': email, 'recipients': recipients }}) else: response.body = json.dumps({'data': {}}) except (email_exceptions.EmailDoesNotExistException) as e: response.status_code = 500 response.add_header("Content-Type", "application/json") response.body = json.dumps({ "error": str(e), "exception": e.__class__.__name__ }) except Exception as e: response.status_code = 500 response.add_header("Content-Type", "application/json") response.body = json.dumps({ "error": "Internal Server Error: %s" % str(e), "exception": e.__class__.__name__ }) return response
def create_researcher(self, username, *args, **kwargs): """ Insert a researcher into the database. :param username: The user's username. :type username: str :return: A response with any errors that may arise. :rtype: :class:`oauth2.web.Response` """ response = Response() try: username = self._sanitize(username) if self._researcher_exists(username): raise user_exceptions.ResearcherExistsException() elif self._user_exists(username): raise user_exceptions.UserExistsException() self._connector.execute([ """ INSERT INTO users ( user_id, role) VALUES ('%s', '%s');""" % (username, "RESEARCHER"), """ INSERT INTO researchers ( user_id) VALUES ('%s');""" % (username), ]) response.status_code = 200 response.add_header("Content-Type", "application/json") response.body = json.dumps({}) except (general_exceptions.InputException, user_exceptions.ResearcherExistsException, user_exceptions.UserExistsException) as e: response.status_code = 500 response.add_header("Content-Type", "application/json") response.body = json.dumps({ "error": str(e), "exception": e.__class__.__name__ }) except Exception as e: response.status_code = 500 response.add_header("Content-Type", "application/json") response.body = json.dumps({ "error": "Internal Server Error: %s" % str(e), "exception": e.__class__.__name__ }) return response
def get_study_by_id(self, study_id, *args, **kwargs): """ Retrieve a single study and all associated researchers and attributes. :param study_id: The study's unique ID. :type study_id: str :return: A response containing a list of study objects and any errors that may arise. :rtype: :class:`oauth2.web.Response` """ response = Response() try: if not self._study_exists(study_id): raise study_exceptions.StudyDoesNotExistException() study = self._connector.select_one(""" SELECT * FROM studies WHERE "study_id" = '%s' """ % (study_id)) researchers = self._get_study_researchers(study_id) response.status_code = 200 response.add_header("Content-Type", "application/json") response.body = json.dumps({ "study": study, "researchers": researchers, }) return response except (general_exceptions.InputException, study_exceptions.StudyDoesNotExistException) as e: response.status_code = 500 response.add_header("Content-Type", "application/json") response.body = json.dumps({ "error": str(e), "exception": e.__class__.__name__ }) except Exception as e: response.status_code = 500 response.add_header("Content-Type", "application/json") response.body = json.dumps({ "error": "Internal Server Error: %s" % str(e), "exception": e.__class__.__name__ }) return response
def remove_study(self, study_id, *args, **kwargs): """ Remove an existing study. :param study_id: The study's unique ID. :type study_id: str :return: A response with any errors that may arise. :rtype: :class:`oauth2.web.Response` """ response = Response() try: """ Validate the data. """ if not self._study_exists(study_id): raise study_exceptions.StudyDoesNotExistException() """ Remove the study. """ self._connector.execute([ """ DELETE FROM studies WHERE "study_id" = '%s';""" % (study_id, ), ]) response.status_code = 200 response.add_header("Content-Type", "application/json") response.body = json.dumps({}) except (general_exceptions.InputException, study_exceptions.StudyDoesNotExistException) as e: response.status_code = 500 response.add_header("Content-Type", "application/json") response.body = json.dumps({ "error": str(e), "exception": e.__class__.__name__ }) except Exception as e: response.status_code = 500 response.add_header("Content-Type", "application/json") response.body = json.dumps({ "error": "Internal Server Error: %s" % str(e), "exception": e.__class__.__name__ }) return response
def remove_email(self, id, *args, **kwargs): """ Remove the email that has the given ID. :param id: The email's unique ID. :type id: str :return: A response with any errors that may arise. :rtype: :class:`oauth2.web.Response` """ response = Response() try: id = int(id) if not self._email_exists(id): raise email_exceptions.EmailDoesNotExistException(id) """ Delete the email and associated recipients. """ self._connector.execute(""" DELETE FROM emails WHERE id = %d """ % id) response.status_code = 200 response.add_header("Content-Type", "application/json") response.body = json.dumps({}) except (email_exceptions.EmailDoesNotExistException) as e: response.status_code = 500 response.add_header("Content-Type", "application/json") response.body = json.dumps({ "error": str(e), "exception": e.__class__.__name__ }) except Exception as e: response.status_code = 500 response.add_header("Content-Type", "application/json") response.body = json.dumps({ "error": "Internal Server Error: %s" % str(e), "exception": e.__class__.__name__ }) return response
def has_consent(self, study_id, address, *args, **kwargs): """ Check whether the participant with the given address has consented to the use of his data in the given study. :param study_id: The unique ID of the study. :type study_id: str :param address: The unique address of the participant. :type address: str :return: A response with any errors that may arise. The body contains the consent status. :rtype: :class:`oauth2.web.Response` """ response = Response() try: if not self._study_exists(study_id): raise study_exceptions.StudyDoesNotExistException() if not self._participant_address_exists(address): raise user_exceptions.ParticipantAddressDoesNotExistException() consent = self._blockchain_connector.has_consent( study_id, address, *args, **kwargs) response.status_code = 200 response.add_header("Content-Type", "application/json") response.body = json.dumps({"data": consent}) except (study_exceptions.StudyDoesNotExistException, user_exceptions.ParticipantAddressDoesNotExistException) as e: response.status_code = 500 response.add_header("Content-Type", "application/json") response.body = json.dumps({ "error": str(e), "exception": e.__class__.__name__ }) except Exception as e: response.status_code = 500 response.add_header("Content-Type", "application/json") response.body = json.dumps({ "error": "Internal Server Error: %s" % str(e), "exception": e.__class__.__name__ }) return response
def remove_researcher_by_username(self, username, *args, **kwargs): """ Remove a researcher that has the given username. :param username: The user's username. :type username: str :return: A response with any errors that may arise. :rtype: :class:`oauth2.web.Response` """ response = Response() try: username = self._sanitize(username) if not self._researcher_exists(username): raise user_exceptions.ResearcherDoesNotExistException() self._connector.execute([ """ DELETE FROM users WHERE user_id = '%s' AND role = 'RESEARCHER';""" % (username), ]) response.status_code = 200 response.add_header("Content-Type", "application/json") response.body = json.dumps({}) except (general_exceptions.InputException, user_exceptions.ResearcherDoesNotExistException) as e: response.status_code = 500 response.add_header("Content-Type", "application/json") response.body = json.dumps({ "error": str(e), "exception": e.__class__.__name__ }) except Exception as e: response.status_code = 500 response.add_header("Content-Type", "application/json") response.body = json.dumps({ "error": "Internal Server Error: %s" % str(e), "exception": e.__class__.__name__ }) return response
def update_study(self, study_id, name, description, homepage, recruiting, attachment=None, researchers=None, *args, **kwargs): """ Update an existing study. :param study_id: The study's unique ID. :type study_id: str :param name: The study's name. :type name: str :param description: A short description of the study. :type description: str :param homepage: A link to the study's homepage. :type homepage: str :param recruiting: A boolean indicating whether the study is recruiting. :type recruiting: bool :param attachment: The path to the attachment, if there is one. :type: None or str :param researchers: A list of researchers that are participating in the study. :type researchers: list :return: A response with any errors that may arise. :rtype: :class:`oauth2.web.Response` """ response = Response() try: """ Load, parse and sanitize the study arguments. """ name, description, homepage = self._sanitize_list( [name, description, homepage]) researchers = [] if researchers is None else researchers researchers = self._sanitize_list(researchers) """ Validate the data. """ if not self._study_exists(study_id): raise study_exceptions.StudyDoesNotExistException() for researcher in researchers: if not self._researcher_exists(researcher): raise user_exceptions.ResearcherDoesNotExistException() """ Update the study. """ self._connector.execute([ """ UPDATE studies SET "name" = '%s', "description" = '%s', "homepage" = '%s', "recruiting" = '%r' WHERE "study_id" = '%s';""" % (name, description, homepage, recruiting, study_id), ]) if attachment: self._connector.execute([ """ UPDATE studies SET "attachment" = '%s' WHERE "study_id" = '%s';""" % (attachment, study_id), ]) """ Remove all linked researchers. Then add the new ones. """ self._unlink_researchers(study_id) self._link_researchers(study_id, researchers) response.status_code = 200 response.add_header("Content-Type", "application/json") response.body = json.dumps({}) except (general_exceptions.InputException, study_exceptions.AttributeExistsException, study_exceptions.AttributeDoesNotExistException, study_exceptions.StudyDoesNotExistException, user_exceptions.ResearcherDoesNotExistException) as e: response.status_code = 500 response.add_header("Content-Type", "application/json") response.body = json.dumps({ "error": str(e), "exception": e.__class__.__name__ }) except Exception as e: response.status_code = 500 response.add_header("Content-Type", "application/json") response.body = json.dumps({ "error": "Internal Server Error: %s" % str(e), "exception": e.__class__.__name__ }) return response
def update_participant(self, username, first_name=None, last_name=None, email=None, *args, **kwargs): """ Update a participant. :param username: The participant's new username. :type username: str :param first_name: The participant's new first name. If it is not given, then it is not updated. :type first_name: str or None :param last_name: The participant's new last name. If it is not given, then it is not updated. :type last_name: str or None :param email: The participant's new email. If it is not given, then it is not updated. :type email: str or None :return: A response with any errors that may arise. :rtype: :class:`oauth2.web.Response` """ response = Response() try: username = self._sanitize(username) if not self._participant_exists(username): raise user_exceptions.ParticipantDoesNotExistException() attributes = self._encrypt_participant({ 'username': username, 'first_name': first_name, 'last_name': last_name, 'email': email, }) sql = """ UPDATE participants SET %s WHERE user_id = '%s' """ update_strings = [] if first_name is not None: update_strings.append( f"first_name = '{attributes['first_name']}'") if last_name is not None: update_strings.append( f"last_name = '{attributes['last_name']}'") if email is not None: update_strings.append(f"email = '{attributes['email']}'") if len(update_strings): sql = sql % (', '.join(update_strings), username) self._connector.execute(sql) response.status_code = 200 response.add_header("Content-Type", "application/json") response.body = json.dumps({}) except (user_exceptions.ParticipantDoesNotExistException) as e: response.status_code = 500 response.add_header("Content-Type", "application/json") response.body = json.dumps({ "error": str(e), "exception": e.__class__.__name__ }) except Exception as e: response.status_code = 500 response.add_header("Content-Type", "application/json") response.body = json.dumps({ "error": "Internal Server Error: %s" % str(e), "exception": e.__class__.__name__ }) return response
def get_card(self, username, temp, study_id, *args, **kwargs): """ Get the given participant's network business card. :param username: The participant's unique username. :type username: str :param temp: A boolean indicating whether the query should look at the temporary card. If it is set to false, the credential-ready card is queried instead. The boolean is actually provided as a string and converted. :type temp: str :return: A response containing the participant's credential-ready card. This is not stored as a JSON string since it is a `bytes` string. Any errors that may arise are included. :rtype: :class:`oauth2.web.Response` """ response = Response() temp = temp.lower() == "true" card_name = "temp" if temp else "cred" card_name = "%s_card" % card_name query = """ SELECT %s, address FROM participant_identities WHERE participant_id = '%s' AND %s IS NOT NULL """ % (card_name, username, card_name) rows = self._connector.select(query) study_participants = self.get_all_study_participants(study_id) participating_rows = [ row for row in rows if row['address'] in study_participants ] if len(participating_rows): """ Check if the participant is already participating in a study. This happens regardless if the server is running in single- or multi-card mode. If they are, re-use that same card so the consent trail remains in the same place. """ row = participating_rows[0] elif not blockchain.multi_card: if len(rows): """ If the participant never consented to participate in this study, fetch the first identity if there is one. """ row = rows[0] else: """ If the research partner has no cards whatsoever, then create an identity for them. Then, return the newly-created card. """ self.create_participant(username) rows = self._connector.select(query) row = rows[0] else: """ If the research partner has never consented to the study, issue a new identity. Then, return the newly-created card. """ address = self.create_participant(username) query = """ SELECT temp_card, address FROM participant_identities WHERE address = '%s' """ % (address) rows = self._connector.select(query) row = rows[0] """ Update the card name since the temp card will be returned now. """ card_name = 'temp_card' """ The response is a :class:`memoryview` object. Therefore before it is returned, it is converted into a `bytes` string. """ card_data = row[card_name] card_data = bytes(card_data) if temp: """ The temporary card can only be used once. Therefore it should be cleared once requested. """ self._connector.execute(""" UPDATE participant_identities SET temp_card = null WHERE address = '%s'; """ % (row['address'])) response.status_code = 200 response.add_header("Content-Type", "application/octet-stream") response.body = card_data return response
def get_email(self, id=None, recipients=False, search="", case_sensitive=False, number=-1, page=1, *args, **kwargs): """ Get the email with the given ID. If no ID is given, all emails are fetched. Associated recipients can also be fetched. :param id: The email's unique id. :type id: str :param recipients: A parameter that specifies whether the email's recipients should be returned. :type recipients: bool :param search: A search string used to look up emails using their subject and body. :type search: str :param case_sensitive: A boolean indicating whether the search should be case sensitive. :type case_sensitive: str :param number: The number of emails to retrieve. If a negative number is provided, all matching emails are retrieved. :type number: str :param page: The page number, used to aid in pagination. :type page: str :return: A response with any errors that may arise. If an ID is provided, a single email is returned if found. Otherwise, a list of emails is returned. :rtype: :class:`oauth2.web.Response` """ response = Response() try: if id is not None: id = int(id) if not self._email_exists(id): raise email_exceptions.EmailDoesNotExistException(id) number = int(number) page = max(int(page), 1) case_sensitive = case_sensitive == 'True' """ The base SQL string returns every email. The placeholder allows modifications to what is returned. """ sql = """ SELECT emails.* %s FROM emails %s WHERE TRUE %s """ """ Complete the SELECT and FROM fields. """ """ If the recipients are requested, return them as well. """ if recipients: sql = sql % (", ARRAY_AGG(recipient) AS recipients", """LEFT JOIN email_recipients ON emails.id = email_recipients.email_id""", '%s') else: sql = sql % ('', '', '%s') """ Complete the WHERE field. """ filters = [] """ Filter the emails if an ID is given. """ if id is not None: filters.append(f"id = {id}") """ Perform a search if a string is given. """ if search: filters.append( f"(emails.subject %s '%%{search}%%') OR (emails.body %s '%%{search}%%')" % ("LIKE" if case_sensitive else "ILIKE", "LIKE" if case_sensitive else "ILIKE")) if filters: sql = sql % ('AND ' + ' AND '.join(filters)) else: sql = sql % '' """ Add grouping if recipients were requested. """ if recipients: sql += """ GROUP BY emails.id """ """ Limit the results if a non-negative number is given. """ if number >= 0: sql += """ LIMIT %d OFFSET %d""" % (number, number * (page - 1)) """ Get the response. """ emails = self._connector.select(sql) for i, email in enumerate(emails): emails[i]['created_at'] = emails[i]['created_at'].timestamp() """ Calculate the total number of results. """ sql = """ SELECT COUNT(*) AS total FROM emails WHERE TRUE %s """ if filters: sql = sql % ('AND ' + ' AND '.join(filters)) else: sql = sql % '' summary = self._connector.select_one(sql) response.status_code = 200 response.add_header("Content-Type", "application/json") response.body = json.dumps({ "total": summary['total'], "data": emails[0] if id is not None else emails }) except (email_exceptions.EmailDoesNotExistException) as e: response.status_code = 500 response.add_header("Content-Type", "application/json") response.body = json.dumps({ "error": str(e), "exception": e.__class__.__name__ }) except Exception as e: response.status_code = 500 response.add_header("Content-Type", "application/json") response.body = json.dumps({ "error": "Internal Server Error: %s" % str(e), "exception": e.__class__.__name__ }) return response
def get_studies(self, number=10, page=1, search="", case_sensitive=False, active_only=False, *args, **kwargs): """ Retrieve a list of studies. :param number: The number of studies to retrieve. If a negative number is provided, all matching studies are retrieved. :type number: str :param page: The page number, used to aid in pagination. :type page: str :param search: A search string used to look up studies using their name and description. :type search: str :param case_sensitive: A boolean indicating whether the search should be case sensitive. :type case_sensitive: str :param active_only: A boolean indicating whether only active studies should be fetched. By default, all studies are fetched. In the current implementation, the parameter has no effect. :type active_only: bool :return: A response containing a list of study objects and any errors that may arise. :rtype: :class:`oauth2.web.Response` """ response = Response() try: number = int(number) page = max(int(page), 1) case_sensitive = case_sensitive == "True" query = """ SELECT * FROM studies WHERE (studies."name" %s '%%%s%%' OR studies."description" %s '%%%s%%') """ % ( "LIKE" if case_sensitive else "ILIKE", search, "LIKE" if case_sensitive else "ILIKE", search, ) if number >= 0: query += """ LIMIT %d OFFSET %d""" % (number, number * (page - 1)) rows = self._connector.select(query) total = self._connector.count(""" SELECT COUNT(*) FROM studies WHERE (studies."name" %s '%%%s%%' OR studies."description" %s '%%%s%%') """ % ( "LIKE" if case_sensitive else "ILIKE", search, "LIKE" if case_sensitive else "ILIKE", search, )) response.status_code = 200 response.add_header("Content-Type", "application/json") response.body = json.dumps({ "data": { study['study_id']: { "study": study, "researchers": self._get_study_researchers(study["study_id"]), } for study in rows }, "total": total, }) except Exception as e: response.status_code = 500 response.add_header("Content-Type", "application/json") response.body = json.dumps({ "error": "Internal Server Error: %s" % str(e), "exception": e.__class__.__name__ }) return response
def create_email(self, subject, body, recipients=None, recipient_group=None, *args, **kwargs): """ Insert an email into the database. :param subject: The email's subject. :type subject: str :param body: The email's body. :type body: str :param recipients: The email's recipients. If `None` is given, the recipients default to an empty list. Otherwise, the list is considered as a list of emails. :type recipients: None or list :param recipient_group: A recipient group that should receive the email. The group is represented as a string, or nothing at all. The conversion from the group to the actual recipients is handled by the function. Accepted strings: - 'None' - no recipient should be added - 'Subscribed' - only subscribed users should receive the email - 'All' - everyone, including unsubscribed users, should receive the email. **This should be used sparingly and only when absolutely needed to respect user decisions.** :type recipient_group: None or str :return: A response with any errors that may arise. The response contains the new email's attributes, including its ID. :rtype: :class:`oauth2.web.Response` """ response = Response() try: subject = self._sanitize(subject) body = self._sanitize(body) cursor = self._connector.execute(""" INSERT INTO emails (subject, body) VALUES ('%s', '%s') RETURNING id, subject, body """ % (subject, body), with_cursor=True) email = cursor.fetchone() cursor.close() if recipient_group is None or recipient_group.lower() == "none": recipient_list = [] elif recipient_group.lower() == "subscribed": rows = self._connector.select(""" SELECT participants.email FROM participants JOIN participant_subscriptions ON participants.user_id = participant_subscriptions.participant_id WHERE participant_subscriptions.any_email = TRUE """) recipient_list = [self._decrypt(row['email']) for row in rows] elif recipient_group.lower() == "all": rows = self._connector.select(""" SELECT email FROM participants """) recipient_list = [self._decrypt(row['email']) for row in rows] else: raise email_exceptions.UnknownRecipientGroupException( recipient_group) """ Add the given list of recipients to the email's recipients. """ if (recipients is not None and type(recipients) is list): recipient_list += recipients if len(recipient_list): self._connector.bulk_execute( """ INSERT INTO email_recipients (email_id, recipient) VALUES %s """, [(email['id'], recipient) for recipient in recipient_list], "(%s, %s)") response.status_code = 200 response.add_header("Content-Type", "application/json") response.body = json.dumps({"data": dict(email)}) except (email_exceptions.UnknownRecipientGroupException, email_exceptions.UnsupportedRecipientGroupException) as e: response.status_code = 500 response.add_header("Content-Type", "application/json") response.body = json.dumps({ "error": str(e), "exception": e.__class__.__name__ }) except Exception as e: response.status_code = 500 response.add_header("Content-Type", "application/json") response.body = json.dumps({ "error": "Internal Server Error: %s" % str(e), "exception": e.__class__.__name__ }) return response
def create_study(self, study_id, name, description, homepage, attachment=None, recruiting=True, researchers=None, *args, **kwargs): """ Insert a study into the database. :param study_id: The study's unique ID. :type study_id: str :param name: The study's name. :type name: str :param description: A short description of the study. :type description: str :param homepage: A link to the study's homepage. :type homepage: str :param attachment: The path to the attachment, if there is one. :type: None or str :param recruiting: A boolean indicating whether the study is recruiting. :type recruiting: bool :param researchers: A list of researchers that are participating in the study. :type researchers: list :return: A response with any errors that may arise. :rtype: :class:`oauth2.web.Response` :raises: :class:`handlers.exceptions.study_exceptions.StudyExistsException` :raises: :class:`handlers.exceptions.user_exceptions.ResearcherDoesNotExistException` """ response = Response() try: """ Load, parse and sanitize the study arguments. """ name, description, homepage = self._sanitize_list( [name, description, homepage]) researchers = [] if researchers is None else researchers researchers = self._sanitize_list(researchers) """ Validate the data. """ if self._study_exists(study_id): raise study_exceptions.StudyExistsException() for researcher in researchers: if not self._researcher_exists(researcher): raise user_exceptions.ResearcherDoesNotExistException() self._blockchain_connector.create_study(study_id) """ Create the study. """ """ Add the study. """ self._connector.execute([ """ INSERT INTO studies ( study_id, name, description, homepage, attachment, recruiting) VALUES ('%s', '%s', '%s', '%s', '%s', '%r');""" % (study_id, name, description, homepage, attachment or '', recruiting), ]) """ Add the researchers. """ self._link_researchers(study_id, researchers) response.status_code = 200 response.add_header("Content-Type", "application/json") response.body = json.dumps({}) except (general_exceptions.InputException, study_exceptions.AttributeExistsException, study_exceptions.StudyExistsException, user_exceptions.ResearcherDoesNotExistException) as e: response.status_code = 500 response.add_header("Content-Type", "application/json") response.body = json.dumps({ "error": str(e), "exception": e.__class__.__name__ }) except Exception as e: response.status_code = 500 response.add_header("Content-Type", "application/json") response.body = json.dumps({ "error": "Internal Server Error: %s" % str(e), "exception": e.__class__.__name__ }) return response
def get_studies_by_researcher(self, researcher, number=10, page=1, search="", case_sensitive=False, *args, **kwargs): """ Retrieve a list of studies. :param researcher: The unique ID of the researcher, only used if it is not empty. The studies are filtered such that only those studies in which the researcher is participating are retrieved. :type researcher: str :param number: The number of studies to retrieve. If a negative number is provided, all matching studies are retrieved. :type number: str :param page: The page number, used to aid in pagination. :type page: str :param search: A search string used to look up studies using their name and description. :type search: str :param case_sensitive: A boolean indicating whether the search should be case sensitive. :type case_sensitive: str :return: A response containing a list of study objects and any errors that may arise. :rtype: :class:`oauth2.web.Response` """ response = Response() try: number = int(number) page = max(int(page), 1) case_sensitive = case_sensitive == "True" if not self._researcher_exists(researcher): raise user_exceptions.ResearcherDoesNotExistException() query = """ SELECT * FROM studies, studies_researchers WHERE (studies."name" %s '%%%s%%' OR studies."description" %s '%%%s%%') AND studies.study_id = studies_researchers.study_id AND studies_researchers.researcher_id = '%s' """ % ( "LIKE" if case_sensitive else "ILIKE", search, "LIKE" if case_sensitive else "ILIKE", search, researcher, ) if number >= 0: query += """ LIMIT %d OFFSET %d""" % (number, number * (page - 1)) rows = self._connector.select(query) for row in rows: if psycopg2.extras.RealDictRow in row: del row[psycopg2.extras.RealDictRow] total = self._connector.count(""" SELECT COUNT(*) FROM studies, studies_researchers WHERE (studies."name" %s '%%%s%%' OR studies."description" %s '%%%s%%') AND studies.study_id = studies_researchers.study_id AND studies_researchers.researcher_id = '%s' """ % ( "LIKE" if case_sensitive else "ILIKE", search, "LIKE" if case_sensitive else "ILIKE", search, researcher, )) response.status_code = 200 response.add_header("Content-Type", "application/json") response.body = json.dumps({ "data": [{ "study": study, "researchers": self._get_study_researchers(study["study_id"]), } for study in rows], "total": total, }) except (user_exceptions.ResearcherDoesNotExistException) as e: response.status_code = 500 response.add_header("Content-Type", "application/json") response.body = json.dumps({ "error": str(e), "exception": e.__class__.__name__ }) except Exception as e: response.status_code = 500 response.add_header("Content-Type", "application/json") response.body = json.dumps({ "error": "Internal Server Error: %s" % str(e), "exception": e.__class__.__name__ }) return response
def deliver(self, simulated=False, *args, **kwargs): """ Send the next unsent email. The `max_recipients` parameter can be used to limit the number of recipients to deliver to. :param simulated: A boolean to simulate the email delivery. If the delivery is simulated, the email is not actually delivered, but marked as such. :type simulated: boolean :return: A response with any errors that may arise. The email and the recipients to whom the email was sent are returned. :rtype: :class:`oauth2.web.Response` """ response = Response() try: """ Get the next email and its recipients. The results are returned in the same row. The recipients are returned as an array. """ email = self._get_next_email(*args, **kwargs) if email: recipients = email['recipients'] email['created_at'] = email['created_at'].timestamp() del email['recipients'] """ If there is an email to be sent, set up the SMTP connection. Then, set up the email itself and deliver it. The email is always sent to the sender, with the recipients being `Bcc` receivers. """ if not simulated: smtpserver = smtplib.SMTP(smtp.smtp_host, smtp.smtp_port) if smtp.smtp_secure == 'tls': smtpserver.set_debuglevel(0) smtpserver.ehlo() smtpserver.starttls() smtpserver.ehlo() elif smtp.smtp_secure == 'ssl': smtpserver.set_debuglevel(0) smtpserver.ehlo() """ Authenticate if need be. """ if smtp.smtp_auth: smtpserver.login(smtp.smtp_user, smtp.smtp_pass) """ Construct the email. """ message = MIMEText(email_partials.email() % email['body'], 'html') message['Subject'] = email['subject'] message['From'] = f"{smtp.smtp_name} <{smtp.smtp_from}>" message['Bcc'] = ','.join(recipients) smtpserver.sendmail(smtp.smtp_from, [smtp.smtp_from] + recipients, message.as_string()) smtpserver.close() """ Mark the emails as sent. """ self._connector.execute(""" UPDATE email_recipients SET sent = True WHERE email_id = %d AND recipient IN ('%s') """ % (email['id'], "', '".join(recipients))) response.status_code = 200 response.add_header("Content-Type", "application/json") if email: response.body = json.dumps( {'data': { 'email': email, 'recipients': recipients }}) else: response.body = json.dumps({'data': {}}) except (email_exceptions.EmailDoesNotExistException) as e: response.status_code = 500 response.add_header("Content-Type", "application/json") response.body = json.dumps({ "error": str(e), "exception": e.__class__.__name__ }) except Exception as e: traceback.print_exc() response.status_code = 500 response.add_header("Content-Type", "application/json") response.body = json.dumps({ "error": "Internal Server Error: %s" % str(e), "exception": e.__class__.__name__ }) return response
def get_consent_trail(self, username, *args, **kwargs): """ Get a user's consent trail. This trail shows how the participant's consent for studies changed over time. :param username: The unique username of the participant. :type username: str :return: A response with any errors that may arise. The body contains the studies and the timelines. The two are separated from each other. The timeline is made up of the timestamp, and a list of consent changes separated by study IDs. The studies are separated by IDs, and therefore the timeline can use it as a look-up table. :rtype: :class:`oauth2.web.Response` """ response = Response() timeline = {} try: username = self._sanitize(username) if not self._participant_exists(username): raise user_exceptions.ParticipantDoesNotExistException() """ The command must always check for the participant's consent status. """ command = """ SELECT * FROM studies """ rows = self._connector.select(command) studies = {study["study_id"]: study for study in rows} """ For each study get the user's consent changes, if any. """ for row in rows: study_id = row["study_id"] """ Construct the timeline, one timestamp at a time, from the current study. """ consent_trail = self._blockchain_connector.get_consent_trail( study_id, username, *args, **kwargs) for (timestamp, consent) in consent_trail.items(): timeline[timestamp] = timeline.get(timestamp, {}) timeline[timestamp][study_id] = consent response.status_code = 200 response.add_header("Content-Type", "application/json") response.body = json.dumps( {"data": { "studies": studies, "timeline": timeline }}) except (user_exceptions.ParticipantDoesNotExistException) as e: response.status_code = 500 response.add_header("Content-Type", "application/json") response.body = json.dumps({ "error": str(e), "exception": e.__class__.__name__ }) except Exception as e: response.status_code = 500 response.add_header("Content-Type", "application/json") response.body = json.dumps({ "error": "Internal Server Error: %s" % str(e), "exception": e.__class__.__name__ }) return response
def update_subscription(self, username, subscription, subscribed, *args, **kwargs): """ Update the subscription of a participant. :param username: The username of the participant whose subscription will be updated. :type username: str :param subscription: The subscription to update. The only accepted subscription type at present is 'any_email'. :type subscription: str :param subscribed: A boolean indicating whether the participant is subscribed. :typr subscribed: bool :return: A response with any errors that may arise. :rtype: :class:`oauth2.web.Response` """ response = Response() try: username = self._sanitize(username) if not self._participant_exists(username): raise user_exceptions.ParticipantDoesNotExistException() if subscription not in self._get_subscription_types(): raise email_exceptions.UnknownSubscriptionTypeException( subscription) """ Update the subscription. """ row = self._connector.execute(""" UPDATE participant_subscriptions SET %s = %s WHERE participant_id = '%s' """ % (subscription, subscribed, username)) response.status_code = 200 response.add_header("Content-Type", "application/json") response.body = json.dumps({}) except (email_exceptions.UnknownSubscriptionTypeException, user_exceptions.ParticipantDoesNotExistException) as e: response.status_code = 500 response.add_header("Content-Type", "application/json") response.body = json.dumps({ "error": str(e), "exception": e.__class__.__name__ }) except Exception as e: response.status_code = 500 response.add_header("Content-Type", "application/json") response.body = json.dumps({ "error": "Internal Server Error: %s" % str(e), "exception": e.__class__.__name__ }) return response
def get_studies_by_participant(self, username, *args, **kwargs): """ Get a list of studies that the participant has consented to. :param username: The unique username of the participant. :type username: str :return: A response with any errors that may arise. The body contains the studies. :rtype: :class:`oauth2.web.Response` """ response = Response() try: username = self._sanitize(username) if not self._participant_exists(username): raise user_exceptions.ParticipantDoesNotExistException() """ The consent status is checked later on. """ command = """ SELECT * FROM studies """ rows = self._connector.select(command) """ Get all of the participant's addresses. """ identities = self._connector.select(""" SELECT address FROM participant_identities WHERE participant_id = '%s' """ % (username)) addresses = [identity['address'] for identity in identities] """ Check the participant's consent status and only retain the study if they consented. """ studies = [] for row in rows: study_id = row["study_id"] """ Get a list of addresses associated with the study. """ study_addresses = self._blockchain_connector.get_study_participants( study_id, *args, **kwargs) address = list( set(study_addresses).intersection(set(addresses))) if not len(address): continue address = address[0] consent = self._blockchain_connector.has_consent( study_id, address, *args, **kwargs) if consent: studies.append(row) response.status_code = 200 response.add_header("Content-Type", "application/json") response.body = json.dumps({ "data": [{ "study": study, "researchers": self._get_study_researchers(study["study_id"]), } for study in studies], }) except (user_exceptions.ParticipantDoesNotExistException) as e: response.status_code = 500 response.add_header("Content-Type", "application/json") response.body = json.dumps({ "error": str(e), "exception": e.__class__.__name__ }) except Exception as e: response.status_code = 500 response.add_header("Content-Type", "application/json") response.body = json.dumps({ "error": "Internal Server Error: %s" % str(e), "exception": e.__class__.__name__ }) return response
def get_attributes(self, username, attributes, *args, **kwargs): """ Get the attribute values of the participant with the given username. :param username: The unique username of the participant. :type username: str :param attributes: A list of attribute IDs whose values are sought. :type attributes: list :return: A response with any errors that may arise. The data is returned as an associative array. If an attribute does not have a value, None is returned instead. :rtype: :class:`oauth2.web.Response` """ response = Response() try: username = self._sanitize(username) attributes = list(attributes.values() ) if type(attributes) is dict else attributes attribute_values = dict.fromkeys(attributes, None) if not self._participant_exists(username): raise user_exceptions.ParticipantDoesNotExistException() if not all( self._attribute_id_exists(int(attribute)) for attribute in attributes): raise study_exceptions.AttributeDoesNotExistException() for attribute_id in attributes: attribute_value = self._connector.select_one(""" SELECT value FROM participants_attributes WHERE participant_id = '%s' AND attribute_id = %d """ % (username, int(attribute_id))) attribute_values[attribute_id] = attribute_value[ "value"] if attribute_value is not None else None response.status_code = 200 response.add_header("Content-Type", "application/json") response.body = json.dumps({"data": attribute_values}) except (study_exceptions.AttributeDoesNotExistException, user_exceptions.ParticipantDoesNotExistException) as e: response.status_code = 500 response.add_header("Content-Type", "application/json") response.body = json.dumps({ "error": str(e), "exception": e.__class__.__name__ }) except Exception as e: response.status_code = 500 response.add_header("Content-Type", "application/json") response.body = json.dumps({ "error": "Internal Server Error: %s" % str(e), "exception": e.__class__.__name__ }) return response
def create_participant(self, username, first_name="", last_name="", email="", *args, **kwargs): """ Insert a participant into the database. :param username: The participant's username. :type username: str :param first_name: The participant's first name. :type first_name: str :param first_name: The participant's last name. :type first_name: str :param email: The participant's email. :type email: str :return: A response with any errors that may arise. :rtype: :class:`oauth2.web.Response` """ response = Response() try: username = self._sanitize(username) if self._participant_exists(username): raise user_exceptions.ParticipantExistsException() elif self._user_exists(username): raise user_exceptions.UserExistsException() attributes = self._encrypt_participant({ 'username': username, 'first_name': first_name, 'last_name': last_name, 'email': email, }) self._connector.execute([ """ INSERT INTO users (user_id, role) VALUES ('%s', '%s'); """ % (username, "PARTICIPANT"), """ INSERT INTO participants (user_id, first_name, last_name, email) VALUES ('%s', '%s', '%s', '%s'); """ % (username, attributes['first_name'], attributes['last_name'], attributes['email']), """ INSERT INTO participant_subscriptions (participant_id) VALUES ('%s'); """ % (username), ]) response.status_code = 200 response.add_header("Content-Type", "application/json") response.body = json.dumps({}) except (general_exceptions.InputException, user_exceptions.ParticipantExistsException, user_exceptions.UserExistsException) as e: response.status_code = 500 response.add_header("Content-Type", "application/json") response.body = json.dumps({ "error": str(e), "exception": e.__class__.__name__ }) except Exception as e: response.status_code = 500 response.add_header("Content-Type", "application/json") response.body = json.dumps({ "error": "Internal Server Error: %s" % str(e), "exception": e.__class__.__name__ }) return response
def get_subscription(self, username, subscription=None, *args, **kwargs): """ Get a participant's subscription status. If no subscription type is provided, all subscription types are returned. :param username: The username of the participant whose subscriptions will be retrieved. :type username: str :param subscription: The subscription to retrieve. If `None` is given, all subscriptions are returned. :type subscription: None or str :return: A response with any errors that may arise. :rtype: :class:`oauth2.web.Response` """ response = Response() try: username = self._sanitize(username) if not self._participant_exists(username): raise user_exceptions.ParticipantDoesNotExistException() if subscription is not None and subscription not in self._get_subscription_types( ): raise email_exceptions.UnknownSubscriptionTypeException( subscription) """ Retrieve the subscription according to whether one or all subscriptions are requested. """ row = self._connector.select_one(""" SELECT %s FROM participant_subscriptions WHERE participant_id = '%s' """ % ('*' if subscription is None else f"participant_id, {subscription}", username)) response.status_code = 200 response.add_header("Content-Type", "application/json") response.body = json.dumps({'data': dict(row)}) except (email_exceptions.UnknownSubscriptionTypeException, user_exceptions.ParticipantDoesNotExistException) as e: response.status_code = 500 response.add_header("Content-Type", "application/json") response.body = json.dumps({ "error": str(e), "exception": e.__class__.__name__ }) except Exception as e: response.status_code = 500 response.add_header("Content-Type", "application/json") response.body = json.dumps({ "error": "Internal Server Error: %s" % str(e), "exception": e.__class__.__name__ }) return response