Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Skin Scoring App #600

Merged
merged 15 commits into from
Feb 12, 2025
5 changes: 4 additions & 1 deletion microsetta_private_api/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@
read_survey_template, read_survey_templates, read_answered_survey,
read_answered_surveys, submit_answered_survey,
read_answered_survey_associations, top_food_report,
read_myfoodrepo_available_slots
read_myfoodrepo_available_slots, get_skin_scoring_app_credentials,
post_skin_scoring_app_credentials
)
from ._sample import (
read_sample_association, associate_sample, read_sample_associations,
Expand Down Expand Up @@ -103,6 +104,8 @@
'read_answered_survey_associations',
'top_food_report',
'read_myfoodrepo_available_slots',
'get_skin_scoring_app_credentials',
'post_skin_scoring_app_credentials',
'read_sample_association',
'associate_sample',
'read_sample_associations',
Expand Down
125 changes: 106 additions & 19 deletions microsetta_private_api/api/_survey.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,31 +34,40 @@ def read_survey_templates(account_id, source_id, language_tag, token_info):
with Transaction() as t:
source_repo = SourceRepo(t)
source = source_repo.get_source(account_id, source_id)

if source is None:
return jsonify(code=404, message="No source found"), 404

template_repo = SurveyTemplateRepo(t)

if source.source_type == Source.SOURCE_TYPE_HUMAN:
return jsonify([template_repo.get_survey_template_link_info(x)
for x in [
SurveyTemplateRepo.VIOSCREEN_ID,
SurveyTemplateRepo.POLYPHENOL_FFQ_ID,
SurveyTemplateRepo.SPAIN_FFQ_ID,
SurveyTemplateRepo.BASIC_INFO_ID,
SurveyTemplateRepo.AT_HOME_ID,
SurveyTemplateRepo.LIFESTYLE_ID,
SurveyTemplateRepo.GUT_ID,
SurveyTemplateRepo.GENERAL_HEALTH_ID,
SurveyTemplateRepo.HEALTH_DIAG_ID,
SurveyTemplateRepo.ALLERGIES_ID,
SurveyTemplateRepo.DIET_ID,
SurveyTemplateRepo.DETAILED_DIET_ID,
SurveyTemplateRepo.OTHER_ID
]]), 200
template_ids = [
SurveyTemplateRepo.VIOSCREEN_ID,
SurveyTemplateRepo.POLYPHENOL_FFQ_ID,
SurveyTemplateRepo.SPAIN_FFQ_ID,
SurveyTemplateRepo.BASIC_INFO_ID,
SurveyTemplateRepo.AT_HOME_ID,
SurveyTemplateRepo.LIFESTYLE_ID,
SurveyTemplateRepo.GUT_ID,
SurveyTemplateRepo.GENERAL_HEALTH_ID,
SurveyTemplateRepo.HEALTH_DIAG_ID,
SurveyTemplateRepo.ALLERGIES_ID,
SurveyTemplateRepo.DIET_ID,
SurveyTemplateRepo.DETAILED_DIET_ID,
SurveyTemplateRepo.OTHER_ID
]
if template_repo.check_display_skin_scoring_app(
account_id, source_id
):
template_ids.append(SurveyTemplateRepo.SKIN_SCORING_APP_ID)

elif source.source_type == Source.SOURCE_TYPE_ANIMAL:
return jsonify([template_repo.get_survey_template_link_info(x)
for x in [2]]), 200
template_ids = [2]
else:
return jsonify([]), 200
template_ids = []

return jsonify([template_repo.get_survey_template_link_info(x)
for x in template_ids]), 200


def _remote_survey_url_vioscreen(transaction, account_id, source_id,
Expand Down Expand Up @@ -181,6 +190,23 @@ def _remote_survey_url_spain_ffq(transaction, account_id, source_id):
return SERVER_CONFIG['spain_ffq_url']


def _remote_survey_url_skin_scoring_app(transaction,
account_id,
source_id):
st_repo = SurveyTemplateRepo(transaction)

# Confirm that the user has credentials allocated
ssa_u, _ = st_repo.get_skin_scoring_app_credentials_if_exists(
account_id,
source_id
)

if ssa_u is None:
raise NotFound("Sorry, you were not allocated credentials")

return SERVER_CONFIG['skin_app_url']


def read_survey_template(account_id, source_id, survey_template_id,
language_tag, token_info, survey_redirect_url=None,
vioscreen_ext_sample_id=None,
Expand Down Expand Up @@ -220,6 +246,11 @@ def read_survey_template(account_id, source_id, survey_template_id,
url = _remote_survey_url_spain_ffq(t,
account_id,
source_id)
elif survey_template_id == \
SurveyTemplateRepo.SKIN_SCORING_APP_ID:
url = _remote_survey_url_skin_scoring_app(t,
account_id,
source_id)
else:
raise ValueError(f"Cannot generate URL for survey "
f"{survey_template_id}")
Expand Down Expand Up @@ -499,3 +530,59 @@ def read_myfoodrepo_available_slots():
resp = jsonify(code=200, number_of_available_slots=available,
total_number_of_slots=total)
return resp, 200


def get_skin_scoring_app_credentials(account_id, source_id, token_info):
_validate_account_access(token_info, account_id)

with Transaction() as t:
st_repo = SurveyTemplateRepo(t)
ssa_u, ssa_p = st_repo.get_skin_scoring_app_credentials_if_exists(
account_id, source_id
)
response_obj = {
"app_username": ssa_u,
"app_password": ssa_p
}
return jsonify(response_obj), 200


def post_skin_scoring_app_credentials(account_id, source_id, token_info):
_validate_account_access(token_info, account_id)

with Transaction() as t:
st_repo = SurveyTemplateRepo(t)

# First, confirm that the source doesn't already have credentials
ssa_u, _ = st_repo.get_skin_scoring_app_credentials_if_exists(
account_id, source_id
)

# This shouldn't happen, but if it does, return an error
if ssa_u is not None:
return jsonify(
code=400,
message="Credentials already exist"
), 400

# Now, try to allocate credentials and create an entry in the skin
# scoring app registry table
ssa_u, ssa_p = st_repo.create_skin_scoring_app_entry(
account_id, source_id
)
t.commit()

if ssa_u is None:
# No credentials were available
return jsonify(
code=404,
message="No credentials available"
), 404
else:
# Credentials were successfully allocated
return jsonify(
{
"app_username": ssa_u,
"app_password": ssa_p
}
), 201
58 changes: 58 additions & 0 deletions microsetta_private_api/api/microsetta_private_api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1024,6 +1024,64 @@ paths:
'404':
$ref: '#/components/responses/404NotFound'

'/accounts/{account_id}/sources/{source_id}/surveys/skin_scoring_app_credentials':
get:
operationId: microsetta_private_api.api.get_skin_scoring_app_credentials
tags:
- Surveys (By Source)
summary: Get skin scoring app credentials associated with source, if they exist
description: Get skin scoring app credentials associated with source, if they exist
parameters:
- $ref: '#/components/parameters/account_id'
- $ref: '#/components/parameters/source_id'
responses:
'200':
description: Credentials for skin scoring app
content:
application/json:
schema:
type: object
properties:
app_username:
type: string
nullable: true
app_password:
type: string
nullable: true
'401':
$ref: '#/components/responses/401Unauthorized'
'403':
$ref: '#/components/responses/403Forbidden'
post:
operationId: microsetta_private_api.api.post_skin_scoring_app_credentials
tags:
- Surveys (By Source)
summary: Create association between a set of skin scoring app credentials and a source
description: Create association between a set of skin scoring app credentials and a source
parameters:
- $ref: '#/components/parameters/account_id'
- $ref: '#/components/parameters/source_id'
responses:
'201':
description: Credentials for skin scoring app
content:
application/json:
schema:
type: object
properties:
app_username:
type: string
app_password:
type: string
'400':
AmandaBirmingham marked this conversation as resolved.
Show resolved Hide resolved
description: 'Credentials already exist for source'
'401':
$ref: '#/components/responses/401Unauthorized'
'403':
$ref: '#/components/responses/403Forbidden'
'404':
$ref: '#/components/responses/404NotFound'

'/accounts/{account_id}/sources/{source_id}/samples':
get:
operationId: microsetta_private_api.api.read_sample_associations
Expand Down
32 changes: 31 additions & 1 deletion microsetta_private_api/db/migration_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -795,6 +795,35 @@ def migrate_133(TRN):
print("No mapping: " + ffq_id + " - " + barcode)
TRN.execute()

@staticmethod
def migrate_144(TRN):
# We need to load the credentials that the vendor provided in CSV form
# Format is username, password and includes a header row
skin_app_credentials_path = SERVER_CONFIG["skin_app_credentials_path"]
if not os.path.exists(skin_app_credentials_path):
print(
"Credentials for app not found:" + skin_app_credentials_path
)
return

with open(skin_app_credentials_path) as csv_file:
csv_contents = csv.reader(csv_file)
header = True

for csv_row in csv_contents:
if header:
header = False
continue
app_username, app_password = csv_row

TRN.add(
"INSERT INTO ag.skin_scoring_app_credentials "
"(app_username, app_password) "
"VALUES (%s, %s)",
(app_username, app_password)
)
TRN.execute()

MIGRATION_LOOKUP = {
"0048.sql": migrate_48.__func__,
"0050.sql": migrate_50.__func__,
Expand All @@ -806,7 +835,8 @@ def migrate_133(TRN):
# "0082.sql": migrate_82.__func__
# ...
"0096.sql": migrate_96.__func__,
"0133.sql": migrate_133.__func__
"0133.sql": migrate_133.__func__,
"0144.sql": migrate_144.__func__
}

@classmethod
Expand Down
27 changes: 27 additions & 0 deletions microsetta_private_api/db/patches/0144.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
-- The organization that hosts the skin-scoring app provides us with username
-- and password pairings for participants to access the app. We need to store
-- these pairings, as well as a flag for whether the pairing has been
-- allocated to a participant. We explicitly store this flag to avoid reuse
-- if sources (and their related survey databsase records) were to be deleted.
CREATE TABLE ag.skin_scoring_app_credentials (
app_username VARCHAR PRIMARY KEY,
app_password VARCHAR NOT NULL,
credentials_allocated BOOLEAN NOT NULL DEFAULT FALSE
);

-- And we create a registry table, similar to all of the other external
-- surveys we've hosted in the past, to link the username to the account and
-- source that used it.
CREATE TABLE ag.skin_scoring_app_registry (
app_username VARCHAR PRIMARY KEY,
account_id UUID NOT NULL,
source_id UUID,
deleted BOOLEAN NOT NULL DEFAULT false,
creation_timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),

CONSTRAINT fk_skin_scoring_app_username FOREIGN KEY (app_username) REFERENCES ag.skin_scoring_app_credentials(app_username),
CONSTRAINT fk_skin_scoring_app_registry_account FOREIGN KEY (account_id) REFERENCES ag.account(id),
CONSTRAINT fk_skin_scoring_app_registry_source FOREIGN KEY (source_id) REFERENCES ag.source(id)
);

CREATE INDEX skin_scoring_app_registry_source ON ag.skin_scoring_app_registry (account_id, source_id);
16 changes: 16 additions & 0 deletions microsetta_private_api/repo/sample_repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,9 @@ def get_samples_by_source(self, account_id, source_id,
sample.kit_id = self._get_supplied_kit_id_by_sample(
sample.barcode
)
sample.project_id = self._get_project_ids_by_sample(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am really uncomfortable with this. There is already a sample_projects property on the Sample object, which is filled during _create_sample_obj; I realize this is not exactly what you want, because it is project names rather than project ids, but I do not think that introducing an entirely parallel but separate list for holding sample projects info, and creating a sample-level property for it that is defined ONLY on samples created in get_samples_by_source and NOT for samples created in, e.g., get_sample or _get_sample_by_id is a good direction. AFAICT this new property and the new function that fill it exist just so that get_skin_scoring_app_credentials_if_exists can check whether the sbi cohort project is associated with a sample. Couldn't it just check in the existing sample_projects list by name instead of by id, as a bunch of code does in other places (see example)?

for s_p in s.sample_projects:
if s_p.startswith('THDMI'):

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Project names can be edited in Flight, which introduces a level of fuzziness for this purpose that I think is far from ideal. Especially given the fact that people who have access to Flight would largely be unaware of the consequences in editing a project name (I realize there's a separate discussion to be had about whether people should be able to edit project names in Flight given the way they're used in the code base, but that's out of scope).

I realize that there is a precedent for that utilization, but it doesn't feel like something that should be perpetuated since an ID-based link is not prone to the same issues of human intervention (whether accidental or intentional). If you don't agree, I can change it, but it was an intentional choice to not continue down the existing path. If you agree that there's merit to pursuing an ID-based solution, I'm open to streamlining the way I'm implementing it.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@cassidysymons I do agree that having users able to change the project name in flight is A Bad Thing and an opening for errors. Have there been particular instances in which this happened and messed things up?

I also agree that it is much worse to identify things by names than by ids. However, this code seems to be adding a new public project_id property on Sample that is parallel to but uncoupled from sample_projects, is only defined for some paths to Sample object creation, and isn't integrated with Sample.to_api. I worry this is opening up at least as many possible error sources as the mutable names in Flight.

If we are going to hold project ids as well as project names in Sample, my theoretical preference would be to refactor sample_projects so it held tuples of project and name, or a project object of some kind. I acknowledge, though, that we almost certainly don't have the time to do that refactor since it would touch existing uses of sample_projects and require modifying to_api, etc.

I propose a compromise that is less work. We could add _project_ids as a private property of the Sample object (changing its __init__ and from_db but not to_api because, well, private :) We could then modify SampleRepo._create_sample_obj to populate _project_ids (_create_sample_obj is already calling _retrieve_projects, which, for minimum new coding effort, could be modified with an optional param to return id instead of barcode, and then could be called twice, once to get names and once to get ids--clunky but easy). This would take care of having this info available to samples created by all the canonical sample creation methods. Some teeny .get_project_ids() method or something could be added to Sample to make the contents readable for the survey_template_repo code using the Sample here within microsetta_private_api (I mean, I know there's no such thing as really private in python so we could just reach in and read _project_ids directly, but that would be gauche :D ). Thoughts?

sample.barcode
)
samples.append(sample)
return samples

Expand Down Expand Up @@ -407,6 +410,19 @@ def _get_supplied_kit_id_by_sample(self, sample_barcode):
row = cur.fetchone()
return row[0]

def _get_project_ids_by_sample(self, sample_barcode):
with self._transaction.cursor() as cur:
cur.execute(
"SELECT project_id "
"FROM barcodes.project_barcode "
"WHERE barcode = %s",
(sample_barcode, )
)
rows = cur.fetchall()

project_ids = [row[0] for row in rows]
return project_ids

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See above comment

def scrub(self, account_id, source_id, sample_id):
"""Wipe out free text information for a sample

Expand Down
Loading