Skip to content
Snippets Groups Projects
annotations.py 19.1 KiB
Newer Older
from fastapi import APIRouter, Depends, HTTPException, status as http_status, Query
from nacsos_data.db.schemas import BotAnnotationMetaData, BotAnnotation
from nacsos_data.models.annotations import \
    AnnotationSchemeModel, \
    AssignmentScopeModel, \
    AssignmentModel, \
    AssignmentStatus, \
    AssignmentScopeConfig, \
    AnnotationSchemeModelFlat
from nacsos_data.models.bot_annotations import \
    ResolutionMethod, \
    AnnotationFilters, \
    BotAnnotationModel, \
Tim Repke's avatar
Tim Repke committed
    AnnotationCollection, \
    BotMetaResolve
from nacsos_data.models.items import AnyItemModel
from nacsos_data.db.crud.items import read_any_item_by_item_id
from nacsos_data.db.crud.projects import read_project_by_id
from nacsos_data.db.crud.annotations import \
    read_assignment, \
    read_assignments_for_scope, \
    read_assignments_for_scope_for_user, \
    read_assignment_scopes_for_project, \
    read_assignment_scopes_for_project_for_user, \
    read_annotations_for_assignment, \
    read_next_assignment_for_scope_for_user, \
    read_next_open_assignment_for_scope_for_user, \
    read_annotation_scheme, \
    read_annotation_schemes_for_project, \
    upsert_annotations, \
    read_assignment_scope, \
    upsert_annotation_scheme, \
    delete_annotation_scheme, \
    upsert_assignment_scope, \
    delete_assignment_scope, \
    read_item_ids_with_assignment_count_for_project, \
    read_assignment_counts_for_scope, \
    ItemWithCount, \
    AssignmentCounts, \
    UserProjectAssignmentScope, \
    store_assignments
from nacsos_data.util.annotations.resolve import \
    AnnotationFilterObject, \
    get_resolved_item_annotations
from nacsos_data.util.annotations.validation import \
    merge_scheme_and_annotations, \
    annotated_scheme_to_annotations, \
    flatten_annotation_scheme
Tim Repke's avatar
Tim Repke committed
from nacsos_data.util.annotations.assignments.random import random_assignments
Tim Repke's avatar
Tim Repke committed
from uuid import UUID
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
Tim Repke's avatar
Tim Repke committed

from server.api.errors import SaveFailedError, AssignmentScopeNotFoundError, NoNextAssignmentWarning, \
    ProjectNotFoundError, AnnotationSchemeNotFoundError, MissingInformationError, NoDataForKeyError
from server.util.security import UserPermissionChecker
from server.data import db_engine
Tim Repke's avatar
Tim Repke committed

router = APIRouter()


class AnnotatedItem(BaseModel):
    scheme: AnnotationSchemeModel
    assignment: AssignmentModel


class AnnotationItem(AnnotatedItem):
    scope: AssignmentScopeModel
@router.get('/schemes/definition/{annotation_scheme_id}',
            response_model=AnnotationSchemeModel | AnnotationSchemeModelFlat)
async def get_scheme_definition(annotation_scheme_id: str,
                                flat: bool = Query(default=False)) -> AnnotationSchemeModel | AnnotationSchemeModelFlat:
Tim Repke's avatar
Tim Repke committed
    """
    This endpoint returns the detailed definition of an annotation scheme.
Tim Repke's avatar
Tim Repke committed

    :param annotation_scheme_id: database id of the annotation scheme.
    :param flat: True to get the flattened scheme
    :return: a single annotation scheme
Tim Repke's avatar
Tim Repke committed
    """
    scheme = await read_annotation_scheme(annotation_scheme_id=annotation_scheme_id, db_engine=db_engine)
Tim Repke's avatar
Tim Repke committed
    if scheme is not None:
        if flat:
            return flatten_annotation_scheme(scheme)
Tim Repke's avatar
Tim Repke committed
        return scheme
    raise AnnotationSchemeNotFoundError(f'No `AnnotationScheme` found in DB for id {annotation_scheme_id}')
@router.put('/schemes/definition/', response_model=str)
async def put_annotation_scheme(annotation_scheme: AnnotationSchemeModel,
                                permissions=Depends(UserPermissionChecker('annotations_edit'))) -> str:
    key = await upsert_annotation_scheme(annotation_scheme=annotation_scheme, db_engine=db_engine)
@router.delete('/schemes/definition/{scheme_id}')
async def remove_annotation_scheme(annotation_scheme_id: str,
                                   permissions=Depends(UserPermissionChecker('annotations_edit'))) -> None:
    await delete_annotation_scheme(annotation_scheme_id=annotation_scheme_id, db_engine=db_engine)
