import csv import io import logging import time from datetime import datetime, timezone from typing import List, Optional from asgiref.sync import async_to_sync from fastapi import APIRouter, Body, Response, status from starlette.requests import Request from service import async_task_manager from service.api import schemas from service.async_task_manager import io_task from service.conf import settings from service.tasks.analytics import ( get_analytics, get_grouped_analytics, re_pre_calculate_chart_data, ) from service.tasks.audience.audience_csv import CsvAudience from service.tasks.base_attributes_generation import process_file from service.tasks.data_model import initialize_schema, insert_into_upload_url_commons from service.tasks.filtering import ( apply_filters, create_segment, get_filters, get_grouped_filters, get_saved_filters, re_pre_calculate_filter_config, save_segment, ) from service.tasks.filtering.filters import get_filter_conf from service.tasks.management.cognito import create_user from service.tasks.precondition.checks.deletion import ( AllianceCollectionDeleteCheck, AllianceDeleteCheck, AllianceMemberRemoveCheck, FacebookAudienceByIdDeleteCheck, WorkspaceCollectionDeleteCheck, WorkspaceDeleteCheck, WorkspaceSegmentByIdDeleteCheck, ) from service.tasks.precondition.engine import Precondition from service.utils.aws_connectors import run_query from service.utils.data_model_utils import ( change_collection_status, delete_garbage, generate_collection, get_collection_name, get_management_schema, obfuscate_sha256, refresh_materialized_queries, rename_collection, select_latest_alliance_analytics_segment, ) from service.utils.s3_file_operations import s3_put, s3_signed_download_url logger = logging.getLogger(__name__) router = APIRouter(tags=["Tasks"]) @router.get( "/delete_garbage", description=""" Delete unnecessary rows from some tables to avoid bloat, and to keep our db operations fast """, ) def remove_garbage(): delete_garbage() # this function name must not conflict with this routing function return "Deleted some garbage" @router.post( "/initialize_new_user", summary="Initialize new fresh user", status_code=status.HTTP_204_NO_CONTENT, ) def initialize_new_user( new_schema: str = Body(...), user_id: str = Body(...), email: str = Body(...), caw: str = Body("company"), ): """Creating new schema if not exists""" initialize_schema( schema=new_schema, user_id=user_id, caw=caw, email=email, ) return Response(status_code=status.HTTP_204_NO_CONTENT) @router.post("/create_new_user", response_model=schemas.UserBase) def create_new_user( new_schema: str = Body(...), userPoolName: str = Body(...), email: str = Body(...), tmpPasswd: str = Body(...), caw: str = Body("company"), ): """Create new fresh user""" user_id = create_user(email, tmpPasswd, new_schema, userPoolName) """Creating new schema if not exists""" initialize_schema(new_schema, user_id, caw, email) return {"user_id": user_id} @router.post("/drop_current_schema", status_code=status.HTTP_204_NO_CONTENT) def drop_current_schema( user_id: str = Body(...), email: str = Body(...), caw: str = Body("workspace"), workspace_schema: Optional[str] = Body(None), alliance_schema: Optional[str] = Body(None), ): """clear and recreate a schema""" if settings.PROFILE == "live": RuntimeError("nice try") schema = get_schema_or_error(alliance_schema, workspace_schema) if run_query( f"SELECT val FROM {schema}.meta_data WHERE key = 'management_version'" ): caw = "company" run_query(f"drop schema {schema} cascade;", fetch=False) """Recreating schema""" initialize_schema(schema, user_id, caw, email) if caw == "company": # alliance/workspace restoration procedure all_chemas = run_query( "select table_schema, table_name from information_schema.tables where table_name IN ('meta_data' )", fetch=True, ) existing_schemas = [schema[0] for schema in all_chemas] repairing_schema = schema repairing_user = user_id found_caws = [ aid[0] for aid in run_query( f"SELECT alliance_id from commons.company_alliance WHERE management_company_id = '{repairing_schema}'", fetch=True, ) ] existing_caws = [caw for caw in found_caws if caw in existing_schemas] nonexisting_caws = [caw for caw in found_caws if caw not in existing_schemas] statements = [] for caw in existing_caws: meta = run_query(f"SELECT * FROM {caw}.meta_data", fetch=True) for line in meta: if line["key"] == "creator": if line["val"] != repairing_user: # cleaning up very old hanging schemas statements.append(f"DROP SCHEMA {caw} cascade;") statements.append( "DELETE FROM commons.company_alliance WHERE alliance_id = 'caw'" ) break else: if caw[0] == "a": statements.append( f"INSERT INTO {repairing_schema}.alliance (id) VALUES ('{caw}')" ) statements.append( f"INSERT INTO {repairing_schema}.user_roles (id, company_alliance_workspace, user_id, roles) VALUES " f"('{caw}', 'alliance','{repairing_user}', '[\"owner\"]')" ) if caw[0] in "wc": statements.append( f"INSERT INTO {repairing_schema}.workspace (id) VALUES ('{caw}')" ) statements.append( f"INSERT INTO {repairing_schema}.user_roles (id, company_alliance_workspace, user_id, roles) VALUES " f"('{caw}', 'workspace','{repairing_user}', '[\"owner\"]')" ) for caw in nonexisting_caws: statements.append( f"DELETE FROM commons.company_alliance WHERE alliance_id = '{caw}'" ) for statement in statements: try: run_query(statement) except Exception as e: logger.exception( "ERROR while restoring alliances and workspaces to dropped schema", exc_info=e, ) return Response(status_code=status.HTTP_204_NO_CONTENT) @router.post("/initiate_file_upload", response_model=schemas.InitiateFileUploadOut) def initiate_file_upload( workspace_schema: str = Body(...), user_id: str = Body(...), fileName: str = Body(...), bucket: str = Body(...), ): """Initiating the file upload process""" """Creating custom hash to use in upload url""" current_timestamp = datetime.now(tz=timezone.utc) hash_part = obfuscate_sha256(fileName + str(current_timestamp)) file_key = hash_part + "/" + (fileName.split("/")[-1]) """Generating collection_id""" collection_id = generate_collection( collection_name=f"{fileName}", collection_source=f"{bucket}/{file_key}", schema_name=workspace_schema, user_id=user_id, status="uploading", log_description="Source collection upload", ) """Inserting data in to commons.url_path table""" insert_into_upload_url_commons( schema=workspace_schema, user_id=user_id, url_path=file_key, collection_id=collection_id, ) return {"collectionId": str(collection_id), "fileKey": str(file_key)} @router.post("/initiate_manual_file_upload") def initiate_manual_file_upload( schema_name: str = Body(..., alias="customerId"), user_id: str = Body(..., alias="userId"), file_name: str = Body(..., alias="fileName"), file_path: str = Body(..., alias="filePath"), bucket_name: str = Body(..., alias="bucket"), field_map: str = Body(..., alias="fieldMap"), ): """Initiating the file upload process""" start_time = time.time() """Creating new schema if not exists""" management_schema = get_management_schema(schema_name) """Creating custom hash to use in upload url""" file_key = file_path + "/" + file_name """Generating collection_id""" collection_id = generate_collection( collection_name=f"{file_name}", collection_source=f"{bucket_name}/{file_key}", schema_name=schema_name, user_id=user_id, status="manual_upload", ) process_file( schema_name, management_schema, file_key, bucket_name, file_name, field_map, collection_id, ) """ Process the actual csv file and load into the schema """ elapsed_time = time.time() - start_time logger.info( f"{schema_name} - successfully processed source collection {collection_id} in {elapsed_time} seconds" ) return f"All done, it took {elapsed_time} seconds" @router.post("/rename_collection", response_model=schemas.Collection) def rename_collection_ep( user_id: str = Body(...), collectionId: int = Body(...), newName: str = Body(...), workspace_schema: Optional[str] = Body(None), alliance_schema: Optional[str] = Body(None), ): """ Renames collection, sends appsync notifications about it """ schema = get_schema_or_error(alliance_schema, workspace_schema) collection = rename_collection(schema, user_id, collectionId, newName) collection["id"] = f"{schema}-{collection['id']}" return collection @router.post("/async_notify") def async_notify(request: Request): """ React asynchronously to events """ event_data = async_to_sync(request.json)() task_handle = async_task_manager.knows_how_to_handle(event_data) if task_handle: return task_handle.wait_for_immediate_response() else: raise RuntimeError("Unknown async event") @router.post("/get_analytics", response_model=List[schemas.ChartTypes]) def get_analytics_data( workspace_schema: Optional[str] = Body(None), alliance_schema: Optional[str] = Body(None), collectionId: Optional[int] = Body(None), ): """generate analytics""" schema = get_schema_or_error(alliance_schema, workspace_schema) return get_analytics( schema=schema, collection_id=collectionId, ) @router.post("/get_grouped_analytics", response_model=List[schemas.AnalyticsGroup]) def get_grouped_analytics_data( workspace_schema: Optional[str] = Body(None), alliance_schema: Optional[str] = Body(None), collectionId: Optional[int] = Body(None), ): """generate analytics""" schema = get_schema_or_error(alliance_schema, workspace_schema) if collectionId == "None": collectionId = None return get_grouped_analytics( schema=schema, collection_id=collectionId, filter_excessive_charts=(("ml", 6),), ) @router.post("/apply_filters", response_model=schemas.Collection) def apply_filters_ep( user_id: str = Body(...), workspace_schema: Optional[str] = Body(None), alliance_schema: Optional[str] = Body(None), source_id: Optional[str] = Body(None, alias="sourceId"), set_id: Optional[int] = Body(None, alias="setId"), filters: Optional[List] = Body(None), ): """apply filter to existing or new set""" schema = get_schema_or_error(alliance_schema, workspace_schema) # todo refactor: make route receive Optional[int], not ('123' or '') if source_id is not None: source_id_int = int(source_id) if source_id.isdigit() else None else: source_id_int = None if alliance_schema and not source_id: source_id_int = select_latest_alliance_analytics_segment(alliance_schema) return apply_filters( schema=schema, user_id=user_id, source_id=source_id_int, set_id=set_id or None, filter_input=filters, ) @router.post("/get_filters", response_model=List[schemas.Filter]) def get_filters_ep( workspace_schema: Optional[str] = Body(None), alliance_schema: Optional[str] = Body(None), setId: Optional[int] = Body(None), collectionId: Optional[int] = Body(None), ): """get filters all possible, if no set_id only applicable to a collection, if existing set_id points to source collection saved pre-filled filters, if existing set_id points to set/segment""" schema = get_schema_or_error(alliance_schema, workspace_schema) if alliance_schema and not collectionId: collectionId = select_latest_alliance_analytics_segment(alliance_schema) return get_filters( schema=schema, set_id=setId or collectionId, ) @router.post("/get_grouped_filters", response_model=List[schemas.FilterGroup]) def get_grouped_filters_ep( workspace_schema: Optional[str] = Body(None), alliance_schema: Optional[str] = Body(None), setId: Optional[int] = Body(None), collectionId: Optional[int] = Body(None), ): """get filters all possible, if no set_id only applicable to a collection, if existing set_id points to source collection saved pre-filled filters, if existing set_id points to set/segment""" schema = get_schema_or_error(alliance_schema, workspace_schema) if alliance_schema and not collectionId: collectionId = select_latest_alliance_analytics_segment(alliance_schema) return get_grouped_filters( schema=schema, set_id=setId or collectionId, ) @router.post("/get_saved_filters", response_model=List[schemas.SavedFilter]) def get_saved_filters_ep( workspace_schema: Optional[str] = Body(None), alliance_schema: Optional[str] = Body(None), setId: Optional[int] = Body(None), collectionId: Optional[int] = Body(None), ): """get filters all possible, if no set_id only applicable to a collection, if existing set_id points to source collection saved pre-filled filters, if existing set_id points to set/segment""" schema = get_schema_or_error(alliance_schema, workspace_schema) return get_saved_filters( schema=schema, set_id=setId or collectionId, ) @router.post("/create_segments", status_code=status.HTTP_204_NO_CONTENT) def create_segment_endpoint( user_id: str = Body(...), collection_id: int = Body(...), workspace_schema: Optional[str] = Body(None), alliance_schema: Optional[str] = Body(None), algo_collection_id: Optional[int] = Body(None), segment_attribute: Optional[str] = Body(None), segment_name: Optional[str] = Body(None), segment_values: Optional[List[str]] = Body(None), ): @io_task def _create_segment_async(*args, task_handle=None, **kwargs): create_segment(*args, **kwargs) schema = get_schema_or_error(alliance_schema, workspace_schema) if segment_attribute and segment_name and segment_values: try: filter_conf = get_filter_conf(attribute_name=segment_attribute) tasks = [ _create_segment_async( schema, user_id, segment_name.format(segment_value), collection_id, filter_conf["fid"], [segment_value], ) for segment_value in segment_values ] for task in tasks: # todo rewrite with asyncio task.wait_for_result() except KeyError as e: logger.info( f"Cannot generate {segment_name} segments because {e}", exc_info=e ) except RuntimeError as e: logger.exception("RuntimeError", exc_info=e) if algo_collection_id is not None: change_collection_status(schema, user_id, algo_collection_id, "finished") return Response(status_code=status.HTTP_204_NO_CONTENT) @router.post("/calculate_analytics_cache", status_code=status.HTTP_204_NO_CONTENT) def calculate_analytics_cache_endpoint( collection_id: int = Body(...), workspace_schema: Optional[str] = Body(None), alliance_schema: Optional[str] = Body(None), user_id: str = Body(None), ): schema = get_schema_or_error(alliance_schema, workspace_schema) change_collection_status(schema, user_id, collection_id, "analyzing") refresh_materialized_queries(schema) try: # Recalculate/cache globals and charts for collection re_pre_calculate_chart_data(schema, collection_id) # Recalculate/cache filter configs re_pre_calculate_filter_config(schema, collection_id) except Exception as e: logger.exception(e) change_collection_status(schema, user_id, collection_id, "finished") return Response(status_code=status.HTTP_204_NO_CONTENT) @router.post("/save_segment", response_model=schemas.Collection) def get_save_segment( user_id: str = Body(...), setId: int = Body(...), workspace_schema: Optional[str] = Body(None), alliance_schema: Optional[str] = Body(None), name: Optional[str] = Body(None), ): """save_segment all possible, if no set_id only applicable to a collection, if existing set_id points to source collection saved pre-filled filters, if existing set_id points to set/segment""" schema = get_schema_or_error(alliance_schema, workspace_schema) return save_segment( schema=schema, user_id=user_id, set_id=setId, new_name=name, ) @router.post("/delete_consequences", response_model=List[schemas.ConsequenceGroup]) def delete_consequences_ep( appsync_field: str = Body(...), management_schema: str = Body(...), user_id: str = Body(...), audienceId: Optional[str] = Body(None), workspace_schema: Optional[str] = Body(None), alliance_schema: Optional[str] = Body(None), alliance_management_schema: Optional[str] = Body(None), collectionId: Optional[int] = Body(None), collectionIds: Optional[List[int]] = Body(None), acceptedConsequences: Optional[str] = Body(None), memberId: Optional[str] = Body(None), ): precondition = None if appsync_field == "tryDeleteCollection": precondition = Precondition( WorkspaceCollectionDeleteCheck( workspace_schema, collectionIds or collectionId, management_schema=management_schema, user_id=user_id, ), WorkspaceSegmentByIdDeleteCheck( workspace_schema, collectionIds or collectionId, management_schema=management_schema, user_id=user_id, ), accepted_consequences=acceptedConsequences, ) elif appsync_field == "tryDeleteAllianceCollections": precondition = Precondition( AllianceCollectionDeleteCheck( alliance_schema, collectionIds or collectionId, management_schema=alliance_management_schema, user_id=user_id, ), accepted_consequences=acceptedConsequences, ) elif appsync_field == "tryDeleteWorkspace": precondition = Precondition( WorkspaceDeleteCheck( workspace_schema, management_schema=management_schema, user_id=user_id ), accepted_consequences=acceptedConsequences, ) elif appsync_field == "tryDeleteAlliance": precondition = Precondition( AllianceDeleteCheck( alliance_schema, management_schema=alliance_management_schema, user_id=user_id, ), accepted_consequences=acceptedConsequences, ) elif appsync_field == "tryRemoveAllianceMember": precondition = Precondition( AllianceMemberRemoveCheck( alliance_schema, memberId, management_schema=alliance_management_schema, user_id=user_id, ), accepted_consequences=acceptedConsequences, ) elif appsync_field == "tryFbDeleteAudience": precondition = Precondition( FacebookAudienceByIdDeleteCheck( alliance_schema or workspace_schema, audienceId, management_schema=management_schema, user_id=user_id, ), accepted_consequences=acceptedConsequences, ) if precondition is not None: return precondition.prepare_frontend_package() else: raise RuntimeError("Unknown appsync field") @router.post("/export_csv", response_model=schemas.ExportCsvOut) def export_csv( user_id: str = Body(...), management_schema: str = Body(...), collectionId: int = Body(...), bucket: str = Body(...), workspace_schema: Optional[str] = Body(None), alliance_schema: Optional[str] = Body(None), ): audience = CsvAudience( schema=workspace_schema or management_schema, alliance_schema=alliance_schema, collection_id=collectionId, ) df = audience.get_audience_data() profile_count = len(df) if profile_count: # Save file in s3 and sign download link s = io.StringIO() df.to_csv(s, quoting=csv.QUOTE_ALL) name = get_collection_name( alliance_schema or workspace_schema or management_schema, collectionId ) key = f"{management_schema}/exports/FanSifter export {name}{'' if name.endswith('.csv') else '.csv'}" s3_put(bucket, key, s.getvalue()) download_url = s3_signed_download_url(bucket, key) else: download_url = None return {"profileCount": profile_count, "downloadUrl": download_url} def get_schema_or_error( alliance_schema: Optional[str], workspace_schema: Optional[str] ) -> str: schema = alliance_schema or workspace_schema if schema: return schema raise ValueError("alliance_schema or workspace_schema should be defined.")