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

Interface - DO NOT MERGE - Merge bg:interface instead. ARCHIVED FOR CONVERSATION #78

Open
wants to merge 19 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,23 @@ can be found below.
- update your dependencies
```shell
conda install --file requirements/run.txt
pip install -r requirements/pip_requirements.txt
```
- Currently, there are three usernames and passwords required in order to develop the website. One for oauth (env var),
mongo atlas (yaml file opened in script), for google cloud storage (json file pointed at by ENV var). The latter can
be ascertained as described above. The former two have been described below.
- Add a secret username and password to a yml file in the pydatarecognition folder named secret_password.yml
- These should take the following form (you replace the <>, removing the <>)
```yaml
username: <username>
password: <password>
```
- Add an oauth login page to your google cloud platform account () and add the following variables to a .env file in the
pydatarecognition directory
```shell
GOOGLE_CLIENT_ID=<client_id_for_oauth>
GOOGLE_CLIENT_SECRET=<client_secret_for_oauth>
```
- run the following command from the base dir terminal to run the app
```shell
uvicorn pydatarecognition.app:app --reload
Expand Down
284 changes: 173 additions & 111 deletions pydatarecognition/app.py
Original file line number Diff line number Diff line change
@@ -1,143 +1,205 @@
import os
from pathlib import Path
import yaml
import tempfile
import shutil
import uuid

from fastapi import FastAPI, Body, HTTPException, status, File
from fastapi.responses import JSONResponse
from fastapi.encoders import jsonable_encoder
from typing import List, Optional, Literal
import motor.motor_asyncio
from pydatarecognition.powdercif import PydanticPowderCif
from pydatarecognition.utils import xy_resample
from pydatarecognition.cif_io import user_input_read
from skbeam.core.utils import twotheta_to_q
import scipy.stats
import numpy as np
import io
import base64
from functools import wraps

STEPSIZE_REGULAR_QGRID = 10**-3
from fastapi import FastAPI, File, Form, Depends
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from fastapi.openapi.docs import get_swagger_ui_html

from starlette.config import Config as Configure
from starlette.requests import Request
from starlette.middleware.sessions import SessionMiddleware
from starlette.responses import RedirectResponse

from authlib.integrations.starlette_client import OAuth

COLLECTION = "cif"
from typing import Optional, Literal

app = FastAPI()
import pydatarecognition.rest_api as rest_api
from pydatarecognition.dependencies import get_user
from pydatarecognition.mongo_client import mongo_client
from pydatarecognition.rank import rank_db_cifs
from pydatarecognition.cif_io import rank_write

# Connect to mongodb
filepath = Path(os.path.abspath(__file__))
with open(os.path.join(filepath.parent, 'secret_password.yml'), 'r') as f:
user_secrets = yaml.safe_load(f)
username = user_secrets['username']
password = user_secrets['password']
client = motor.motor_asyncio.AsyncIOMotorClient(f'mongodb+srv://{username}:{password}@sidewinder.uc5ro.mongodb.net/?retryWrites=true&w=majority')
db = client.test

# Setup cif mapping reference
CIF_DIR = filepath.parent.parent / 'docs' / 'examples' / 'cifs'
doifile = CIF_DIR / 'iucrid_doi_mapping.txt'
dois = np.genfromtxt(doifile, dtype='str')
doi_dict = {}
for i in range(len(dois)):
doi_dict[dois[i][0]] = dois[i][1]
STEPSIZE_REGULAR_QGRID = 10**-3


app = FastAPI(docs_url=None, redoc_url=None)
app.add_event_handler("startup", mongo_client.connect_db)
app.add_event_handler("shutdown", mongo_client.close_mongo_connection)
app.include_router(rest_api.router)
app.mount("/static", StaticFiles(directory="static"), name="static")
app.add_middleware(SessionMiddleware, secret_key='!secret')
templates = Jinja2Templates(directory="templates")


def login_required(f):
@wraps(f)
async def wrapped(request, *args, **kwargs):
if request.session.get('login_status'):
if request.session['login_status'] == "authorized":
return await f(request, *args, **kwargs)
return RedirectResponse(app.url_path_for('login'))
return wrapped


@app.route('/')
async def home(request: Request):
if request.session.get('login_status'):
if request.session['login_status'] == "authorized":
return templates.TemplateResponse('landing.html',
{"request": request, "user": request.session.get('username'), "img": request.session.get('photourl')})
else:
return templates.TemplateResponse('landing.html', {"request": request, "user": None})
else:
return templates.TemplateResponse('landing.html', {"request": request, "user": None})


@app.route('/about')
def footer_about(request: Request):
"""
Route function for about in the footer.

Returns
-------
render_template
Renders the footer-about page.
"""
return templates.TemplateResponse('footer-about.html',
{"request": request, "user": request.session.get('username'), "img": request.session.get('photourl')})


@app.route('/privacy')
def footer_privacy(request: Request):
"""
Route function for privacy policy in the footer.

Returns
-------
render_template
Renders the privacy-policy page.
"""
return templates.TemplateResponse('footer-privacy.html',
{"request": request, "user": request.session.get('username'), "img": request.session.get('photourl')})


@app.route('/terms')
async def footer_term(request: Request):
"""
Route function for terms of use in the footer.

Returns
-------
render_template
Renders the footer-term page.
"""
return templates.TemplateResponse('footer-term.html',
{"request": request, "user": request.session.get('username'), "img": request.session.get('photourl')})


# Initialize our OAuth instance from the client ID and client secret specified in our .env file
config = Configure('.env')
oauth = OAuth(config)

CONF_URL = 'https://accounts.google.com/.well-known/openid-configuration'
oauth.register(
name='google',
server_metadata_url=CONF_URL,
client_kwargs={
'scope': 'openid email profile'
}
)

@app.get('/login', tags=['authentication']) # Tag it as "authentication" for our docs
async def login(request: Request):

@app.post("/", response_description="Add new CIF", response_model=PydanticPowderCif)
async def create_cif(powdercif: PydanticPowderCif = Body(...)):
powdercif = jsonable_encoder(powdercif)
new_cif = await db[COLLECTION].insert_one(powdercif)
created_cif = await db[COLLECTION].find_one({"_id": new_cif.inserted_id})
return JSONResponse(status_code=status.HTTP_201_CREATED, content=created_cif)
return templates.TemplateResponse('login.html', {"request": request, "user": None})


@app.get(
"/", response_description="List all cifs", response_model=List[PydanticPowderCif]
)
async def list_cifs():
cifs = await db[COLLECTION].find().to_list(5)
return cifs
@app.get('/google_login', tags=['authentication']) # Tag it as "authentication" for our docs
async def google_login(request: Request):
# Redirect Google OAuth back to our application
redirect_uri = request.url_for('auth')

return await oauth.google.authorize_redirect(request, redirect_uri)

@app.get(
"/{id}", response_description="Get a single CIF", response_model=PydanticPowderCif
)
async def show_cif(id: str):
if (cif := await db[COLLECTION].find_one({"_id": id})) is not None:
return cif

raise HTTPException(status_code=404, detail=f"CIF {id} not found")
@app.route('/auth')
async def auth(request: Request):
# Perform Google OAuth
token = await oauth.google.authorize_access_token(request)
user = await oauth.google.parse_id_token(request, token)

# Save the user
request.session['user'] = dict(user)
request.session['photourl'] = request.session['user']['picture']
request.session['username'] = request.session['user']['given_name'] if ('given_name' in request.session['user']) else "Anonymous"
request.session['login_status'] = 'authorized'

@app.put("/{id}", response_description="Update a CIF", response_model=PydanticPowderCif)
async def update_cif(id: str, cif: PydanticPowderCif = Body(...)):
cif = {k: v for k, v in cif.dict().items() if v is not None}
return RedirectResponse(url='/')

if len(cif) >= 1:
update_result = await db[COLLECTION].update_one({"_id": id}, {"$set": cif})

if update_result.modified_count == 1:
if (
updated_cif := await db[COLLECTION].find_one({"_id": id})
) is not None:
return updated_cif
@app.get('/logout', tags=['authentication']) # Tag it as "authentication" for our docs
async def logout(request: Request):
# Remove the user
request.session.pop('user', None)
request.session.pop('photourl', None)
request.session.pop('username', None)
request.session.pop('login_status', None)

if (existing_cif := await db[COLLECTION].find_one({"_id": id})) is not None:
return existing_cif
return RedirectResponse(url='/')

raise HTTPException(status_code=404, detail=f"CIF {id} not found")

@app.route('/docs', methods=['GET']) # Tag it as "documentation" for our docs
@login_required
async def get_documentation(request: Request):
response = get_swagger_ui_html(openapi_url='/openapi.json', title='Documentation')
return response

@app.delete("/{id}", response_description="Delete a CIF")
async def delete_cif(id: str):
delete_result = await db[COLLECTION].delete_one({"_id": id})

if delete_result.deleted_count == 1:
return JSONResponse(status_code=status.HTTP_204_NO_CONTENT)
@app.route('/cif_search', methods=['GET'])
@login_required
async def cif_search(request: Request):
"""
Route function for cif search.

raise HTTPException(status_code=404, detail=f"CIF {id} not found")
Returns
-------
render_template
Renders the cif search page.
"""
return templates.TemplateResponse('cif_search.html',
{"request": request, "user": request.session.get('username'),
"img": request.session.get('photourl'),
"result": None
})

@app.put(
"/query/", response_description="Rank matches to User Input Data"
)
async def rank_cif(xtype: Literal["twotheta", "q"], wavelength: float, user_input: bytes = File(...), paper_filter_iucrid: Optional[str] = None):
cifname_ranks = []
r_pearson_ranks = []
doi_ranks = []
tempdir = tempfile.mkdtemp()
temp_filename = os.path.join(tempdir, f'temp_{uuid.uuid4()}.txt')
with open(temp_filename, 'wb') as w:
w.write(user_input)
userdata = user_input_read(temp_filename)
user_x_data, user_intensity = userdata[0, :], userdata[1:, ][0]
if xtype == 'twotheta':
user_q = twotheta_to_q(np.radians(user_x_data), wavelength)
if paper_filter_iucrid:
cif_list = db[COLLECTION].find({"iucrid": paper_filter_iucrid})
else:
cif_list = db[COLLECTION].find({})
async for cif in cif_list:
mongo_cif = PydanticPowderCif(**cif)
try:
data_resampled = xy_resample(user_q, user_q, mongo_cif.q, mongo_cif.intensity, STEPSIZE_REGULAR_QGRID)
pearson = scipy.stats.pearsonr(data_resampled[0][:, 1], data_resampled[1][:, 1])
r_pearson = pearson[0]
p_pearson = pearson[1]
cifname_ranks.append(mongo_cif.cif_file_name)
r_pearson_ranks.append(r_pearson)
doi = doi_dict[mongo_cif.iucrid]
doi_ranks.append(doi)
except AttributeError:
print(f"{mongo_cif.cif_file_name} was skipped.")

cif_rank_pearson = sorted(list(zip(cifname_ranks, r_pearson_ranks, doi_ranks)), key=lambda x: x[1], reverse=True)
ranks = [{'IUCrCIF': cif_rank_pearson[i][0],
'score': cif_rank_pearson[i][1],
'doi': cif_rank_pearson[i][2]} for i in range(len(cif_rank_pearson))]
shutil.rmtree(tempdir)
return ranks

@app.post('/cif_search', tags=['Web Interface'])
async def upload_data_cif(request: Request, user_input: bytes = File(...), wavelength: str = Form(...),
filter_key: str = Form(None), filter_value: str = Form(None),
datatype: Literal["twotheta", "q"] = Form(...), user: Optional[dict] = Depends(get_user)):
db_client = await mongo_client.get_db_client()
db = db_client.test
ranks, plot = await rank_db_cifs(db, datatype, wavelength, user_input, filter_key, filter_value, plot=True)
# creates an in-memory buffer in which to store the file
file_object = io.BytesIO()
plot.savefig(file_object, format='png', dpi=150)
base64img = "data:image/png;base64," + base64.b64encode(file_object.getvalue()).decode('ascii')
result = rank_write(ranks).replace('\t\t', '&emsp;&emsp;&emsp;&emsp;&emsp;')
return templates.TemplateResponse('cif_search.html',
{"request": request, "user": request.session.get('username'),
"img": request.session.get('photourl'),
"result": result.replace('\t', '&emsp;&emsp;'),
"base64img": base64img
})


if __name__ == "__main__":
import uvicorn
uvicorn.run("app:app", host="localhost", reload=True)
uvicorn.run("app:app", host="127.0.0.1", port=8000, reload=True)
11 changes: 6 additions & 5 deletions pydatarecognition/cif_io.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ def user_input_read(user_input_file_path):
return user_data


def rank_write(cif_ranks, output_path):
def rank_write(cif_ranks, output_path=None):
'''
given a list of dicts of IUCr CIFs, scores, and DOIs together with a path to the output dir,
writes a .txt file with ranks, scores, IUCr CIFs, and DOIs.
Expand Down Expand Up @@ -153,10 +153,11 @@ def rank_write(cif_ranks, output_path):
f"{encoded_ref_string}\n"
rank_doi_score_txt_print += f"{i+1}{tab_char*2}{cif_ranks[i]['score']:.4f}\t{cif_ranks[i]['doi']}\t" \
f"{encoded_ref_string}\n"
with open(output_path / 'rank_WindowsNotepad.txt', 'w') as output_file:
output_file.write(rank_doi_score_txt_write)
with open(output_path / 'rank_PyCharm_Notepad++.txt', 'w') as output_file:
output_file.write(rank_doi_score_txt_print)
if output_path:
with open(output_path / 'rank_WindowsNotepad.txt', 'w') as output_file:
output_file.write(rank_doi_score_txt_write)
with open(output_path / 'rank_PyCharm_Notepad++.txt', 'w') as output_file:
output_file.write(rank_doi_score_txt_print)

return rank_doi_score_txt_print

Expand Down
14 changes: 14 additions & 0 deletions pydatarecognition/dependencies.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from typing import Optional
from starlette.requests import Request
from fastapi import HTTPException


# Try to get the logged in user
async def get_user(request: Request) -> Optional[dict]:
user = request.session.get('user', None)
if user is not None:
return user
else:
raise HTTPException(status_code=403, detail='Please return to home screen and log in.')

return None
Loading