@router.get('/schemes/list/{project_id}', response_model=list[AnnotationSchemeModel])
async def get_scheme_definitions_for_project(project_id: str) -> list[AnnotationSchemeModel]:
Tim Repke's avatar
Tim Repke committed
    """
    This endpoint returns the detailed definitions of all annotation schemes associated with a project.
Tim Repke's avatar
Tim Repke committed

    :param project_id: database id of the project
    :return: list of annotation schemes
Tim Repke's avatar
Tim Repke committed
    """
    return await read_annotation_schemes_for_project(project_id=project_id, db_engine=db_engine)
async def _construct_annotation_item(assignment: AssignmentModel, project_id: str) -> AnnotationItem:
Tim Repke's avatar
Tim Repke committed
    if assignment.assignment_id is None:
        raise MissingInformationError('No `assignment_id` set for `assignment`.')
    scope = await read_assignment_scope(assignment_scope_id=assignment.assignment_scope_id, db_engine=db_engine)
    scheme = await read_annotation_scheme(annotation_scheme_id=assignment.annotation_scheme_id, db_engine=db_engine)
Tim Repke's avatar
Tim Repke committed
    if scheme is None:
        raise AnnotationSchemeNotFoundError(f'No annotation scheme found in DB for id '
                                            f'{assignment.annotation_scheme_id}')

    annotations = await read_annotations_for_assignment(assignment_id=assignment.assignment_id, db_engine=db_engine)
Tim Repke's avatar
Tim Repke committed
    merged_scheme = merge_scheme_and_annotations(annotation_scheme=scheme, annotations=annotations)

    project = await read_project_by_id(project_id=project_id, engine=db_engine)
Tim Repke's avatar
Tim Repke committed
    if project is None:
        raise ProjectNotFoundError(f'No project found in DB for id {project_id}')
    item = await read_any_item_by_item_id(item_id=assignment.item_id, item_type=project.type, engine=db_engine)

Tim Repke's avatar
Tim Repke committed
    return AnnotationItem(scheme=merged_scheme, assignment=assignment, scope=scope, item=item)
@router.get('/annotate/next/{assignment_scope_id}/{current_assignment_id}', response_model=AnnotationItem)
async def get_next_assignment_for_scope_for_user(assignment_scope_id: str,
                                                 current_assignment_id: str,
                                                 permissions=Depends(UserPermissionChecker('annotations_read'))):
    # FIXME response for "last in list"
    assignment = await read_next_assignment_for_scope_for_user(current_assignment_id=current_assignment_id,
                                                               assignment_scope_id=assignment_scope_id,
                                                               user_id=permissions.user.user_id,
                                                               db_engine=db_engine)
Tim Repke's avatar
Tim Repke committed
    if assignment is None:
        raise NoNextAssignmentWarning(f'Could not determine a next assignment for scope {assignment_scope_id}')
Tim Repke's avatar
Tim Repke committed
    return await _construct_annotation_item(assignment=assignment, project_id=permissions.permissions.project_id)


@router.get('/annotate/next/{assignment_scope_id}', response_model=AnnotationItem)
async def get_next_open_assignment_for_scope_for_user(assignment_scope_id: str,
                                                      permissions=Depends(UserPermissionChecker('annotations_read'))):
    # FIXME response for "all done"
    assignment = await read_next_open_assignment_for_scope_for_user(assignment_scope_id=assignment_scope_id,
                                                                    user_id=permissions.user.user_id,
                                                                    db_engine=db_engine)
Tim Repke's avatar
Tim Repke committed
    return await _construct_annotation_item(assignment=assignment, project_id=permissions.permissions.project_id)


@router.get('/annotate/assignment/{assignment_id}', response_model=AnnotationItem)
async def get_assignment(assignment_id: str,
                         permissions=Depends(UserPermissionChecker('annotations_read'))):
    assignment = await read_assignment(assignment_id=assignment_id, db_engine=db_engine)
    if assignment.user_id != permissions.user.user_id:
        raise HTTPException(status_code=http_status.HTTP_401_UNAUTHORIZED,
                            detail='You do not have permission to handle this assignment, as it is not yours!')
Tim Repke's avatar
Tim Repke committed
    return await _construct_annotation_item(assignment=assignment, project_id=permissions.permissions.project_id)


@router.get('/annotate/scopes/{project_id}', response_model=list[UserProjectAssignmentScope])
async def get_assignment_scopes_for_user(project_id: str,
                                         permissions=Depends(UserPermissionChecker('annotations_read'))) \
        -> list[UserProjectAssignmentScope]:
    scopes = await read_assignment_scopes_for_project_for_user(project_id=project_id,
                                                               user_id=permissions.user.user_id,
                                                               db_engine=db_engine)
@router.get('/annotate/scopes/', response_model=list[AssignmentScopeModel])
async def get_assignment_scopes_for_project(permissions=Depends(UserPermissionChecker('annotations_edit'))) \
        -> list[AssignmentScopeModel]:
    scopes = await read_assignment_scopes_for_project(project_id=permissions.permissions.project_id,
                                                      db_engine=db_engine)
