Skip to content

Commit

Permalink
PP-337 Quicksight dashboard embed URL generation (#1378)
Browse files Browse the repository at this point in the history
* Quicksight dashboard embed URL generation

* Pydantic 1.1 under python 3.8 does not allow custom datatypes with Generics

Re-implemented as a functional_validator

* Switched from using arrays to a descriptive dict for quicksight ARNs

Added the a /names API to get the dashboard names available
  • Loading branch information
RishiDiwanTT authored Oct 6, 2023
1 parent 5484028 commit 714dbd5
Show file tree
Hide file tree
Showing 11 changed files with 450 additions and 3 deletions.
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,8 +239,18 @@ export SIMPLIFIED_FCM_CREDENTIALS_FILE="/opt/credentials/fcm_credentials.json"
The FCM credentials can be downloaded once a Google Service account has been created.
More details in the [FCM documentation](https://firebase.google.com/docs/admin/setup#set-up-project-and-service-account)

##### Quicksight Dashboards

For generating quicksight dashboard links the following environment variable is required
`QUICKSIGHT_AUTHORIZED_ARNS` - A dictionary of the format `"<dashboard name>": ["arn:aws:quicksight:...",...]`
where each quicksight dashboard gets treated with an arbitrary "name", and a list of "authorized arns".
The first the "authorized arns" is always considered as the `InitialDashboardID` when creating an embed URL
for the respective "dashboard name".

#### Email

### Email sending

To use the features that require sending emails, for example to reset the password for logged-out users, you will need
to have a working SMTP server and set some environment variables:

Expand Down
3 changes: 3 additions & 0 deletions api/admin/controller/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

from typing import TYPE_CHECKING

from api.admin.controller.quicksight import QuickSightController

if TYPE_CHECKING:
from api.controller import CirculationManager

Expand Down Expand Up @@ -100,3 +102,4 @@ def setup_admin_controllers(manager: CirculationManager):
manager.admin_catalog_services_controller = CatalogServicesController(manager)
manager.admin_announcement_service = AnnouncementSettings(manager)
manager.admin_search_controller = AdminSearchController(manager)
manager.admin_quicksight_controller = QuickSightController(manager)
117 changes: 117 additions & 0 deletions api/admin/controller/quicksight.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import logging
from typing import Dict

import boto3
import flask

from api.admin.model.quicksight import (
QuicksightDashboardNamesResponse,
QuicksightGenerateUrlRequest,
QuicksightGenerateUrlResponse,
)
from api.controller import CirculationManagerController
from api.problem_details import NOT_FOUND_ON_REMOTE
from core.config import Configuration
from core.model.admin import Admin
from core.model.library import Library
from core.problem_details import INTERNAL_SERVER_ERROR, INVALID_INPUT
from core.util.problem_detail import ProblemError


class QuickSightController(CirculationManagerController):
def generate_quicksight_url(self, dashboard_name) -> Dict:
log = logging.getLogger(self.__class__.__name__)
admin: Admin = getattr(flask.request, "admin")
request_data = QuicksightGenerateUrlRequest(**flask.request.args)

all_authorized_arns = Configuration.quicksight_authorized_arns()
if not all_authorized_arns:
log.error("No Quicksight ARNs were configured for this server.")
raise ProblemError(
INTERNAL_SERVER_ERROR.detailed(
"Quicksight has not been configured for this server."
)
)

authorized_arns = all_authorized_arns.get(dashboard_name)
if not authorized_arns:
raise ProblemError(
INVALID_INPUT.detailed(
"The requested Dashboard ARN is not recognized by this server."
)
)

# The first dashboard id is the primary ARN
dashboard_arn = authorized_arns[0]
# format aws:arn:quicksight:<region>:<account id>:<dashboard>
arn_parts = dashboard_arn.split(":")
# Pull the region and account id from the ARN
aws_account_id = arn_parts[4]
region = arn_parts[3]
dashboard_id = arn_parts[5].split("/", 1)[1] # drop the "dashboard/" part

allowed_libraries = []
for library in self._db.query(Library).all():
if admin.is_librarian(library):
allowed_libraries.append(library)

if request_data.library_ids:
allowed_library_ids = list(
set(request_data.library_ids).intersection(
{l.id for l in allowed_libraries}
)
)
else:
allowed_library_ids = [l.id for l in allowed_libraries]

if not allowed_library_ids:
raise ProblemError(
NOT_FOUND_ON_REMOTE.detailed(
"No library was found for this Admin that matched the request."
)
)

libraries = (
self._db.query(Library).filter(Library.id.in_(allowed_library_ids)).all()
)

try:
delimiter = "|"
client = boto3.client("quicksight", region_name=region)
response = client.generate_embed_url_for_anonymous_user(
AwsAccountId=aws_account_id,
Namespace="default", # Default namespace only
AuthorizedResourceArns=authorized_arns,
ExperienceConfiguration={
"Dashboard": {"InitialDashboardId": dashboard_id}
},
SessionTags=[
dict(
Key="library_name",
Value=delimiter.join([l.name for l in libraries]),
)
],
)
except Exception as ex:
log.error(f"Error while fetching the Quisksight Embed url: {ex}")
raise ProblemError(
INTERNAL_SERVER_ERROR.detailed(
"Error while fetching the Quisksight Embed url."
)
)

embed_url = response.get("EmbedUrl")
if response.get("Status") // 100 != 2 or embed_url is None:
log.error(f"QuiskSight Embed url error response {response}")
raise ProblemError(
INTERNAL_SERVER_ERROR.detailed(
"Error while fetching the Quisksight Embed url."
)
)

return QuicksightGenerateUrlResponse(embed_url=embed_url).api_dict()

def get_dashboard_names(self):
"""Get the named dashboard IDs defined in the configuration"""
config = Configuration.quicksight_authorized_arns()
return QuicksightDashboardNamesResponse(names=list(config.keys())).api_dict()
23 changes: 23 additions & 0 deletions api/admin/model/quicksight.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from typing import List

from pydantic import Field, validator

from core.util.flask_util import CustomBaseModel, str_comma_list_validator


class QuicksightGenerateUrlRequest(CustomBaseModel):
library_ids: List[int] = Field(
description="The list of libraries to include in the dataset, an empty list is equivalent to all the libraries the user is allowed to access."
)

@validator("library_ids", pre=True)
def parse_library_ids(cls, value):
return str_comma_list_validator(value)


class QuicksightGenerateUrlResponse(CustomBaseModel):
embed_url: str = Field(description="The dashboard embed url.")


class QuicksightDashboardNamesResponse(CustomBaseModel):
names: List[str] = Field(description="The named quicksight dashboard ids")
38 changes: 36 additions & 2 deletions api/admin/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,16 @@
from api.admin.controller.custom_lists import CustomListsController
from api.admin.dashboard_stats import generate_statistics
from api.admin.model.dashboard_statistics import StatisticsResponse
from api.admin.model.quicksight import (
QuicksightDashboardNamesResponse,
QuicksightGenerateUrlRequest,
QuicksightGenerateUrlResponse,
)
from api.admin.templates import admin_sign_in_again as sign_in_again_template
from api.app import api_spec, app
from api.routes import allows_library, has_library, library_route
from core.app_server import ensure_pydantic_after_problem_detail, returns_problem_detail
from core.util.problem_detail import ProblemDetail, ProblemDetailModel
from core.util.problem_detail import ProblemDetail, ProblemDetailModel, ProblemError

# An admin's session will expire after this amount of time and
# the admin will have to log in again.
Expand Down Expand Up @@ -82,7 +87,11 @@ def returns_json_or_response_or_problem_detail(f):

@wraps(f)
def decorated(*args, **kwargs):
v = f(*args, **kwargs)
try:
v = f(*args, **kwargs)
except ProblemError as ex:
# A ProblemError is the same as a ProblemDetail
v = ex.problem_detail
if isinstance(v, ProblemDetail):
return v.response
if isinstance(v, Response):
Expand Down Expand Up @@ -313,6 +322,31 @@ def stats():
return statistics_response.api_dict()


@app.route("/admin/quicksight_embed/<dashboard_name>")
@api_spec.validate(
resp=SpecResponse(HTTP_200=QuicksightGenerateUrlResponse),
tags=["admin.quicksight"],
query=QuicksightGenerateUrlRequest,
)
@returns_json_or_response_or_problem_detail
@requires_admin
def generate_quicksight_url(dashboard_name: str):
return app.manager.admin_quicksight_controller.generate_quicksight_url(
dashboard_name
)


@app.route("/admin/quicksight_embed/names")
@api_spec.validate(
resp=SpecResponse(HTTP_200=QuicksightDashboardNamesResponse),
tags=["admin.quicksight"],
)
@returns_json_or_response_or_problem_detail
@requires_admin
def get_quicksight_names():
return app.manager.admin_quicksight_controller.get_dashboard_names()


@app.route("/admin/libraries", methods=["GET", "POST"])
@returns_json_or_response_or_problem_detail
@requires_admin
Expand Down
2 changes: 2 additions & 0 deletions api/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@
PatronAuthServiceSelfTestsController,
)
from api.admin.controller.patron_auth_services import PatronAuthServicesController
from api.admin.controller.quicksight import QuickSightController
from api.admin.controller.reset_password import ResetPasswordController
from api.admin.controller.search_service_self_tests import (
SearchServiceSelfTestsController,
Expand Down Expand Up @@ -220,6 +221,7 @@ class CirculationManager:
admin_announcement_service: AnnouncementSettings
admin_search_controller: AdminSearchController
admin_view_controller: ViewController
admin_quicksight_controller: QuickSightController

def __init__(self, _db, services: Services):
self._db = _db
Expand Down
12 changes: 11 additions & 1 deletion core/config.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import json
import logging
import os
from typing import Dict
from typing import Dict, List

from flask_babel import lazy_gettext as _
from sqlalchemy.engine.url import make_url
Expand Down Expand Up @@ -51,6 +51,10 @@ class Configuration(ConfigurationConstants):
OD_FULFILLMENT_CLIENT_KEY_SUFFIX = "OVERDRIVE_FULFILLMENT_CLIENT_KEY"
OD_FULFILLMENT_CLIENT_SECRET_SUFFIX = "OVERDRIVE_FULFILLMENT_CLIENT_SECRET"

# Quicksight
# Comma separated aws arns
QUICKSIGHT_AUTHORIZED_ARNS_KEY = "QUICKSIGHT_AUTHORIZED_ARNS"

# Environment variable for SirsiDynix Auth
SIRSI_DYNIX_APP_ID = "SIMPLIFIED_SIRSI_DYNIX_APP_ID"

Expand Down Expand Up @@ -284,6 +288,12 @@ def overdrive_fulfillment_keys(cls, testing=False) -> Dict[str, str]:
raise CannotLoadConfiguration("Invalid fulfillment credentials.")
return {"key": key, "secret": secret}

@classmethod
def quicksight_authorized_arns(cls) -> Dict[str, List[str]]:
"""Split the comma separated arns"""
arns_str = os.environ.get(cls.QUICKSIGHT_AUTHORIZED_ARNS_KEY, "")
return json.loads(arns_str)

@classmethod
def localization_languages(cls):
return [LanguageCodes.three_to_two["eng"]]
Expand Down
11 changes: 11 additions & 0 deletions core/util/flask_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -205,3 +205,14 @@ def api_dict(
rather than their Python class member names.
"""
return self.dict(*args, by_alias=by_alias, **kwargs)


def str_comma_list_validator(value):
"""Validate a comma separated string and parse it into a list, generally used for query parameters"""
if isinstance(value, (int, float)):
# A single number shows up as an int
value = str(value)
elif not isinstance(value, str):
raise TypeError("string required")

return value.split(",")
Loading

0 comments on commit 714dbd5

Please sign in to comment.