@router.get('/annotate/scope/{assignment_scope_id}', response_model=AssignmentScopeModel)
async def get_assignment_scope(assignment_scope_id: str,
Tim Repke's avatar
Tim Repke committed
                               permissions=Depends(
                                   UserPermissionChecker(['annotations_read', 'annotations_edit'], fulfill_all=False))
                               ) -> AssignmentScopeModel:
    scope = await read_assignment_scope(assignment_scope_id=assignment_scope_id, db_engine=db_engine)
Tim Repke's avatar
Tim Repke committed
    if scope is not None:
        return scope
    raise AssignmentScopeNotFoundError(f'No assignment scope found in the DB for {assignment_scope_id}')


@router.put('/annotate/scope/', response_model=str)
async def put_assignment_scope(assignment_scope: AssignmentScopeModel,
                               permissions=Depends(UserPermissionChecker('annotations_edit'))) -> str:
    key = await upsert_assignment_scope(assignment_scope=assignment_scope, db_engine=db_engine)
    return str(key)


@router.delete('/annotate/scope/{assignment_scope_id}')
async def remove_assignment_scope(assignment_scope_id: str,
                                  permissions=Depends(UserPermissionChecker('annotations_edit'))) -> None:
    try:
        await delete_assignment_scope(assignment_scope_id=assignment_scope_id, db_engine=db_engine)
    except ValueError as e:
        raise HTTPException(status_code=http_status.HTTP_400_BAD_REQUEST,
                            detail=str(e))


@router.get('/annotate/scope/counts/{assignment_scope_id}', response_model=AssignmentCounts)
async def get_num_assignments_for_scope(assignment_scope_id: str,
                                        permissions=Depends(
                                            UserPermissionChecker(['annotations_read', 'annotations_edit'],
Tim Repke's avatar
Tim Repke committed
                                                                  fulfill_all=False))
                                        ) -> AssignmentCounts:
    scope = await read_assignment_counts_for_scope(assignment_scope_id=assignment_scope_id, db_engine=db_engine)
@router.get('/annotate/assignments/{assignment_scope_id}', response_model=list[AssignmentModel])
async def get_assignments(assignment_scope_id: str, permissions=Depends(UserPermissionChecker('annotations_read'))) \
        -> list[AssignmentModel]:
    assignments = await read_assignments_for_scope_for_user(assignment_scope_id=assignment_scope_id,
                                                            user_id=permissions.user.user_id,
                                                            db_engine=db_engine)
@router.get('/annotate/assignments/scope/{assignment_scope_id}', response_model=list[AssignmentModel])
async def get_assignments_for_scope(assignment_scope_id: str,
                                    permissions=Depends(UserPermissionChecker('annotations_read'))) \
        -> list[AssignmentModel]:
    assignments = await read_assignments_for_scope(assignment_scope_id=assignment_scope_id,
                                                   db_engine=db_engine)
@router.get('/annotate/annotations/{assignment_scope_id}', response_model=list[AssignmentModel])
async def get_annotations(assignment_scope_id: str, permissions=Depends(UserPermissionChecker('annotations_read'))) \
        -> list[AssignmentModel]:
    assignments = await read_assignments_for_scope_for_user(assignment_scope_id=assignment_scope_id,
                                                            user_id=permissions.user.user_id,
                                                            db_engine=db_engine)
    return assignments
@router.post('/annotate/save', response_model=AssignmentStatus)
async def save_annotation(annotated_item: AnnotatedItem,
                          permissions=Depends(UserPermissionChecker('annotations_read'))) -> AssignmentStatus:
    # double-check, that the supposed assignment actually exists
Tim Repke's avatar
Tim Repke committed
    if annotated_item.assignment.assignment_id is None:
        raise MissingInformationError('Missing `assignment_id` in `annotation_item`!')

    assignment_db = await read_assignment(assignment_id=annotated_item.assignment.assignment_id, db_engine=db_engine)

    if permissions.user.user_id == assignment_db.user_id \
            and str(assignment_db.assignment_scope_id) == annotated_item.assignment.assignment_scope_id \
            and str(assignment_db.item_id) == annotated_item.assignment.item_id \
            and str(assignment_db.annotation_scheme_id) == annotated_item.assignment.annotation_scheme_id:
        annotations = annotated_scheme_to_annotations(annotated_item.scheme)
        status = await upsert_annotations(annotations=annotations,
                                          assignment_id=annotated_item.assignment.assignment_id,
                                          db_engine=db_engine)
Tim Repke's avatar
Tim Repke committed
        if status is not None:
            return status
        raise SaveFailedError('Failed to save annotation!')
    else:
        raise HTTPException(
            status_code=http_status.HTTP_403_FORBIDDEN,
Tim Repke's avatar
Tim Repke committed
            detail='The combination of project, assignment, user, task, and item is invalid.',


@router.get('/config/items/', response_model=list[ItemWithCount])
Tim Repke's avatar
Tim Repke committed
async def get_items_with_count(permissions=Depends(UserPermissionChecker('dataset_read'))) \
        -> list[ItemWithCount]:
    items = await read_item_ids_with_assignment_count_for_project(project_id=permissions.permissions.project_id,
                                                                  db_engine=db_engine)
    return items


class MakeAssignmentsRequestModel(BaseModel):
    annotation_scheme_id: str
    scope_id: str
    config: AssignmentScopeConfig
    save: bool = False


@router.post('/config/assignments/', response_model=list[AssignmentModel])
async def make_assignments(payload: MakeAssignmentsRequestModel,
                           permissions=Depends(UserPermissionChecker('annotations_edit'))):
    if payload.config.config_type == 'random':
        print(payload.config)
        try:
            assignments = await random_assignments(assignment_scope_id=payload.scope_id,
                                                   annotation_scheme_id=payload.annotation_scheme_id,
                                                   project_id=permissions.permissions.project_id,
                                                   config=payload.config,
                                                   engine=db_engine)
        except ValueError as e:
            raise HTTPException(status_code=http_status.HTTP_400_BAD_REQUEST,
                                detail=str(e))
    else:
        raise HTTPException(status_code=http_status.HTTP_501_NOT_IMPLEMENTED,
                            detail=f'Method "{payload.config.config_type}" is unknown.')

    if payload.save:
        await store_assignments(assignments=assignments, db_engine=db_engine)
class ResolutionProposalResponse(BaseModel):
Tim Repke's avatar
Tim Repke committed
    collection: AnnotationCollection
    proposal: list[BotAnnotationModel]


class SavedResolutionResponse(BaseModel):
    meta: BotMetaResolve
    saved: list[BotAnnotationModel]


@router.get('/config/resolve/', response_model=ResolutionProposalResponse)
async def get_item_annotation_matrix(strategy: ResolutionMethod,
Tim Repke's avatar
Tim Repke committed
                                     scheme_id: str,
                                     scope_id: list[str] | None = Query(default=None),
                                     user_id: list[str] | None = Query(default=None),
                                     key: list[str] | None = Query(default=None),
                                     repeat: list[int] | None = Query(default=None),
Tim Repke's avatar
Tim Repke committed
                                     ignore_order: bool | None = Query(default=False),
                                     ignore_hierarchy: bool | None = Query(default=False),
                                     permissions=Depends(UserPermissionChecker('annotations_edit'))):
    """
    Get all annotations that match the filters (e.g. all annotations made by users in scope with :scope_id).
    Annotations are returned in a 3D matrix:
       rows (dict entries): items (key: item_id)
       columns (list index of dict entry): Label (key in scheme + repeat); index map in matrix.keys
       cells: list of annotations by each user for item/Label combination

    :param strategy
    :param scheme_id:
    :param scope_id:
    :param user_id:
    :param key:
    :param repeat:
    :param permissions:
Tim Repke's avatar
Tim Repke committed
    :param ignore_order:
    :param ignore_hierarchy:
    filters = AnnotationFilters(
        scheme_id=scheme_id,
        scope_id=scope_id,
        user_id=user_id,
        key=key,
        repeat=repeat,
    )
Tim Repke's avatar
Tim Repke committed
    collection, resolved = await get_resolved_item_annotations(strategy=strategy,
                                                               filters=AnnotationFilterObject.parse_obj(filters),
                                                               ignore_order=ignore_order,
                                                               ignore_hierarchy=ignore_hierarchy,
                                                               db_engine=db_engine)
    return ResolutionProposalResponse(collection=collection, proposal=resolved)


@router.get('/config/resolved/:bot_annotation_meta_id', response_model=SavedResolutionResponse)
async def get_saved_resolved_annotations(bot_annotation_meta_id: str,
                                         permissions=Depends(UserPermissionChecker('annotations_edit'))):
    async with db_engine.session() as session:  # type: AsyncSession
        meta = await session.get(BotAnnotationMetaData, bot_annotation_meta_id)
        if meta is None:
            raise NoDataForKeyError(f'No `BotAnnotationMetaData` for "{bot_annotation_meta_id}"!')
Tim Repke's avatar
Tim Repke committed
        bot_annotations = (await session.scalars(
            select(BotAnnotation).where(BotAnnotation.bot_annotation_metadata_id == bot_annotation_meta_id))).all()

        return SavedResolutionResponse(
            meta=meta.meta,
Tim Repke's avatar
Tim Repke committed
            saved=[BotAnnotationModel.parse_obj(bot_annotation)for bot_annotation in bot_annotations]