diff --git a/.gitignore b/.gitignore index b35c2d1b..8a6907af 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,8 @@ docs/node_modules yarn.lock yarn-error.log lerna-debug.log +.yarn/ +.pnp* # generated files covfee/shared/schemata.json @@ -40,3 +42,5 @@ samples/conflab/media/* samples/memory/media/* samples/memory/media.zip +# FIXME: #CONFLAB ignore the deployment files that now live in the private gitlab repo +samples/continuous_annotation/* \ No newline at end of file diff --git a/README.md b/README.md index 31b189a0..e8d8e391 100644 --- a/README.md +++ b/README.md @@ -2,32 +2,11 @@ Covfee was created to provide an easily extensible tool for video perception and annotation experiments, especially those requiring continuous feedback from the user. -Full documentation: [covfee docs](https://josedvq.github.io/covfee/) +Full documentation: [covfee docs](https://tudelft-spc-lab.github.io) ## Quick start -This document contains instructions for installing covfee locally. We recommend that you work with covfee locally first even if you plan to put it online. - -### Setup - -1. Install version 12.x of [node.js](https://nodejs.org/en/download/). Make sure that the `npm` command is available in your terminal. - -2. Clone this repository and install covfee using pip: - -``` -git clone git@github.com:josedvq/covfee.git -cd covfee -python3 -m pip install -e . -``` - -3. Install Javascript dependencies: -``` -covfee-installjs -``` - -### Getting started - -Please see the [covfee docs](https://master--5faeef49f6655f00210dbf35.chromatic.com) for an interactive getting started guide. - -## Changing covfee -If you wish to change the source code of the backend or make changes to the frontend that are not supported by a custom task, see the [Development guide](docs/development.md). +1. Follow the [installation instructions](https://tudelft-spc-lab.github.io/covfee/docs/development). +2. Navigate in your terminal to the `samples/tutorial` folder. +3. Follow the import the task [steps](https://tudelft-spc-lab.github.io/covfee/docs/custom_task#importing-the-task). +3. Follow the running the task [steps](https://tudelft-spc-lab.github.io/covfee/docs/custom_task#running-the-task). diff --git a/covfee/cli/commands/dev.py b/covfee/cli/commands/dev.py index b349f94c..20882006 100644 --- a/covfee/cli/commands/dev.py +++ b/covfee/cli/commands/dev.py @@ -2,6 +2,7 @@ These commands meant as development tools only """ + import os import click @@ -41,3 +42,25 @@ def make_schemata(): schema.make() schema.make_dataclasses() spinner.succeed("Schemata made.") + + +if __name__ == "__main__": + import sys + + # The following code is intended to run a specific covfee command, such as "build" + # from the command line, so it is configurable with a debugger (like in VSCode). + # For example, running `python dev.py --debug-covfee-command build ` + DEBUG_COMMAND: str = "--debug-covfee-command" + if DEBUG_COMMAND in sys.argv: + debug_command_index = sys.argv.index(DEBUG_COMMAND) + 1 + if debug_command_index < len(sys.argv): + debug_command = sys.argv[debug_command_index] + # Note: covfee uses "click" to parse command line parameters. We remove + # DEBUG_COMMAND, which is not recognized by either of the available + # covfee functions. + sys.argv.pop(debug_command_index) + sys.argv.remove(DEBUG_COMMAND) + if debug_command == "schemata": + make_schemata() + elif debug_command == "build": + build_master() diff --git a/covfee/cli/commands/launch.py b/covfee/cli/commands/launch.py index 81608262..2f76644f 100644 --- a/covfee/cli/commands/launch.py +++ b/covfee/cli/commands/launch.py @@ -17,7 +17,8 @@ from covfee.launcher import Launcher, ProjectExistsException, launch_webpack from covfee.shared.validator.validation_errors import JavascriptError, ValidationError -from ...loader import Loader +from covfee.loader import Loader +import sys colorama_init() @@ -97,7 +98,15 @@ def get_start_message(url): "--no-launch", is_flag=True, help="Do not launch covfee, only make the DB" ) @click.argument("project_spec_file") -def make(force, dev, deploy, safe, rms, no_launch, project_spec_file): +def make( + force: bool, + dev: bool, + deploy: bool, + safe: bool, + rms: bool, + no_launch: bool, + project_spec_file: str, +): mode = "local" if dev: mode = "dev" @@ -109,13 +118,19 @@ def make(force, dev, deploy, safe, rms, no_launch, project_spec_file): install_npm_packages() try: + # 1. Parse the project spec file into a format that covfee can manage (CovfeeApp) loader = Loader(project_spec_file) - projects = loader.process(with_spinner=True) + covfee_app = loader.load_project_spec_file_and_parse_as_covfee_app( + with_spinner=True + ) + # 2. Create or update the database launcher = Launcher( - mode, projects, Path(project_spec_file).parent, auth_enabled=not unsafe + mode, covfee_app, Path(project_spec_file).parent, auth_enabled=not unsafe ) - launcher.make_database(force, with_spinner=True) + launcher.create_or_update_database(delete_existing_data=force) + + # 3. Launch the app based on the current data/configuration. if not no_launch: print( get_start_message( @@ -179,3 +194,21 @@ def install_npm_packages(force=False): npm_package = NPMPackage(shared_path) if force or not npm_package.is_installed(): npm_package.install() + + +if __name__ == "__main__": + + # The following code is intended to run a specific covfee command, such as "make" + # from the command line, so it is configurable with a debugger (like in VSCode). + # For example, running `python launch.py --debug-covfee-command make ` + DEBUG_COMMAND: str = "--debug-covfee-command" + if DEBUG_COMMAND in sys.argv: + debug_command_index = sys.argv.index(DEBUG_COMMAND) + 1 + if debug_command_index < len(sys.argv): + debug_command = sys.argv[debug_command_index] + # Note: covfee uses "click" to parse command line parameters. We remove + # DEBUG_COMMAND, which is not recognized by either of the available + # covfee functions. + sys.argv.pop(debug_command_index) + sys.argv.remove(DEBUG_COMMAND) + globals()[debug_command]() diff --git a/covfee/client/admin/force_graph.tsx b/covfee/client/admin/force_graph.tsx index 690d7764..0258dbc1 100644 --- a/covfee/client/admin/force_graph.tsx +++ b/covfee/client/admin/force_graph.tsx @@ -1,10 +1,10 @@ -import * as React from "react" +import type { SimulationNodeDatum } from "d3" import * as d3 from "d3" -import type { SimulationNodeDatum, Simulation } from "d3" -import { NodeStatusToColor, getNodeStatus } from "./utils" -import { NodeType } from "../types/node" +import * as React from "react" +import { ReducedJourney } from "../models/Journey" import { HitInstanceType } from "../types/hit" -import { JourneyType, ReducedJourney } from "../models/Journey" +import { NodeType } from "../types/node" +import { NodeStatusToColor, getNodeStatus } from "./utils" const getDimensions = (nodes: SimulationNode[], nodeRadius: number) => { let minX = Infinity, @@ -22,12 +22,18 @@ const getDimensions = (nodes: SimulationNode[], nodeRadius: number) => { } const createNodes = (nodes: NodeType[], focusedNode: number) => { - const res = nodes.map((n, index) => ({ - id: n.id, - name: n.name, - focused: focusedNode == index, - color: NodeStatusToColor[getNodeStatus(n)], - })) + const res = nodes.map((n, index) => { + let name = n.name + if (n.progress !== null && n.progress !== undefined) { + name += ` (${n.progress.toFixed(1)}%)` + } + return { + id: n.id, + name: name, + focused: focusedNode === index, + color: NodeStatusToColor[getNodeStatus(n)], + } + }) return res } @@ -118,6 +124,9 @@ export const ForceGraph = ({ .attr("stroke-opacity", ({ index }) => nodes[index].focused ? "0.1" : "0.01" ) + nodesRefs.current + .selectChildren("text") + .text(({ index }) => nodes[index].name) } React.useEffect(() => { @@ -256,6 +265,7 @@ export const ForceGraph = ({ .attr("fill", "#000") .attr("stroke", "#fff") .attr("stroke-width", "5px") + .attr("text-anchor", "middle") .attr("x", (d) => d.x) .attr("y", (d) => d.y) .text(({ index }) => nodes[index].name) diff --git a/covfee/client/admin/hit_block/hit_block.tsx b/covfee/client/admin/hit_block/hit_block.tsx index 1bb41a76..8fcca1f4 100644 --- a/covfee/client/admin/hit_block/hit_block.tsx +++ b/covfee/client/admin/hit_block/hit_block.tsx @@ -1,10 +1,12 @@ import * as React from "react" import styled from "styled-components" -import { HitInstanceType } from "../../types/hit" -import { NodeStatus } from "../../types/node" import { NodeIndexOutlined } from "@ant-design/icons" +import classNames from "classnames" import { appContext } from "../../app_context" +import { HitInstanceType } from "../../types/hit" +import { NodeStatus, NodeType } from "../../types/node" +import { ForceGraph } from "../force_graph" import { JourneyColorStatus, JourneyColorStatuses, @@ -16,13 +18,10 @@ import { getJourneyStatus, getNodeStatus, } from "../utils" -import classNames from "classnames" -import { ForceGraph } from "../force_graph" +import { JourneyRow } from "./journey_buttons" import { NodeButtons, NodeRow } from "./node_buttons" -import { HoveringButtons } from "./utils" import type { HoveringButtonsArgs } from "./utils" -import { JourneyRow } from "./journey_buttons" - +import { HoveringButtons } from "./utils" interface Props { hit: HitInstanceType } @@ -130,7 +129,14 @@ export const HitBlock = (props: Props) => { {!collapsed && (
-
+

Nodes

@@ -177,8 +183,16 @@ export const HitBlock = (props: Props) => {
    {props.hit.journeys.map((journey, index) => { + let journeyNodes: NodeType[] = [] + for (const node_id of journey.nodes) { + journeyNodes.push( + props.hit.nodes.find((node) => node.id === node_id) + ) + } + return ( { @@ -244,9 +258,12 @@ const JourneyStatusSummary = NodeStatusSummary const GraphContainer = styled.div` flex: 1 0 auto; max-width: 60%; + /* FIXME #CONFLAB: Hiding the GraphContainer because it takes too much space */ + visibility: hidden; + width: 0px; + height: 0px; ` const NodesList = styled.div` - max-width: 50%; flex: 1 0 auto; padding: 3px; diff --git a/covfee/client/admin/hit_block/journey_buttons.tsx b/covfee/client/admin/hit_block/journey_buttons.tsx index 0330cd49..0bb68974 100644 --- a/covfee/client/admin/hit_block/journey_buttons.tsx +++ b/covfee/client/admin/hit_block/journey_buttons.tsx @@ -1,7 +1,3 @@ -import * as React from "react" -import { JourneyType } from "../../types/journey" -import { Modal } from "antd" -const { confirm } = Modal import { ApiOutlined, DeleteOutlined, @@ -9,20 +5,32 @@ import { PauseOutlined, WechatOutlined, } from "@ant-design/icons" -import { useJourneyFns } from "../../models/Journey" -import { JourneyStatusToColor, StatusIcon, getJourneyStatus } from "../utils" +import { Modal } from "antd" import classNames from "classnames" +import * as React from "react" +import { styled } from "styled-components" import { chatContext } from "../../chat_context" +import { fetchAnnotator, useJourneyFns } from "../../models/Journey" +import { JourneyType } from "../../types/hit" +import { NodeType } from "../../types/node" +import { JourneyStatusToColor, StatusIcon, getJourneyStatus } from "../utils" import { ButtonsContainer } from "./utils" -import { styled } from "styled-components" +const { confirm } = Modal + +interface Annotator { + prolific_id: string + created_at: Date +} type JourneyRowProps = { journey: JourneyType + journeyNodes: NodeType[] focus: boolean onFocus: () => void onBlur: () => void } export const JourneyRow = ({ + journeyNodes, journey, focus, onFocus, @@ -30,6 +38,41 @@ export const JourneyRow = ({ }: JourneyRowProps) => { const { addChats } = React.useContext(chatContext) const { getUrl } = useJourneyFns(journey) + const [annotator, setAnnotator] = React.useState(null) + const [progress, setProgress] = React.useState(0) + + React.useEffect(() => { + fetchAnnotator(journey.id).then((payload) => { + if (Object.keys(payload).length === 0) { + return + } + console.log( + `loaded prolific id ${payload.prolific_pid}, created_at ${payload.created_at}` + ) + let date = new Date(payload.created_at) + date.setMilliseconds(0) // Ignore milliseconds + setAnnotator({ + prolific_id: payload.prolific_pid, + created_at: date, + } as Annotator) + }) + }, [journey]) + + React.useEffect(() => { + let progressSum: number = 0.0 + + for (const node of journeyNodes) { + if (node.progress !== null) { + progressSum += node.progress + } else { + if (node.status === "FINISHED") { + progressSum += 100 + } + } + } + + setProgress(progressSum / journeyNodes.length) + }, [journey, journeyNodes]) return (
  • - {journey.id.substring(0, 10)} + {journey.id.substring(0, 10)} + +
      + {annotator != null && ( +
    • Prolific PID: "{annotator.prolific_id}"
    • + )} + {annotator != null && ( +
    • Start date: {annotator.created_at.toLocaleString()}
    • + )} +
    • Progress: {progress.toFixed(1)}%
    • +
    +
  • */} diff --git a/covfee/client/journey/journey.tsx b/covfee/client/journey/journey.tsx index 58b25469..7a95ea31 100644 --- a/covfee/client/journey/journey.tsx +++ b/covfee/client/journey/journey.tsx @@ -1,15 +1,13 @@ -import * as React from "react" -import styled from "styled-components" -import { generatePath } from "react-router" import { ArrowRightOutlined, PlusOutlined } from "@ant-design/icons" -import { Row, Col, Typography, Menu, Button, Modal, Progress } from "antd" +import { Button, Menu, Modal, Progress, Row } from "antd" import "antd/dist/reset.css" +import * as React from "react" +import { generatePath } from "react-router" +import styled from "styled-components" -import { myerror } from "../utils" -import { MarkdownLoader } from "../tasks/utils/markdown_loader" import { CovfeeMenuItem } from "../gui" -import { Sidebar } from "./sidebar" - +import { MarkdownLoader } from "../tasks/utils/markdown_loader" +import { myerror } from "../utils" import { JourneyContext, JourneyContextType, @@ -17,17 +15,18 @@ import { defaultTimerState, } from "./journey_context" import { NodeLoader } from "./node_loader" +import { Sidebar } from "./sidebar" -import "./journey.scss" -import { FullJourney, fetchJourney, useJourney } from "../models/Journey" -import { useState, useContext } from "react" -import { AllPropsRequired } from "../types/utils" -import { appContext } from "../app_context" +import { useContext, useState } from "react" import { useParams } from "react-router-dom" -import { ChatPopup } from "../chat/chat" +import { appContext } from "../app_context" import { AppProvider } from "../app_provider" +import { ChatPopup } from "../chat/chat" import { ChatProvider, chatContext } from "../chat_context" +import { FullJourney, fetchJourney, useJourney } from "../models/Journey" import { Chat } from "../types/chat" +import { AllPropsRequired } from "../types/utils" +import "./journey.scss" import { Timer } from "./timer" type Props = { @@ -41,13 +40,18 @@ type Props = { */ routingEnabled?: boolean + /** + * Show the sidebar with the journey nodes + */ + showSideBar?: boolean + // ASYNC OPERATIONS // submitTaskResponse: (arg0: TaskResponseType, arg1: any) => Promise // fetchTaskResponse: (arg0: TaskType) => Promise /** * Called when the Hit submit button is clicked */ - onSubmit?: () => Promise + onSubmit?: () => Promise | null } export const _JourneyPage: React.FC = (props) => { @@ -55,6 +59,7 @@ export const _JourneyPage: React.FC = (props) => { () => ({ routingEnabled: true, previewMode: false, + showSideBar: false, onSubmit: () => null, ...props, }), @@ -112,6 +117,12 @@ export const _JourneyPage: React.FC = (props) => { }, []) const showCompletionInfo = React.useCallback(() => { + // FIXME: #CONFLAB this implementation ties strongly the submission logic + // with immediately showing this pop up to redirect to prolific. + // Instead, for the continuous annotation task, we wanted to + // allow the user to confirm, to submit, and then decide when + // to redirect, for better user experience. + return const config = journey.completionInfo return Modal.success({ title: "HIT submitted!", @@ -145,25 +156,30 @@ export const _JourneyPage: React.FC = (props) => { }, [journey]) const handleSubmit = React.useCallback(() => { - args - .onSubmit() - .then(() => { - showCompletionInfo() - }) - .catch((err) => { - if (err.message.includes("required tasks")) { - myerror( - err.message + - " Please make sure all tasks are marked green before submitting.", - err - ) - } else { - myerror( - "Error submitting HIT. Please try again or contact the organizers.", - err - ) - } - }) + let callback = args.onSubmit() + + if (callback instanceof Promise) { + callback + .then(() => { + showCompletionInfo() + }) + .catch((err) => { + if (err.message.includes("required tasks")) { + myerror( + err.message + + " Please make sure all tasks are marked green before submitting.", + err + ) + } else { + myerror( + "Error submitting HIT. Please try again or contact the organizers.", + err + ) + } + }) + } else { + showCompletionInfo() + } }, [args, showCompletionInfo]) const gotoNextNode = React.useCallback(() => { @@ -226,7 +242,14 @@ export const _JourneyPage: React.FC = (props) => { onClick={handleMenuClick} mode="horizontal" theme="dark" - style={{ position: "sticky", top: 0, width: "100%", zIndex: 1000 }} + style={{ + position: "sticky", + top: 0, + width: "100%", + zIndex: 1000, + /* FIXME #CONFLAB: Force hiding for the mingle experiments using the continuous annotation task */ + display: "none", + }} > @@ -245,29 +268,35 @@ export const _JourneyPage: React.FC = (props) => { )} - - - {journey.submitted && ( - - )} - - + {args.showSideBar && ( + + + {journey.submitted && ( + + )} + + + )} - + {hitExtra && ( <> // @@ -313,20 +342,28 @@ const SidebarContainer = styled.div` position: sticky; display: inline-block; vertical-align: top; - top: 46px; - height: calc(100vh - 46px); + // top: 46px; // FIXME #CONFLAB: Force hiding for the mingle experiments using the continuous annotation task + height: calc(100vh); width: 25%; overflow: auto; ` -const ContentContainer = styled.div` +interface ContentContainerProps { + showSideBar: boolean + height?: number +} + +const ContentContainer = styled.div` position: fixed; - top: 46px; + // top: 46px; // FIXME #CONFLAB: Force hiding for the mingle experiments using the continuous annotation task right: 0; display: inline-block; vertical-align: top; - height: calc(100vh - 46px); - width: calc(100% - 25%); + height: ${(props) => + props.height + ? props.height + : "calc(100vh)"}; // FIXME #CONFLAB: Force hiding for the mingle experiments using the continuous annotation task + width: ${(props) => (props.showSideBar ? "calc(100% - 25%)" : "100%")}; overflow: auto; ` diff --git a/covfee/client/journey/node_loader.tsx b/covfee/client/journey/node_loader.tsx index c3af4a36..b2c7f1bb 100644 --- a/covfee/client/journey/node_loader.tsx +++ b/covfee/client/journey/node_loader.tsx @@ -59,6 +59,8 @@ export const NodeLoader: React.FC = (props: Props) => { fetchResponse, submitResponse, setReady, + setProgress: setNodeProgress, + submitProgress, } = useNode(args.node, socket) const [isLoading, setIsLoading] = React.useState(true) @@ -297,6 +299,17 @@ export const NodeLoader: React.FC = (props: Props) => { }) } + const handleTaskProgressSubmit = (progress: number) => { + submitProgress(progress) + .then((data: any) => { + setNodeProgress(progress) + }) + .catch((error) => { + myerror("Error updating task progress.", error) + setNodeProgress(progress) + }) + } + const renderErrorMessage = React.useCallback(() => { return ( @@ -377,7 +390,7 @@ export const NodeLoader: React.FC = (props: Props) => { {...extraProps} onClick={handleTaskSubmit} htmlType="submit" - disabled={node.status !== "RUNNING"} + disabled={node.status !== "RUNNING" || extraProps.disabled} > Submit @@ -441,6 +454,7 @@ export const NodeLoader: React.FC = (props: Props) => { disabled: node.status == "FINISHED", onSubmit: handleTaskSubmit, renderSubmitButton: renderTaskSubmitButton, + onUpdateProgress: handleTaskProgressSubmit, } const taskElement = React.createElement( diff --git a/covfee/client/journey/sidebar.tsx b/covfee/client/journey/sidebar.tsx index 84346910..93b1e719 100644 --- a/covfee/client/journey/sidebar.tsx +++ b/covfee/client/journey/sidebar.tsx @@ -1,6 +1,6 @@ +import classNames from "classnames" import * as React from "react" import styled from "styled-components" -import classNames from "classnames" import { NodeType } from "../types/node" @@ -98,6 +98,12 @@ export const Sidebar: React.FC = (props) => { status={props.currNode === index ? "active" : "default"} active={props.currNode === index} onClickActivate={() => { + if (props.nodes[props.currNode].prerequisite) { + alert( + "Please complete the current task before moving to the next one." + ) + return + } props.onChangeActiveTask(index) }} /> diff --git a/covfee/client/models/Hits.ts b/covfee/client/models/Hits.ts index 608aa372..97b90173 100644 --- a/covfee/client/models/Hits.ts +++ b/covfee/client/models/Hits.ts @@ -1,10 +1,10 @@ +import Constants from "Constants" import React, { useState } from "react" +import { MainSocket, ServerToClientEvents } from "../app_context" import { HitInstanceType } from "../types/hit" +import { NodeType } from "../types/node" import { fetcher, throwBadResponse } from "../utils" -import Constants from "Constants" -import { MainSocket, ServerToClientEvents } from "../app_context" import { JourneyType } from "./Journey" -import { NodeType } from "../types/node" export const useHitInstances = ( data: HitInstanceType[], @@ -85,6 +85,7 @@ export const useHitInstances = ( dt_count: data.dt_count, dt_finish: data.dt_finish, t_elapsed: data.t_elapsed, + progress: data.progress !== null ? +data.progress : null, }) } diff --git a/covfee/client/models/Journey.ts b/covfee/client/models/Journey.ts index dc53b084..1addbd52 100644 --- a/covfee/client/models/Journey.ts +++ b/covfee/client/models/Journey.ts @@ -1,14 +1,14 @@ import * as React from "react" -import { JourneyType as FullJourney } from "../types/journey" -import { JourneyType as ReducedJourney } from "../types/hit" -import { myerror, fetcher, myinfo, throwBadResponse } from "../utils" -import download from "downloadjs" import Constants from "Constants" +import download from "downloadjs" +import { JourneyType as ReducedJourney } from "../types/hit" +import { JourneyType as FullJourney } from "../types/journey" +import { fetcher, myerror, myinfo, throwBadResponse } from "../utils" type JourneyType = FullJourney | ReducedJourney -export type { JourneyType, FullJourney, ReducedJourney } +export type { FullJourney, JourneyType, ReducedJourney } export const useJourneyFns = (journey: T) => { const getApiUrl = () => { @@ -85,3 +85,8 @@ export const submitJourney = (id: string) => { } return fetcher(url, requestOptions).then(throwBadResponse) } + +export const fetchAnnotator = (id: string) => { + const url = Constants.api_url + "/journeys/" + id + "/annotator" + return fetcher(url).then(throwBadResponse) +} diff --git a/covfee/client/models/Node.ts b/covfee/client/models/Node.ts index e970c91f..bf8fa3b2 100644 --- a/covfee/client/models/Node.ts +++ b/covfee/client/models/Node.ts @@ -1,4 +1,6 @@ -import React, { useState, useCallback, useEffect } from "react" +import Constants from "Constants" +import React, { useCallback, useEffect, useState } from "react" +import { MainSocket, ServerToClientEvents } from "../app_context" import { ManualStatus, ManualStatuses, @@ -7,8 +9,6 @@ import { TaskResponseType, } from "../types/node" import { fetcher, throwBadResponse } from "../utils" -import { MainSocket, ServerToClientEvents } from "../app_context" -import Constants from "Constants" export function useNodeFns(node: NodeType) { const fetchResponse = useCallback(() => { @@ -30,6 +30,15 @@ export function useNodeFns(node: NodeType) { [node.url] ) + const submitProgress = useCallback( + (progress: number) => { + const url = node.url + "/progress/" + progress + + return fetcher(url).then(throwBadResponse) + }, + [node.url] + ) + const restart = useCallback(() => { const url = node.url + "/restart" @@ -91,6 +100,7 @@ export function useNodeFns(node: NodeType) { setManualStatus, restart, setReady, + submitProgress, } } @@ -115,6 +125,7 @@ export function useNode(data: NodeType, socket: MainSocket = null) { makeResponse, submitResponse: submitResponseFn, setReady, + submitProgress, } = useNodeFns(node) const numOnlineJourneys: number = React.useMemo(() => { @@ -132,6 +143,13 @@ export function useNode(data: NodeType, socket: MainSocket = null) { })) } + const setProgress = (progress: number) => { + setNode((node) => ({ + ...node, + progress: progress, + })) + } + const fetchResponse = useCallback(() => { // const url = node.url + "/response?" + new URLSearchParams({}) // const p = fetcher(url).then(throwBadResponse) @@ -159,6 +177,7 @@ export function useNode(data: NodeType, socket: MainSocket = null) { dt_count: data.dt_count, dt_finish: data.dt_finish, t_elapsed: data.t_elapsed, + progress: data.progress !== null ? +data.progress : null, })) } @@ -190,10 +209,12 @@ export function useNode(data: NodeType, socket: MainSocket = null) { response, setResponse, setStatus, + setProgress, fetchResponse, submitResponse, makeResponse, setReady, + submitProgress, } } diff --git a/covfee/client/players/videojsfc.tsx b/covfee/client/players/videojsfc.tsx new file mode 100644 index 00000000..6349b8cd --- /dev/null +++ b/covfee/client/players/videojsfc.tsx @@ -0,0 +1,58 @@ +// Videojs functional component as recommended +import React from "react" +import videojs, { VideoJsPlayerOptions } from "video.js" +import "video.js/dist/video-js.css" + +interface Props { + options: VideoJsPlayerOptions + onReady?: (player: videojs.Player) => void +} + +export const VideoJSFC: React.FC = (props) => { + const videoRef = React.useRef(null) + const playerRef = React.useRef(null) + const { options, onReady } = props + + React.useEffect(() => { + // Make sure Video.js player is only initialized once + if (!playerRef.current) { + // The Video.js player needs to be _inside_ the component el for React 18 Strict Mode. + const videoElement = document.createElement("video-js") + + videoElement.classList.add("vjs-big-play-centered") + videoRef.current.appendChild(videoElement) + + const player = (playerRef.current = videojs(videoElement, options, () => { + onReady && onReady(player) + })) + + // You could update an existing player in the `else` block here + // on prop change, for example: + } else { + const player = playerRef.current + + player.autoplay(options.autoplay) + player.src(options.sources) + } + }, [options, videoRef]) + + // Dispose the Video.js player when the functional component unmounts + React.useEffect(() => { + const player = playerRef.current + + return () => { + if (player && !player.isDisposed()) { + player.dispose() + playerRef.current = null + } + } + }, [playerRef]) + + return ( +
    +
    +
    + ) +} + +export default VideoJSFC diff --git a/covfee/client/tasks/base.ts b/covfee/client/tasks/base.ts index 7389ef40..927c27d6 100644 --- a/covfee/client/tasks/base.ts +++ b/covfee/client/tasks/base.ts @@ -52,6 +52,12 @@ export interface BaseTaskProps { * Returns a submit button to be rendered in the task. Alternative to directly calling onSubmit. */ renderSubmitButton: (arg0?: any) => React.ReactNode + + /** + * To be called when wanting to update the task's numeric progress (value between 0 and 100), + * making the information propagate into the admin interface. + */ + onUpdateProgress: (progress: number) => void } export interface CovfeeTaskProps extends BaseTaskProps { diff --git a/covfee/client/tasks/continuous_annotation/action_annotation_flashscreen.tsx b/covfee/client/tasks/continuous_annotation/action_annotation_flashscreen.tsx new file mode 100644 index 00000000..9d46a597 --- /dev/null +++ b/covfee/client/tasks/continuous_annotation/action_annotation_flashscreen.tsx @@ -0,0 +1,27 @@ +import React from "react" +import { REGISTER_ACTION_ANNOTATION_KEY } from "./constants" +import styles from "./continous_annotation.module.css" + +type Props = { + active: boolean + annotation_category: string +} + +const ActionAnnotationFlashscreen: React.FC = (props) => { + return ( +
    +

    + {props.active ? "" : "NOT"} {props.annotation_category} +

    +

    {" (Key: " + REGISTER_ACTION_ANNOTATION_KEY + ")"}

    +
    + ) +} + +export default ActionAnnotationFlashscreen diff --git a/covfee/client/tasks/continuous_annotation/camview_selection.tsx b/covfee/client/tasks/continuous_annotation/camview_selection.tsx new file mode 100644 index 00000000..46fe0e7e --- /dev/null +++ b/covfee/client/tasks/continuous_annotation/camview_selection.tsx @@ -0,0 +1,112 @@ +import React, { useEffect, useRef, useState } from "react" +// You can download this image and place it in the art folder from: +// @covfee.ewi.tudelft.nl:/home/kfunesmora/conflab-media/ +// Do not commit to the repo for confidentiality reasons. +import SvgCamMultiview from "../../art/cam-multiview.svg" +import styles from "./continous_annotation.module.css" + +type Props = { + selectedView: number + setSelectedView: (index: number) => void + layoutIsVertical: boolean + numberOfViews: number +} + +const CamViewSelection: React.FC = (props) => { + const selectedView = props.selectedView + const setSelectedView = props.setSelectedView + const layoutIsVertical = props.layoutIsVertical + const numberOfViews = props.numberOfViews + const [svgSize, setSvgSize] = useState({ + width: 0, + height: 0, + }) + + const containerRef = useRef(null) + const svgResizeObserver = useRef(null) + + const handleClickOnMultiviewImage = (e: MouseEvent) => { + // Based on the known gallery image of participants, we calculate in which + // participant the click is falling in. + if (containerRef.current) { + const camMultiviewImageElement = + containerRef.current.querySelectorAll("svg")[0] + if (camMultiviewImageElement) { + var imageRect = camMultiviewImageElement.getBoundingClientRect() + + if (layoutIsVertical) { + var mouse_pos: number = e.clientY + var rect_start = imageRect.top + var rect_length = imageRect.height + } else { + var mouse_pos: number = e.clientX + var rect_start = imageRect.left + var rect_length = imageRect.width + } + + setSelectedView( + Math.floor((numberOfViews * (mouse_pos - rect_start)) / rect_length) + ) + } + } + } + + // We create a ResizeObserver to keep track of the size of the svg element + useEffect(() => { + const camMultiviewImageElement = containerRef.current.querySelector("svg") + if (camMultiviewImageElement) { + svgResizeObserver.current = new ResizeObserver((entries) => { + for (let entry of entries) { + const { width, height } = entry.contentRect + setSvgSize({ width, height }) + } + }) + svgResizeObserver.current.observe(camMultiviewImageElement) + } + return () => { + if (svgResizeObserver.current) { + svgResizeObserver.current.disconnect() + } + } + }, []) + + /**************Bounding box geometry **************************/ + var boundingBoxRect = { top: 0, left: 0, ...svgSize } + if (layoutIsVertical) { + boundingBoxRect = { + ...boundingBoxRect, + height: svgSize.height / numberOfViews, + top: (selectedView * svgSize.height) / numberOfViews - svgSize.height, + } + } else { + boundingBoxRect = { + ...boundingBoxRect, + width: svgSize.width / numberOfViews, + left: (selectedView * svgSize.width) / numberOfViews - svgSize.width, + } + } + const boundingBoxRectPx = Object.fromEntries( + Object.entries(boundingBoxRect).map(([key, value]) => [key, `${value}px`]) + ) + + return ( +
    + +
    +
    +
    +
    + ) +} + +export default CamViewSelection diff --git a/covfee/client/tasks/continuous_annotation/conflab_participant_selection.tsx b/covfee/client/tasks/continuous_annotation/conflab_participant_selection.tsx new file mode 100644 index 00000000..1bc87c26 --- /dev/null +++ b/covfee/client/tasks/continuous_annotation/conflab_participant_selection.tsx @@ -0,0 +1,123 @@ +import React, { useEffect, useRef } from "react" +// You can download this image and place it in the art folder from: +// @covfee.ewi.tudelft.nl:/home/kfunesmora/conflab-media/ +// Do not commit to the repo for confidentiality reasons. + +// You can download this image and place it in the art folder from: +// @covfee.ewi.tudelft.nl:/home/kfunesmora/conflab-media/ +// Do not commit to the repo for confidentiality reasons. +import ConflabGallery from "../../art/conflab-gallery.svg" +// Hardcoding the size of the viewport of the original svg, which couldn't find +// a way to retrieve by code. +const CONFLAB_SVG_ORIGINAL_SIZE = { width: 1427.578, height: 1496.532 } +const grid_size_x = 6 +const grid_size_y = 8 + +import styles from "./continous_annotation.module.css" + +type ParticipantImageProps = { + participant: string +} + +const SelectedParticipantImage: React.FC = (props) => { + const computeViewBoxOnGalleryToCropSelectedParticipant = () => { + const gallerySVGCellWidth = CONFLAB_SVG_ORIGINAL_SIZE.width / grid_size_x + const gallerySVGCellHeight = CONFLAB_SVG_ORIGINAL_SIZE.height / grid_size_y + + const participant_id = parseInt(props.participant.split("_")[1]) + const cell_id = + participant_id <= 37 ? participant_id - 1 : participant_id - 3 + const cell_x = cell_id % grid_size_x + const cell_y = Math.floor(cell_id / grid_size_x) + const gallerySVGCellX = cell_x * gallerySVGCellWidth + const gallerySVGCellY = cell_y * gallerySVGCellHeight + + return `${gallerySVGCellX} ${gallerySVGCellY} ${gallerySVGCellWidth} ${gallerySVGCellHeight}` + } + + return ( + + + + ) +} + +type ParticipantGalleryProps = { + open: boolean + onCancel: () => void + onParticipantSelected: (participant: string) => void +} + +const ModalParticipantSelectionGallery: React.FC = ( + props +) => { + /*** + This Component implements a modal overlay of participants which is + clickable to select a participant. It is used in the continuous annotation + */ + + // We use a Ref to know to which DOM element to redirect the keyboard focus + // and as to capture key press events. Also, to retrieve the geometry of the + // underlying gallery svg image. + const galleryOverlayRef = useRef(null) + useEffect(() => { + if (props.open && galleryOverlayRef.current) { + galleryOverlayRef.current.focus() + } + }, [props.open]) + + const handleClickOnGalleryImage = (e: MouseEvent) => { + // Based on the known gallery image of participants, we calculate in which + // participant the click is falling in. + if (galleryOverlayRef.current) { + const galleryOverlayImageElement = + galleryOverlayRef.current.querySelectorAll("svg")[0] + if (galleryOverlayImageElement) { + var imageRect = galleryOverlayImageElement.getBoundingClientRect() + var cell_x = Math.floor( + (grid_size_x * (e.clientX - imageRect.left)) / imageRect.width + ) + var cell_y = Math.floor( + (grid_size_y * (e.clientY - imageRect.top)) / imageRect.height + ) + let participant_id: number = cell_y * grid_size_x + cell_x + 1 + if (participant_id >= 38) { + participant_id += 2 + } + props.onParticipantSelected("Participant_" + participant_id) + } + } + } + + if (props.open) { + return ( +
    { + e.preventDefault() + if (e.key === "Escape") { + props.onCancel() + } + }} + tabIndex={-1} + ref={galleryOverlayRef} + > +

    + Click on the participant to select or Press ESC to close +

    + +
    + ) + } else { + return null + } +} + +export { ModalParticipantSelectionGallery, SelectedParticipantImage } diff --git a/covfee/client/tasks/continuous_annotation/constants.tsx b/covfee/client/tasks/continuous_annotation/constants.tsx new file mode 100644 index 00000000..195c3e53 --- /dev/null +++ b/covfee/client/tasks/continuous_annotation/constants.tsx @@ -0,0 +1,7 @@ +// Keyboard keys we are listening to +export const REGISTER_ACTION_ANNOTATION_KEY: string = "S" +export const ABORT_ONGOING_ANNOTATION_KEY: string = "Escape" +export const CHANGE_VIEW_PREV_KEY: string = "O" +export const CHANGE_VIEW_NEXT_KEY: string = "L" +export const TIP_EMOJI: string = "ℹ️" +export const DRINKING_ANNOTATION_CATEGORY: string = "Drinking" diff --git a/covfee/client/tasks/continuous_annotation/continous_annotation.module.css b/covfee/client/tasks/continuous_annotation/continous_annotation.module.css new file mode 100644 index 00000000..cc466737 --- /dev/null +++ b/covfee/client/tasks/continuous_annotation/continous_annotation.module.css @@ -0,0 +1,231 @@ +.action-annotation-task { + display: flex; + justify-content: flex-end; + height: 100%; +} + +.gallery-overlay { + width: 100%; + height: 100%; + position: fixed; + top: 2%; + left: 0%; + background: rgba(49, 49, 49, 0.9); + z-index: 1000; + overflow: auto; +} + +.gallery-overlay h1 { + color: white; + font-size: 2em; + text-align: center; + margin-top: 2%; +} +.gallery-overlay-image { + height: auto; + width: 95%; + position: absolute; + left: 50%; + transform: translateX(-50%); +} +.sidebar { + font-size: 1em; + height: 100%; + display: flex; + flex-direction: column; + background-color: #f0f0f0; /* Example background color */ + padding: 12px; +} +.left-sidebar { + min-width: 300px; /* Set a minimum width */ + width: 22%; + transition: margin 0.3s ease; +} + +.left-sidebar-hidden { + margin-left: -22%; +} + +.sidebar-block { + padding: 10px; + margin-top: 10px; + border-radius: 6px; + background-color: rgb(231, 229, 229); + border: 1px solid blue; +} + +.selected-participant-svg { + margin-top: 4px; + background-color: black; + width: 25vh; +} + +.sidebar-block h1 { + font-size: 18px; + text-align: center; + font-weight: bold; +} +.sidebar-block h2 { + margin-top: 12px; + margin-bottom: 4px; + font-size: 16px; +} + +.right-sidebar { + padding: 12px; + display: flex; + flex-direction: column; + width: 12%; + min-width: 200px; /* Set a minimum width */ + align-self: flex-end; +} + +.sidebarBottom { + margin-top: auto; +} + +.gallery-button { + font-size: 16px; + width: 100%; + margin-top: 4px; + margin-bottom: 4px; +} + +.main-content { + display: flex; + justify-content: flex-end; + flex: 1; + flex-direction: row; +} + +.main-content-video-and-guide { + padding-top: 2px; + flex-grow: 1; + max-width: 85vw; + max-height: "50px"; +} + +.main-content video-js { + height: 100px; +} + +.action-task-dropdown-title { + text-align: left; +} +.action-task-dropwdown { + font-size: 30px; +} + +.action-task-dropwdown-button { + width: 100%; + border: 1px solid blue; + border-radius: 6px; + margin-top: 4px; + margin-bottom: 4px; + display: flex; + align-items: center; +} + +.action-task-dropdown-button-text { + flex: 1; + text-align: left; +} + +.action-task-dropwdown-button-icon { + margin-left: 8px; +} +.action-task-dropwdown-button-icon-placeholder { + width: 16px; +} + +.action-task-dropdown-menu { + max-height: 300px; + overflow: auto; +} + +.action-task-progress-bar { + width: 95%; +} + +.action-task-progress-text { + margin-top: 10px; + text-align: left; +} + +.action-task-progress-finished-message { + margin-top: 10px; + font-weight: "bold"; + font-size: 16px; + text-align: center; +} + +.action-task-progress-code { + font-weight: "bold"; + font-size: 22px; + text-align: center; +} + +.action-task-progress-completion-button { + width: 100%; + font-size: 16px; +} + +.action-annotation-flashscreen { + display: flex; + justify-content: center; /* Horizontally center the content */ + align-items: center; /* Vertically center the content */ + border: 1px solid black; + border-radius: 6px; + height: 10vh; + width: 100%; + flex-direction: column; +} + +.instructions-box-overlay { + display: flex; + background: rgba(49, 49, 49, 0.8); + justify-content: center; + flex-direction: column; + width: 40%; + color: white; + position: relative; + left: 2%; + top: -22vh; + height: 20vh; + padding-left: 3%; + padding-right: 3%; +} + +.instruction-text-during-annotation { + font-size: 1rem; +} + +.action-annotation-flashscreen h1 { + font-size: 20px; + margin: 0px; +} + +.action-annotation-flashscreen-active-key-pressed { + background-color: greenyellow; +} + +.action-annotation-flashscreen-active-key-not-pressed { + background-color: green; +} + +.camview-selection-svg-container { + width: 100%; + max-width: 100%; +} + +.camview-selection-image { + width: 100%; + max-width: 100%; + max-height: 80vh; + height: auto; /* this is critical so that getBoundingClientRect is accurate */ +} + +.camview-selection-bounding-box { + background-color: rgba(255, 217, 0, 0.2); + border: 3px solid rgb(255, 230, 0); +} diff --git a/covfee/client/tasks/continuous_annotation/index.tsx b/covfee/client/tasks/continuous_annotation/index.tsx index ca3c1b13..395296ec 100644 --- a/covfee/client/tasks/continuous_annotation/index.tsx +++ b/covfee/client/tasks/continuous_annotation/index.tsx @@ -1,58 +1,872 @@ import Constants from "Constants" -import * as React from "react" -import { TaskExport } from "types/node" -import { AllPropsRequired } from "types/utils" + +import { CloseOutlined, InfoCircleFilled } from "@ant-design/icons" +import { Button, Checkbox, Modal, message, notification } from "antd" +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { VideoJsPlayer } from "video.js" import { nodeContext } from "../../journey/node_context" +import VideoJSFC from "../../players/videojsfc" +import { TaskExport } from "../../types/node" +import { AllPropsRequired } from "../../types/utils" import { fetcher } from "../../utils" import { CovfeeTaskProps } from "../base" +import ActionAnnotationFlashscreen from "./action_annotation_flashscreen" +import CamViewSelection from "./camview_selection" +import { ModalParticipantSelectionGallery } from "./conflab_participant_selection" + +import { + ABORT_ONGOING_ANNOTATION_KEY, + CHANGE_VIEW_NEXT_KEY, + CHANGE_VIEW_PREV_KEY, + REGISTER_ACTION_ANNOTATION_KEY, + TIP_EMOJI, +} from "./constants" +import styles from "./continous_annotation.module.css" +import { + AnnotationOption, + InstructionsSidebar, + ParticipantOption, +} from "./instructions_sidebar" import { slice } from "./slice" -import type { ContinuousAnnotationTaskSpec } from "./spec" +import type { AnnotationDataSpec, ContinuousAnnotationTaskSpec } from "./spec" +import TaskProgress, { TaskAlreadyCompleted } from "./task_progress" -// TODO: the types of the props are incorrect interface Props extends CovfeeTaskProps {} -export const ContinuousAnnotationTask: React.FC = (props) => { - const args: AllPropsRequired = { - ...props, - spec: { - userCanAdd: true, - ...props.spec, - }, - } +const UNINITIALIZED_ACTION_ANNOTATION_START_TIME: null = null +const CAMVIEW_SELECTION_LAYOUT_IS_VERTICAL: boolean = true +const CAMVIEW_SELECTION_NUMBER_OF_VIEWS: number = 5 +const VIDEO_PLAYBACK_ASSUMED_FRAMERATE: number = 60.0 + +/** + * We specify the data structure for the annotation data received from the server + */ +type AnnotationData = AnnotationDataSpec & { + id: number + data_json: number[] +} + +type ActionAnnotationDataArray = { + buffer: number[] + needs_upload: boolean +} + +const ContinuousAnnotationTask: React.FC = (props) => { + const args: AllPropsRequired = React.useMemo(() => { + return { + ...props, + spec: { + userCanAdd: true, + ...props.spec, + }, + } + }, [props]) const { node } = React.useContext(nodeContext) - const [annotations, setAnnotations] = React.useState() - const fetchTasks = React.useCallback(async () => { + //*************************************************************// + //------------------ States definition -------------------- // + //*************************************************************// + const [submitted, setSubmitted] = useState(args.response.submitted) + + React.useEffect(() => { + setSubmitted(args.response.submitted) + }, [args.response.submitted]) + + const [annotationsDataMirror, setAnnotationsDataMirror] = + React.useState() + // Note: we explicitly IGNORE the Redux setSelectedAnnotationIndex action + // set in slice.ts, which appears to stop working as soon as the node + // has a finished status when using the covfee custom dispatch function. + const [selectedAnnotationIndex, setSelectedAnnotationIndex] = useState< + number | null + >(null) + const [showingGallery, setShowingGallery] = useState(false) + const [showAnnotationTipsOnStart, setShowAnnotationTipsOnStart] = + useState(true) + const [showingAnnotationTips, setShowingAnnotationTips] = useState(false) + + const [isAnnotating, setIsAnnotating] = useState(false) + const [actionAnnotationStartTime, setActionAnnotationStartTime] = useState< + number | null + >(UNINITIALIZED_ACTION_ANNOTATION_START_TIME) + const [selectedCamViewIndex, setSelectedCamViewIndex] = useState(0) + const [activeAnnotationDataArray, setActiveAnnotationDataArray] = + React.useState({ + buffer: [], + needs_upload: false, + }) + + const [ + showTaskVariantPopupBulletPoints, + setShowTaskVariantPopupBulletPoints, + ] = useState( + props.spec.taskVariantPopupBulletPoints && + props.spec.taskVariantPopupBulletPoints.length > 0 + ) + + const dataJsonContainsAValidAnnotation = ( + data_json: null | number[] + ): boolean => { + return data_json !== null && data_json.length > 0 + } + + const validAnnotationsDataAndSelection: boolean = + annotationsDataMirror !== undefined && + selectedAnnotationIndex !== null && + selectedAnnotationIndex >= 0 && + selectedAnnotationIndex < annotationsDataMirror.length + + var isEntireTaskCompleted = false + var numberOfAnnotationsCompleted = 0 + var numberOfAnnotations = 0 + var taskCompletionPercentage = 0 + if (annotationsDataMirror !== undefined) { + isEntireTaskCompleted = annotationsDataMirror.every( + (annotationData: AnnotationData) => + dataJsonContainsAValidAnnotation(annotationData.data_json) + ) + numberOfAnnotations = annotationsDataMirror.length + numberOfAnnotationsCompleted = annotationsDataMirror.filter( + (annotationData: AnnotationData) => + dataJsonContainsAValidAnnotation(annotationData.data_json) + ).length + taskCompletionPercentage = + (100 * numberOfAnnotationsCompleted) / numberOfAnnotations + } + + const selectFirstAvailableAnnotationIndexBasedOnParticipantName = ( + participant: string + ) => { + if (annotationsDataMirror === undefined) { + return + } + // Finds the first annotation in the spec that has the participant + const first_annotation_index_for_participant = + annotationsDataMirror.findIndex((annotation) => { + return annotation.participant === participant + }) + if (first_annotation_index_for_participant !== -1) { + setSelectedAnnotationIndex(first_annotation_index_for_participant) + } + } + + //*************************************************************// + //------------------ Server communication -------------------- // + //*************************************************************// + const fetchAnnotationsServerData = React.useCallback(async () => { const url = Constants.base_url + node.customApiBase + `/tasks/${node.id}/annotations/all` const res = await fetcher(url) - const annotations = await res.json() - setAnnotations(annotations) + setAnnotationsDataMirror(await res.json()) }, [node.customApiBase, node.id]) + // We keep the annotationDataMirror up to date with the server data. + // Note that given useEffect, this leads to a call immediately when + // this component is instantiated. + // Note that fetchAnnotationsServerData is a useCallback, so it is memoized + // and that function reference is what is being monitored by the useEffect + React.useEffect(() => { + fetchAnnotationsServerData() + }, [fetchAnnotationsServerData]) + + const postActiveAnnotationDataArrayToServer = async () => { + if (!validAnnotationsDataAndSelection) { + return + } + if (!activeAnnotationDataArray.needs_upload) { + return + } + console.log("Posting new data to server", activeAnnotationDataArray) + const active_annotation_data_to_post = { + ...annotationsDataMirror[selectedAnnotationIndex], + data_json: activeAnnotationDataArray.buffer, + } + + try { + const url = + Constants.base_url + + node.customApiBase + + "/annotations/" + + active_annotation_data_to_post.id + const res = await fetcher(url, { + method: "UPDATE", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(active_annotation_data_to_post), + }) + if (res.ok) { + fetchAnnotationsServerData() + } else { + console.error("Error posting new data:", res.status) + } + } catch (error) { + console.error("Error posting new data:", error) + } + setActiveAnnotationDataArray((prevActiveAnnotationDataArray) => { + return { ...prevActiveAnnotationDataArray, needs_upload: false } + }) + } + + // When the activeAnnotationDataArray eventually is observed to have the needs_upload + // flag set to True, we post the new data to the server + useEffect(() => { + if (activeAnnotationDataArray.needs_upload) { + postActiveAnnotationDataArrayToServer() + notification.open({ + message: "Annotation Saved", + description: "Please continue with the next one.", + icon: , + }) + } + }, [activeAnnotationDataArray]) + + // update selectedAnnotationIndex to a reasonable state, for the case in which + // annotationsDataMirror is undefined, or empty, or when is valid but selectedAnnotationIndex + // was still uninitialized. + useEffect(() => { + if (annotationsDataMirror === undefined) { + setSelectedAnnotationIndex(null) + } else if (annotationsDataMirror.length === 0) { + setSelectedAnnotationIndex(null) + } else if (selectedAnnotationIndex === null) { + setSelectedAnnotationIndex(0) + } + }, [selectedAnnotationIndex, annotationsDataMirror]) + + useEffect(() => { + props.onUpdateProgress(taskCompletionPercentage) + }, [annotationsDataMirror]) + + //*************************************************************// + //----------- Video playback fuctionality -------------------- // + //*************************************************************// + + // We get a reference to the VideoJS player and assign event + // listeners to it. + const videoPlayerRef = useRef(null) + const [, setIsVideoPlayerReady] = useState(false) + // We keep track of the loadstart event to make React respond to it + // based on useEffect calls, because the execution of the loadstart + // callback is different between chrome and firefox. In Chrome is + // executed after the useEffect calls (when all state is fully updated), + // but Firefox executes it before the useEffect calls leading to bugs. + const [videoLoadStartEvent, setVideoLoadStartEvent] = useState(null) + + const handleVideoPlayerReady = (player: VideoJsPlayer) => { + videoPlayerRef.current = player + // We associate a dummy state to trigger a render when the video player is ready + // and thus execution of the code in the useEffect hook connecting event listeners + // below. + setIsVideoPlayerReady(true) + forceVideoAudioRequirement() + } + + useEffect(() => { + if (videoPlayerRef.current) { + const handleVideoLoadStart = (event: any) => { + // enqueues a useEffect call + setVideoLoadStartEvent(event) + forceVideoAudioRequirement() + } + const handleVolumeChange = () => { + forceVideoAudioRequirement() + } + videoPlayerRef.current.on("ended", handleVideoEnd) + videoPlayerRef.current.on("loadstart", handleVideoLoadStart) + videoPlayerRef.current.on("volumechange", handleVolumeChange) + return () => { + videoPlayerRef.current.off("ended", handleVideoEnd) + videoPlayerRef.current.off("loadstart", handleVideoLoadStart) + videoPlayerRef.current.off("volumechange", handleVolumeChange) + } + } + }) // No dependencies so all functions are updated with all latest state + + useEffect(() => { + if (videoPlayerRef.current) { + if (isAnnotating) { + videoPlayerRef.current.controlBar.hide() + } else { + videoPlayerRef.current.controlBar.show() + } + } + }, [isAnnotating]) + + const handleVideoEnd = () => { + handleAnnotationsOnVideoEndEvent() + setIsAnnotating(false) + } + + // We define the options, more specifically the sources, for the video player + // Note: it's important to memoize the options object to avoid the videojs player + // to be reset on every render, except when the selectedCamViewIndex + // does change, which trigger a change in the sources, i.e., the video url + // being used. + const videoPlayerOptions = useMemo(() => { + let participant = "" + let source = { ...props.spec.media[selectedCamViewIndex] } + // we let a plausible participant number to be replaced in the video url + // just so we don't get errors when things are loading. + let participant_substr_to_set_to_src_url: string = "1" + if (validAnnotationsDataAndSelection) { + // Note: consider optimizing such that if replaceable strings are not + // found, then it fallback into not having selectedAnnotationIndex + // as a dependency, and thus avoiding the videos from reload. + participant = annotationsDataMirror[selectedAnnotationIndex].participant + participant_substr_to_set_to_src_url = participant.replace( + "Participant_", + "" + ) + } + source.src = source.src.replace( + "{participant}", + participant_substr_to_set_to_src_url + ) + return { + autoplay: false, + controls: true, + responsive: true, + fluid: true, + sources: [source], + } + }, [props.spec, selectedCamViewIndex, selectedAnnotationIndex]) + + // ...and add logic that ensures that video playback status is kept in sync under + // the selectedCamViewIndex changes. First, we keep track of the playback status. + const [ + playbackStatusOnCamViewChangeEvent, + setPlaybackStatusOnCamViewChangeEvent, + ] = useState({ + paused: true, + currentTime: 0.0, + }) + useEffect(() => { + // Note: this is triggered when the selectedCamViewIndex changes + if (videoPlayerRef.current) { + setPlaybackStatusOnCamViewChangeEvent({ + paused: videoPlayerRef.current.paused(), + currentTime: videoPlayerRef.current.currentTime(), + }) + } + }, [selectedCamViewIndex]) + + // ...and then we ensure that the video player is updated with the playback status + // when the new video source becomes active. + useEffect(() => { + if (videoPlayerRef.current) { + videoPlayerRef.current.currentTime( + playbackStatusOnCamViewChangeEvent.currentTime + ) + if ( + playbackStatusOnCamViewChangeEvent.paused != + videoPlayerRef.current.paused() + ) { + if (playbackStatusOnCamViewChangeEvent.paused) { + videoPlayerRef.current.pause() + } else { + videoPlayerRef.current.play().catch((error) => { + console.log("Error playing video: ", error) + }) + } + } + } + }, [videoLoadStartEvent]) + + const forceVideoAudioRequirement = () => { + if (!videoPlayerRef.current || props.spec.audioRequirement === null) { + return + } + + if (props.spec.audioRequirement) { + if ( + videoPlayerRef.current.volume() !== 1 || + videoPlayerRef.current.muted() + ) { + videoPlayerRef.current.volume(1) + videoPlayerRef.current.muted(false) + } + } else { + if (videoPlayerRef.current.volume() !== 0) { + videoPlayerRef.current.volume(0) + videoPlayerRef.current.muted(true) + } + } + } + + const numberOfVideoFrames = () => { + if (videoPlayerRef.current) { + return Math.round( + videoPlayerRef.current.duration() * getCurrentVideoFramerate() + ) + } else { + return 0 + } + } + + const startVideoPlayback = (startTimeInSeconds: number) => { + if (videoPlayerRef.current) { + videoPlayerRef.current.currentTime(startTimeInSeconds) + videoPlayerRef.current.play() + } + } + + const pauseVideoPlayback = () => { + if (videoPlayerRef.current) { + videoPlayerRef.current.pause() + } + } + + const getCurrentVideoTime = () => { + if (videoPlayerRef.current) { + return videoPlayerRef.current.currentTime() + } else { + return 0.0 + } + } + + const getCurrentVideoFramerate = () => { + if (videoPlayerRef.current) { + return ( + videoPlayerRef.current.playbackRate() * VIDEO_PLAYBACK_ASSUMED_FRAMERATE + ) + } else { + return VIDEO_PLAYBACK_ASSUMED_FRAMERATE + } + } + + //********************************************************************// + //------------- Participant and annnotation menus-------------------- // + //********************************************************************// + + const participantCompleted = (participant: string) => { + if (annotationsDataMirror === undefined) { + return false + } + const participant_annotations = annotationsDataMirror.filter( + (annotation) => annotation.participant === participant + ) + return participant_annotations.every((annotation) => + dataJsonContainsAValidAnnotation(annotation.data_json) + ) + } + + const annotationCompleted = (index: number) => { + if (annotationsDataMirror === undefined) { + return false + } + return dataJsonContainsAValidAnnotation( + annotationsDataMirror[index].data_json + ) + } + + //********************************************************************// + //----------------- Action annotation logic ------------------------- // + //********************************************************************// + const annotateStartEventOfActionAnnotation = () => { + // If there isn't an ongoing annotation already... (note that + // holding the key leads to multiple event calls) + if ( + actionAnnotationStartTime === UNINITIALIZED_ACTION_ANNOTATION_START_TIME + ) { + const currentVideoTime = getCurrentVideoTime() + setActionAnnotationStartTime(currentVideoTime) + } + } + + const annotateEndEventOfActionAnnotation = () => { + // If we are annotating, we set the activeAnnotationDataArray to 1 for the + // corresponding time range + if ( + actionAnnotationStartTime !== UNINITIALIZED_ACTION_ANNOTATION_START_TIME + ) { + const currentVideoTime = getCurrentVideoTime() + console.log("Annotation ended at", currentVideoTime) + const currentVideoFramerate = getCurrentVideoFramerate() + // Based on the registered annotation start time and framerate, we get + // the corresponding frame time. + const startFrameIndex = Math.round( + actionAnnotationStartTime * currentVideoFramerate + ) + // Similarly, we retrieve the current frame number + const endFrameIndex = Math.min( + Math.round(currentVideoTime * currentVideoFramerate), + activeAnnotationDataArray.buffer.length + ) + // We then update the data array for the elements in between start and end time + if ( + startFrameIndex < activeAnnotationDataArray.buffer.length && + endFrameIndex > startFrameIndex + ) { + setActiveAnnotationDataArray((prevActiveAnnotationDataArray) => { + const newActiveAnnotationDataArray = + prevActiveAnnotationDataArray.buffer.slice() + newActiveAnnotationDataArray.fill(1, startFrameIndex, endFrameIndex) + return { + buffer: newActiveAnnotationDataArray, + needs_upload: prevActiveAnnotationDataArray.needs_upload, + } + }) + } + // We clear out annotation start time + setActionAnnotationStartTime(UNINITIALIZED_ACTION_ANNOTATION_START_TIME) + } + } + + const handleAnnotationStartOrStopButtonClick = () => { + if (!isAnnotating) { + if (!validAnnotationsDataAndSelection) { + return + } + let new_buffer: number[] = Array.from( + { length: numberOfVideoFrames() }, + () => 0 + ) + + if (new_buffer.length === 0) { + // This is the result of failure to load the video. + message.error( + "There was an error loading the video. Please refresh the page and try again." + ) + return + } + + // Scroll to the top of the page, + // on 1080p resolution it doesn't do anything, as the page is as big as the screen, + // on 720p or lower, the page is bigger than the screen, so it scrolls to the top. + const parentElement = document.getElementById("JourneyContentContainer") + parentElement.scrollTo({ top: 0, left: 0, behavior: "instant" }) + + startVideoPlayback(0.0) + + setActiveAnnotationDataArray({ + buffer: new_buffer, + needs_upload: false, + }) + } else { + pauseVideoPlayback() + } + setIsAnnotating(!isAnnotating) + } + + const handleAnnotationsOnVideoEndEvent = () => { + if (isAnnotating) { + // TODO: Triple check that isAnnotating is not being set to false before this + // function is called. + annotateEndEventOfActionAnnotation() + // Setting the need_upload will trigger the postActiveAnnotationDataArrayToServer + // function given the useEffect hook with activeAnnotationDataArray as dependency + setActiveAnnotationDataArray((prevActiveAnnotationDataArray) => { + return { ...prevActiveAnnotationDataArray, needs_upload: true } + }) + } else { + console.log("Annotating is false") + } + } + + const handleParticipantNotAppearingInVideos = () => { + // FIXME: Extend this logic to push all annotations categories + // associated to the selected participant as empty, not just the current one. + setActiveAnnotationDataArray({ + buffer: Array.from({ length: 1 }, () => 0), + needs_upload: true, + }) + } + + //********************************************************************// + //---------- Keyboard management for Action annotation--------------- // + //********************************************************************// + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + if (event.key.toUpperCase() === REGISTER_ACTION_ANNOTATION_KEY) { + annotateStartEventOfActionAnnotation() + } + if (isAnnotating && event.key === ABORT_ONGOING_ANNOTATION_KEY) { + handleAnnotationStartOrStopButtonClick() + } + }, + [actionAnnotationStartTime, getCurrentVideoTime, isAnnotating] + ) + + const handleKeyUp = useCallback( + (event: KeyboardEvent) => { + if (event.key.toUpperCase() === REGISTER_ACTION_ANNOTATION_KEY) { + annotateEndEventOfActionAnnotation() + } + // We use the arrow keys to change the selected camera view + // FIXME: this leads to scrolling the page. However, we expect that the final + // implementation will have a layout which won't generate a scrollbar while + // annotating. + if (event.key.toUpperCase() == CHANGE_VIEW_PREV_KEY) { + setSelectedCamViewIndex((prevSelectedViewIndex) => { + return Math.max(0, prevSelectedViewIndex - 1) + }) + } + if (event.key.toUpperCase() == CHANGE_VIEW_NEXT_KEY) { + setSelectedCamViewIndex((prevSelectedViewIndex) => { + return Math.min(4, prevSelectedViewIndex + 1) + }) + } + }, + [isAnnotating, annotateEndEventOfActionAnnotation] + ) + + // Register the listeners for keyboard events React.useEffect(() => { - fetchTasks() - }, [fetchTasks]) + window.addEventListener("keydown", handleKeyDown) + window.addEventListener("keyup", handleKeyUp) + return () => { + window.removeEventListener("keydown", handleKeyDown) + window.removeEventListener("keyup", handleKeyUp) + } + }, [handleKeyDown, handleKeyUp]) + + //********************************************************************// + // Participant and annotations for participant options + + let participant_options: ParticipantOption[] = [] + let annotation_options: AnnotationOption[] = [] + if (validAnnotationsDataAndSelection) { + participant_options = annotationsDataMirror + // We filter unique ocurrences of participants + .filter( + (annotation, index, self) => + index === + self + .map((self_annotation) => self_annotation.participant) + .indexOf(annotation.participant) + ) + // Those unique occurrences are then mapped into menu items + .map(({ participant }) => ({ + name: participant, + completed: participantCompleted(participant), + })) + + annotation_options = annotationsDataMirror + // We extend the annotationDataMirror with the index for element + .map((annotation, annotation_index) => ({ + annotation, + annotation_index, + })) + // We pick the annotations for the currently selected participant (forwarding the original index) + .filter( + ({ annotation, annotation_index }) => + annotation.participant === + annotationsDataMirror[selectedAnnotationIndex].participant + ) + // Now we transform those into entries for the menu, using the original index as unique identifier (key) + .map(({ annotation, annotation_index }) => ({ + category: annotation.category, + index: annotation_index, + completed: annotationCompleted(annotation_index), + })) + } + + React.useEffect(() => { + setShowingAnnotationTips(isAnnotating && showAnnotationTipsOnStart) + }, [isAnnotating]) + + // ********************************************************************// + //----------------------- JSX rendering logic ------------------------// + //********************************************************************// + if (!validAnnotationsDataAndSelection) { + // We assume that a first selection is done automatically and that the user + // can't get the component into a state in which there is no valid selection. + return

    Loading...

    + } + + // Redirection URL once the annotator has completed the entire annotation task. + const redirectUrl = + "https://app.prolific.com/submissions/complete?cc=" + + props.spec.prolificCompletionCode + + if (args.response.submitted && isEntireTaskCompleted) { + return + } return ( - <> -

    Node data:

    -

    {JSON.stringify(node)}

    +
    + {showTaskVariantPopupBulletPoints && ( + { + setShowTaskVariantPopupBulletPoints(false) + }} + > + Ok + , + ]} + > + {props.spec.taskVariantPopupBulletPoints && ( +
      + {props.spec.taskVariantPopupBulletPoints.map( + (instruction: string, index: number) => ( +
    • {instruction}
    • + ) + )} +
    + )} +
    + )} + { + setShowingGallery(false) + }} + onParticipantSelected={(participant: string) => { + selectFirstAvailableAnnotationIndexBasedOnParticipantName(participant) + setShowingGallery(false) + }} + /> +
    +
    + { + props.onSubmit({}) + setSubmitted(true) + }} + submitButtonDisabled={submitted} + /> + + { + selectFirstAvailableAnnotationIndexBasedOnParticipantName( + participant + ) + }} + onAnnotationSelected={(index: number) => { + setSelectedAnnotationIndex(index) + }} + onStartStopAnnotationClick={handleAnnotationStartOrStopButtonClick} + onOpenParticipantSelectionClick={() => { + setShowingGallery(true) + }} + onWatchTutorialVideoClick={() => { + setShowTaskVariantPopupBulletPoints(true) + }} + /> +
    +
    {/* <--- Filler div */} +
    +
    + + {showingAnnotationTips && ( +
    + {/* These are the tips we want to make sure the annotator sees while the annotation process is ongoing */} +
    + )} +
    + {/* <> +

    Node data:

    +

    {JSON.stringify(node)}

    -

    Annotations in the database:

    -

    {JSON.stringify(annotations)}

    - +

    + URL of the task API is {Constants.api_url + node.customApiBase} +

    + +

    Annotations in the database:

    +

    {JSON.stringify(annotationsDataMirror)}

    + */} +
    +
    +
    +

    + Press {CHANGE_VIEW_PREV_KEY.toUpperCase()} or{" "} + {CHANGE_VIEW_NEXT_KEY.toUpperCase()} to select a camera view +

    + +
    +
    + +
    +
    +
    + ) } -export type { ContinuousAnnotationTaskSpec } - export default { taskComponent: ContinuousAnnotationTask, taskSlice: slice, + useSharedState: false, } as TaskExport diff --git a/covfee/client/tasks/continuous_annotation/instructions_sidebar.tsx b/covfee/client/tasks/continuous_annotation/instructions_sidebar.tsx new file mode 100644 index 00000000..7c535788 --- /dev/null +++ b/covfee/client/tasks/continuous_annotation/instructions_sidebar.tsx @@ -0,0 +1,339 @@ +import type { MenuProps } from "antd" +import { Button, Dropdown, MenuInfo, Modal, Space } from "antd" +import React, { useEffect, useState } from "react" +import { SelectedParticipantImage } from "./conflab_participant_selection" + +import { + BorderOutlined, + CheckSquareTwoTone, + DownOutlined, + ExclamationCircleOutlined, +} from "@ant-design/icons" + +import { + CHANGE_VIEW_NEXT_KEY, + CHANGE_VIEW_PREV_KEY, + DRINKING_ANNOTATION_CATEGORY, + REGISTER_ACTION_ANNOTATION_KEY, + TIP_EMOJI, +} from "./constants" + +import styles from "./continous_annotation.module.css" + +type ParticipantOption = { + name: string + completed: boolean +} + +type AnnotationOption = { + category: string + index?: number + completed: boolean +} + +type Props = { + selected_participant: ParticipantOption + selected_annotation: AnnotationOption + participant_options: ParticipantOption[] + annotation_options: AnnotationOption[] + video_tutorial_url?: string + + onCantFindParticipant: () => void + onParticipantSelected: (participant: string) => void + onAnnotationSelected: (annotation_index: number) => void + onStartStopAnnotationClick: () => void + onOpenParticipantSelectionClick: () => void + onWatchTutorialVideoClick: () => void +} + +const InstructionsSidebar: React.FC = (props) => { + // Modal dialogs control + const [checkingWhetherToRedoAnnotation, setCheckingWhetherToRedoAnnotation] = + useState(false) + const [isMarkParticipantModalOpen, setIsMarkParticipantModalOpen] = + useState(false) + + // We prepare the participants options in the menu + const participants_menu_items: MenuProps["items"] = [ + { + key: "1", + type: "group", + label: "Select participant", + children: props.participant_options.map((option: ParticipantOption) => ({ + key: option.name, + label: option.name, + icon: option.completed ? : , + onClick: (item: MenuInfo) => { + props.onParticipantSelected(item.key) + }, + })), + }, + ] + + // We prepare the annotations options + const annotations_menu_items: MenuProps["items"] = [ + { + key: "1", + type: "group", + label: "Select annotation", + children: props.annotation_options.map((option: AnnotationOption) => ({ + key: option.index, + label: option.category, + icon: option.completed ? : , + onClick: (item: MenuInfo) => { + props.onAnnotationSelected(item.key) + }, + })), + }, + ] + + const handleStartRedoAnnotationClick = () => { + if (props.selected_annotation.completed) { + setCheckingWhetherToRedoAnnotation(true) + } else { + props.onStartStopAnnotationClick() + } + } + + useEffect(() => { + if (checkingWhetherToRedoAnnotation) { + Modal.confirm({ + title: "Are you sure you want to redo this annotation?", + okText: "Yes", + onOk: () => { + setCheckingWhetherToRedoAnnotation(false) + props.onStartStopAnnotationClick() + }, + onCancel: () => { + setCheckingWhetherToRedoAnnotation(false) + }, + }) + } + }, [checkingWhetherToRedoAnnotation]) + + const multiple_annotations_for_selected_participant = + props.annotation_options.length > 1 + + return ( + <> + { + setIsMarkParticipantModalOpen(false) + }} + footer={[ + , + , + ]} + > +
      +
    • + I checked all camera views and I can't find this participant{" "} + {props.selected_annotation.category === DRINKING_ANNOTATION_CATEGORY + ? "or I did find the participant but I confirmed they don't have a beverage" + : ""} + . +
    • +
    • + I am certain this participant does not enter any view + {props.selected_annotation.category === DRINKING_ANNOTATION_CATEGORY + ? " or does not get a beverage" + : ""}{" "} + in the middle of playback. +
    • +
    +
    +
    +

    Instructions

    + {props.video_tutorial_url && ( + + )} +

    + Step 1: + {"Select the camera view where the person below is "} + best visible + {" using the "} {CHANGE_VIEW_PREV_KEY} + {" or "} + {CHANGE_VIEW_NEXT_KEY} + {" keys. You can play the video to help you find the person. "} +

    + + +

    + Step 2: + If you have found the participant + {props.selected_annotation.category === + DRINKING_ANNOTATION_CATEGORY ? ( + , confirmed they have a beverage + ) : ( + "" + )}{" "} + and selected the best camera view, proceed to Step 3. If you + can't find the participant at all + {props.selected_annotation.category === + DRINKING_ANNOTATION_CATEGORY ? ( + or doesn't have a beverage + ) : ( + "" + )} + , click the button below. A pop-up will appear asking you to confirm. + If you confirm this, proceed to Step + {multiple_annotations_for_selected_participant ? " 5" : " 4"}. +

    + + {multiple_annotations_for_selected_participant && ( + <> +

    + Step 3: Select an action that hasn't been + annotated and continue to Step 4. If all have been annotated, skip + to Step 5. +

    + + + + + )} +

    + + Step {multiple_annotations_for_selected_participant ? "4" : "3"}:{" "} + + Start the annotation process. Get ready! + The video will start playing from the beginning. During playback, + press and hold the{" "} + {`${REGISTER_ACTION_ANNOTATION_KEY}`} key to indicate + the person is {props.selected_annotation.category}. + Release while they are not ({TIP_EMOJI} + + You can press {`${REGISTER_ACTION_ANNOTATION_KEY}`}{" "} + right now, before the annotation process starts, to practice! + + ). When finished, go to Step{" "} + {multiple_annotations_for_selected_participant ? "3" : "4"}. +

    + +

    + + Step {multiple_annotations_for_selected_participant ? "5" : "4"}:{" "} + {" "} + Select a participant that hasn't been annotated (without a checkmark{" "} + + ). Then, go to Step 1 +

    + + + + +
    + + ) +} + +export { AnnotationOption, InstructionsSidebar, ParticipantOption } diff --git a/covfee/client/tasks/continuous_annotation/slice.ts b/covfee/client/tasks/continuous_annotation/slice.ts index db321671..78746d32 100644 --- a/covfee/client/tasks/continuous_annotation/slice.ts +++ b/covfee/client/tasks/continuous_annotation/slice.ts @@ -1,9 +1,30 @@ -import { createSlice } from "../utils/state" +import { createSlice } from "@reduxjs/toolkit" -export interface State {} +export interface State { + // Note: delete selectedAnnotationIndex as state as soon as it is confirmed + // that this not influence the "response" object for an active + // covfee instance. This is because we don't really need it, and using + // it might be a source of bugs due to the use of the custom dispatch. + selectedAnnotationIndex?: number | null + mediaPaused: boolean +} -export const initialState: State = {} +export const initialState: State = { + selectedAnnotationIndex: null, + mediaPaused: null, +} -export const slice = createSlice(initialState, {}) +export const slice = createSlice({ + name: "form", + initialState, + reducers: { + setMediaPaused: (state, action) => { + state.mediaPaused = action.payload + }, + setSelectedAnnotationIndex: (state, action) => { + state.selectedAnnotationIndex = action.payload + }, + }, +}) export const { actions, reducer } = slice diff --git a/covfee/client/tasks/continuous_annotation/spec.ts b/covfee/client/tasks/continuous_annotation/spec.ts index 443b2c54..178da8e3 100644 --- a/covfee/client/tasks/continuous_annotation/spec.ts +++ b/covfee/client/tasks/continuous_annotation/spec.ts @@ -1,20 +1,31 @@ -import { BaseTaskSpec } from "@covfee-shared/spec/task"; - +import { BaseTaskSpec } from "@covfee-shared/spec/task" /** * @TJS-additionalProperties false */ +export interface AnnotationDataSpec { + category: string + participant: string + interface: "RankTrace" | "GTrace" | "Binary" +} +export interface MediaSpec { + type: "video/mp4" + src: string +} + export interface ContinuousAnnotationTaskSpec extends BaseTaskSpec { /** * @default "ContinuousAnnotationTask" */ - type: "ContinuousAnnotationTask"; - media: { - type: "video"; - url: string; - }; - annotations: { - name: string; - interface: "RankTrace" | "GTrace" | "Binary"; - }[]; - userCanAdd: boolean; + type: "ContinuousAnnotationTask" + media: MediaSpec[] + annotations: AnnotationDataSpec[] + prolificCompletionCode?: string + taskVariantPopupBulletPoints?: string[] + userCanAdd: boolean + /** + * When specified: True, means audio on is mandatory, False means audio off (muted) is mandatory. + * @default null + */ + audioRequirement?: boolean + videoTutorialUrl?: string } diff --git a/covfee/client/tasks/continuous_annotation/task_progress.tsx b/covfee/client/tasks/continuous_annotation/task_progress.tsx new file mode 100644 index 00000000..6eb6bcb4 --- /dev/null +++ b/covfee/client/tasks/continuous_annotation/task_progress.tsx @@ -0,0 +1,103 @@ +import { Button, Modal, Progress } from "antd" +import React, { useEffect, useState } from "react" + +import styles from "./continous_annotation.module.css" + +type Props = { + finished: boolean + percent: number + completionCode?: string + redirectUrl?: string + submitButtonDisabled: boolean + onSubmit: () => void +} + +const TaskProgress: React.FC = (props) => { + const [checkWhetherToSubmitTask, setCheckWhetherToSubmitTask] = + useState(false) + + useEffect(() => { + if (checkWhetherToSubmitTask) { + Modal.confirm({ + title: "Are you sure you want to submit?", + content: "You won't be able to change your response after you submit.", + okText: "Submit", + onOk: () => { + setCheckWhetherToSubmitTask(false) + props.onSubmit() + if (props.redirectUrl) { + window.location.href = props.redirectUrl + } + }, + onCancel: () => { + setCheckWhetherToSubmitTask(false) + }, + }) + } + }, [checkWhetherToSubmitTask]) + + return ( +
    +

    Task progress

    + percent.toFixed(1) + "%"} + /> + {props.finished && ( + <> +

    + All annotations are completed! 🎉 +

    +

    + To finish, click on the Submit button below. +

    + + + )} +
    + ) +} + +type TaskAlreadyCompletedProps = { + redirectUrl?: string +} + +export const TaskAlreadyCompleted: React.FC = ( + props +) => { + return ( + <> +
    +

    Your response was already submitted. Thank you!

    + {props.redirectUrl && ( + + )} +
    + + ) +} + +export default TaskProgress diff --git a/covfee/client/tasks/continuous_annotation/video_overlay_info.tsx b/covfee/client/tasks/continuous_annotation/video_overlay_info.tsx new file mode 100644 index 00000000..eaf3653a --- /dev/null +++ b/covfee/client/tasks/continuous_annotation/video_overlay_info.tsx @@ -0,0 +1,27 @@ +import React from "react" +import { REGISTER_ACTION_ANNOTATION_KEY } from "./constants" +import styles from "./continous_annotation.module.css" + +type Props = { + active: boolean + annotation_category: string +} + +const VideoOverlayInfo: React.FC = (props) => { + return ( +
    +

    + {props.active ? "" : "NOT"} {props.annotation_category} +

    +

    {" (Key: " + REGISTER_ACTION_ANNOTATION_KEY + ")"}

    +
    + ) +} + +export default VideoOverlayInfo diff --git a/covfee/client/types/journey.ts b/covfee/client/types/journey.ts index 9e75c416..c757f57f 100644 --- a/covfee/client/types/journey.ts +++ b/covfee/client/types/journey.ts @@ -1,6 +1,6 @@ import { JourneySpec } from "@covfee-spec/journey" -import { NodeType } from "./node" import { MarkdownContentSpec } from "@covfee-spec/tasks/utils" +import { NodeType } from "./node" export const JourneyApiStatuses = [ "INIT", diff --git a/covfee/client/types/node.ts b/covfee/client/types/node.ts index 848f1b23..4cad6026 100644 --- a/covfee/client/types/node.ts +++ b/covfee/client/types/node.ts @@ -45,6 +45,7 @@ export interface NodeType extends AllPropsRequired { * Status of the node */ status: NodeStatus + progress: number | null manual: ManualStatus taskData: any journeys: JourneyAssoc[] diff --git a/covfee/config/defaults.py b/covfee/config/defaults.py index a3dffd33..4c8cdbbc 100644 --- a/covfee/config/defaults.py +++ b/covfee/config/defaults.py @@ -54,3 +54,5 @@ ADMIN_USERNAME = "admin" ADMIN_PASSWORD = "admin" + +PROLIFIC_API_TOKEN: str = "MY_TOKEN_HERE" diff --git a/covfee/launcher.py b/covfee/launcher.py index ebe5a79c..11f83721 100644 --- a/covfee/launcher.py +++ b/covfee/launcher.py @@ -2,18 +2,25 @@ import platform import shutil import sys +from datetime import datetime from shutil import which -from typing import List from click import Path from colorama import Fore -from halo.halo import Halo +from sqlalchemy import Engine +from sqlalchemy.orm import sessionmaker import covfee.server.orm as orm from covfee.cli.utils import working_directory from covfee.config import Config +from covfee.logger import logger from covfee.server.app import create_app_and_socketio -from covfee.server.db import get_engine, get_session_local +from covfee.server.db import ( + DatabaseEngineConfig, + create_database_engine, + create_database_sessionmaker, +) +from covfee.shared.dataclass import CovfeeApp class ProjectExistsException(Exception): @@ -25,65 +32,121 @@ def __init__(self, name): class Launcher: """ Takes care of: - 1) validating projects - 2) commiting projects to DB (optional) + 1) validating orm_initialization_data + 2) commiting orm_initialization_data to DB (optional) 3) launching covfee in 'local' or 'dev' mode """ - # holds valid projects - projects: List["orm.Project"] + # holds valid _orm_initialization_data + environment: str + config: Config + _covfee_app: CovfeeApp + folder: Path + auth_enabled: bool + engine: Engine + _sessionmaker: sessionmaker + _database_modifications_should_be_manually_confirmed: bool = False + _database_engine_config: DatabaseEngineConfig def __init__( self, environment, - projects: List["orm.Project"] = [], + covfee_app: CovfeeApp, folder: Path = None, auth_enabled: bool = True, ): self.environment = environment + self._covfee_app = covfee_app self.config = Config(environment) - self.projects = projects self.folder = folder self.auth_enabled = auth_enabled - self.engine = get_engine( - in_memory=(environment == "dev"), db_path=self.config["DATABASE_PATH"] + if environment != "dev": + self._database_engine_config = DatabaseEngineConfig( + database_file=self.config["DATABASE_PATH"], + ) + self._database_modifications_should_be_manually_confirmed = os.path.exists( + self._database_engine_config.database_file + ) + else: + # In memory database for development and debugging + self._database_engine_config = DatabaseEngineConfig() + + self.engine = create_database_engine(self._database_engine_config) + self._sessionmaker = create_database_sessionmaker(self.engine) + + def create_or_update_database(self, delete_existing_data: bool = False): + # 1. Create the folders for the database and media + os.makedirs(os.path.join(self.folder, ".covfee"), exist_ok=True) + os.makedirs(os.path.join(self.folder, "www", "media"), exist_ok=True) + + # 2. Delete old tables if "delete_existing_data" is True. Then create tables. + if delete_existing_data: + if self._database_modifications_should_be_manually_confirmed: + confirmation = input( + "Are you sure you want to delete existing database tables? (yes/no): " + ) + if confirmation.lower() != "yes": + print("Aborting...") + exit() + + self._make_a_backup_of_the_database_file() + + orm.Base.metadata.drop_all(self.engine) + orm.Base.metadata.create_all(self.engine) + + # 3. Create the admin user if required and not existing in the database + self._create_admin_user_in_database_if_needed() + + with self._sessionmaker() as session: + # 4. Now, we update the database according to the covfee app specification + # given by the user. If the projects already existed, then it either + # keeps the database as is, or, if the user is adding more HITs/Journeys + # through the global_unique_id mechanic, then it updates the database with + # new HITs/Journeys with global_unique_ids which are not already in the database. + # All the others are ignored/kept as is. + self._covfee_app.add_to_database_new_or_updated_projects_specifications_and_instances( + session + ) + + # 5. Prior to commit the changes, check with the user that this is intentional. + if ( + self._database_modifications_should_be_manually_confirmed + and not delete_existing_data + and (session.new or session.dirty or session.deleted) + ): + user_confirmation_response = input( + "The database will be modified. Are you sure you want to continue? (yes/no): " + ) + if user_confirmation_response.lower() != "yes": + print("Aborting...") + exit() + else: + self._make_a_backup_of_the_database_file() + else: + logger.info("No database modifications were detected.") + + session.commit() + session.close() + + def _make_a_backup_of_the_database_file(self) -> None: + database_backup_filename = f"{self._database_engine_config.database_file}.backup.{datetime.now().strftime('%Y%m%d%H%M%S')}" + logger.info(f"Creating database backup: {database_backup_filename}...") + shutil.copy2( + self._database_engine_config.database_file, + database_backup_filename, ) - self.session_local = get_session_local(self.engine) - - def make_database(self, force=False, with_spinner=False): - self.init_folder() - self.create_tables(drop=force) - self.create_admin() - if not force: - with Halo( - text="Looking for existing projects", - spinner="dots", - enabled=with_spinner, - ) as spinner: - try: - self.check_conficts() - except ProjectExistsException as ex: - spinner.fail("Conflicts found") - raise ex - - self.commit() def launch(self, host="0.0.0.0", port=5000): if self.environment != "dev": self.link_bundles() - socketio, app = create_app_and_socketio(self.environment, self.session_local) + socketio, app = create_app_and_socketio(self.environment, self._sessionmaker) with app.app_context(): app.config["UNSAFE_MODE_ON"] = not self.auth_enabled self._start_server(socketio, app, host, port) - def create_tables(self, drop=False): - if drop: - orm.Base.metadata.drop_all(self.engine) - orm.Base.metadata.create_all(self.engine) - - def create_admin(self): + def _create_admin_user_in_database_if_needed(self): default_username = self.config["DEFAULT_ADMIN_USERNAME"] default_password = self.config["DEFAULT_ADMIN_PASSWORD"] if "ADMIN_USERNAME" in self.config and "ADMIN_PASSWORD" in self.config: @@ -98,7 +161,7 @@ def create_admin(self): raise ValueError( 'Default admin credentials "admin:admin" have not been changed. Please change username and password in config when deploying with authentication.' ) - with self.session_local() as session: + with self._sessionmaker() as session: user = orm.User.by_username(session, username) if user is not None: return @@ -138,34 +201,6 @@ def launch_browser(self, unsafe=False): else: print(Fore.GREEN + f" * covfee is available at {target_url}") - def check_conficts(self, with_spinner=False): - with self.session_local() as session: - for project in self.projects: - existing_project = orm.Project.by_name(session, project.name) - if existing_project: - raise ProjectExistsException(project.name) - - def commit(self): - with self.session_local() as session: - for project in self.projects: - existing_project = orm.Project.by_name(session, project.name) - - if existing_project: - session.delete(existing_project) - session.commit() - - for project in self.projects: - session.add(project) - session.commit() - - def init_folder(self): - covfee_hidden = os.path.join(self.folder, ".covfee") - if not os.path.exists(covfee_hidden): - os.makedirs(covfee_hidden) - media_path = os.path.join(self.folder, "www", "media") - if not os.path.exists(media_path): - os.makedirs(media_path) - def link_bundles(self): master_bundle_path = os.path.join(self.config["MASTER_BUNDLE_PATH"], "main.js") if not os.path.exists(master_bundle_path): diff --git a/covfee/loader.py b/covfee/loader.py index 1c632559..b93258c2 100644 --- a/covfee/loader.py +++ b/covfee/loader.py @@ -3,7 +3,7 @@ import json import importlib from pathlib import Path -from typing import List +from typing import List, Dict from flask import current_app as app from halo import Halo @@ -14,6 +14,8 @@ from covfee.cli.utils import working_directory from covfee.shared.schemata import Schemata from covfee.shared.validator.ajv_validator import AjvValidator +from covfee.shared.dataclass import CovfeeApp +from covfee.shared.dataclass import Project as ProjectSpec from covfee.server.orm.project import Project colorama_init() @@ -30,82 +32,109 @@ def cli_create_tables(): class Loader: """Translates between different covfee file formats""" - def __init__(self, project_spec_file=None): + _project_spec_file: Path + _project_spec_from_python_file: bool + + def __init__(self, project_spec_file: str): if not os.path.exists(project_spec_file): raise FileNotFoundError("covfee file not found.") - self.project_spec_file = Path(project_spec_file) - self.file_extension = self.project_spec_file.suffix - if self.file_extension not in [".py", ".json"]: - raise ValueError(f"Unsupported file extension {self.file_extension}") + self._project_spec_file = Path(project_spec_file) + self._project_spec_from_python_file = self._project_spec_file.suffix == ".py" + + if ( + not self._project_spec_from_python_file + and self._project_spec_file.suffix != ".json" + ): + raise ValueError( + f"Unsupported covfee project specification file type {project_spec_file}" + ) + + def load_project_spec_file_and_parse_as_covfee_app( + self, with_spinner: bool=False + ) -> CovfeeApp: + covfee_app: CovfeeApp + if self._project_spec_from_python_file: + covfee_app = self._load_covfee_app_from_python_module() + else: + schema = Schemata() + if not schema.exists(): + schema.make() + + projects_json_specs: List[Dict] = ( + self._load_projects_json_specs_from_json_file(with_spinner) + ) + self._raise_exception_if_projects_json_specs_are_not_valid( + projects_json_specs, with_spinner + ) + covfee_app = self._parse_projects_json_specs_into_covfee_app( + projects_json_specs, with_spinner + ) - self.working_dir = self.project_spec_file.parent - self.projects = [] + return covfee_app - def json_parse(self, with_spinner=True): + def _load_projects_json_specs_from_json_file(self, with_spinner: bool=True) -> List[Dict]: + projects_json_specs: List[Dict] = [] with Halo( - text=f"Parsing file {self.project_spec_file} as json..", + text=f"Parsing file {self._project_spec_file} as json..", spinner="dots", enabled=with_spinner, ) as spinner: try: - project_spec = json.load(open(self.project_spec_file)) + project_json_specs: Dict = json.load(open(self._project_spec_file)) except Exception as e: spinner.fail( - f"Error parsing file {self.project_spec_file} as JSON. Are you sure it is valid json?" + f"Error parsing file {self._project_spec_file} as JSON. Are you sure it is valid json?" ) raise e - self.projects.append(project_spec) - spinner.succeed(f"Read covfee file {self.project_spec_file}.") + projects_json_specs.append(project_json_specs) + spinner.succeed(f"Read covfee file {self._project_spec_file}.") + return projects_json_specs - def json_validate(self, with_spinner=False): + def _raise_exception_if_projects_json_specs_are_not_valid( + self, projects_json_specs: List[Dict], with_spinner: bool=False + ) -> None: filter = AjvValidator() - for project_spec in self.projects: + for project_json_specs in projects_json_specs: with Halo( - text=f'Validating project {project_spec["name"]}', + text=f'Validating project {project_json_specs["name"]}', spinner="dots", enabled=with_spinner, ) as spinner: try: - filter.validate_project(project_spec) + filter.validate_project(project_json_specs) except Exception as e: spinner.fail( - f'Error validating project "{project_spec["name"]}".\n' + f'Error validating project "{project_json_specs["name"]}".\n' ) raise e - spinner.succeed(f'Project "{project_spec["name"]}" is valid.') - - def json_make(self, with_spinner=False): - projects = [] - for project_spec, cf in self.projects: + spinner.succeed(f'Project "{project_json_specs["name"]}" is valid.') + + def _parse_projects_json_specs_into_covfee_app( + self, projects_json_specs: List[Dict], with_spinner: bool=False + ) -> CovfeeApp: + raise NotImplementedError + # FIXME: ...this function was broken, but was modified to provide a skeleton of what's needed. + # ... moreover, the previous version of the code was very unclear regarding what cf is + # ...supposed to be and whether it is inside the json data, or somehow added to "self.projects" + projects_specs: List[ProjectSpec] = [] + for project_json_spec, cf in projects_json_specs: with Halo( - text=f'Making project {project_spec["name"]} from file {cf}', + text=f'Making project {project_json_spec["name"]} from file {cf}', spinner="dots", enabled=with_spinner, ) as spinner: - projects.append(Project(**project_spec)) + + projects_specs.append(ProjectSpec(**project_json_spec)) spinner.succeed( - f'Project {project_spec["name"]} created successfully from file {cf}' + f'Project {project_json_spec["name"]} created successfully from file {cf}' ) + return CovfeeApp(projects_specs) - def python_load(self): + def _load_covfee_app_from_python_module(self) -> CovfeeApp: if os.getcwd() not in sys.path: sys.path.append(os.getcwd()) - module = importlib.import_module(self.project_spec_file.stem) - app = getattr(module, "app") - self.projects += app.get_instantiated_projects() - - def process(self, with_spinner=False) -> List[Project]: - if self.file_extension == ".py": - self.python_load() - else: - # validate the covfee files - schema = Schemata() - if not schema.exists(): - schema.make() - - self.json_parse(with_spinner) - self.validate(with_spinner) - self.json_make(with_spinner) - return self.projects + module = importlib.import_module(self._project_spec_file.stem) + app: CovfeeApp = getattr(module, "app") + return app diff --git a/covfee/server/app.py b/covfee/server/app.py index ade031c0..f01779d7 100644 --- a/covfee/server/app.py +++ b/covfee/server/app.py @@ -1,23 +1,40 @@ import inspect import json import os - -from flask import Blueprint, Flask +from typing import Optional + +from flask import ( + Blueprint, + Flask, + abort, + redirect, + render_template, + request, + send_from_directory, +) from flask import current_app as app -from flask import render_template, send_from_directory from flask_cors import CORS from flask_jwt_extended import JWTManager from flask_session import Session -from sqlalchemy.orm import scoped_session +from sqlalchemy import not_, select +from sqlalchemy.orm import scoped_session, sessionmaker from covfee.config import Config from covfee.server import tasks +from covfee.server.rest_api.utils import ( + ProlificAPIRequestError, + fetch_prolific_ids_for_invalid_participants, +) from covfee.server.tasks.base import BaseCovfeeTask +from .orm.annotator import Annotator +from .orm.journey import JourneyInstance, JourneyInstanceStatus, JourneySpec from .scheduler.apscheduler import scheduler -def create_app_and_socketio(mode="deploy", session_local=None): +def create_app_and_socketio( + mode: str = "deploy", session_local: Optional[sessionmaker] = None +): # called once per process # but reused across threads @@ -34,10 +51,10 @@ def create_app_and_socketio(mode="deploy", session_local=None): app.json = CovfeeJSONProvider(app) if session_local is None: - from .db import get_session_local + from .db import DatabaseEngineConfig, create_database_sessionmaker - session_local = get_session_local( - in_memory=False, db_path=config["DATABASE_PATH"] + session_local = create_database_sessionmaker( + DatabaseEngineConfig(database_file=config["DATABASE_PATH"]) ) app.sessionmaker = session_local @@ -127,6 +144,129 @@ def admin(): ) +# annotator redirection from prolific academic +@frontend.route("/prolific") +def prolific(): + # The journey_instance_id to use for redirection + journey_instance_url: Optional[str] = None + + # We extract the Prolific academic worker unique id + prolific_study_id = request.args.get("STUDY_ID") + prolific_annotator_id = request.args.get("PROLIFIC_PID") + prolific_annotator_session_id = request.args.get("SESSION_ID") + + if ( + prolific_study_id is None + or prolific_annotator_id is None + or prolific_annotator_session_id is None + ): + abort(404) + + try: + prolific_ids_for_invalid_participants = ( + fetch_prolific_ids_for_invalid_participants( + prolific_study_id, app.config["PROLIFIC_API_TOKEN"] + ) + ) + except ProlificAPIRequestError as err: + # This error would be raised whenever: + # - Prolific academic API is down, or timedout + # - Incorrect study_id or token + print(f"ProlificAPIRequestError: {err}") + return render_template( + "annotator_error.html", + message="Failed to communicate with prolific academic. Please try again later!", + id=prolific_annotator_id, + ) + + if prolific_annotator_id in prolific_ids_for_invalid_participants: + return render_template( + "annotator_error.html", + message="This task can no longer be accessed.", + id=prolific_annotator_id, + ) + + # We check if an Annotator exists in the database with the given prolific_annotator_id + annotator = app.session.execute( + select(Annotator).filter_by( + prolific_id=prolific_annotator_id, prolific_study_id=prolific_study_id + ) + ).scalar_one_or_none() + + if annotator is not None: + # Annotator already registered, check whether to redirect to their journey_instance work, or + # to inform them they no longer have access. + if annotator.journey_instance is not None: + journey_instance_url = annotator.journey_instance.get_url() + else: + return render_template( + "annotator_error.html", + message="This task can no longer be accessed. Please contact the study coordinator.", + id=prolific_annotator_id, + ) + else: + # We ignore finished or disabled journeys within the current study + non_finished_journey_instances_in_study_query = ( + app.session.query(JourneyInstance) + .join(JourneySpec, JourneyInstance.journeyspec_id == JourneySpec.id) + .filter( + not_( + JourneyInstance.status.in_( + [JourneyInstanceStatus.FINISHED, JourneyInstanceStatus.DISABLED] + ) + ), + JourneySpec.prolific_study_id == prolific_study_id, + ) + ) + # We search for a journey_instance that does not have an annotator associated with it + journey_instance: JourneyInstance + for journey_instance in non_finished_journey_instances_in_study_query.all(): + if ( + journey_instance.annotator is None + or journey_instance.annotator.prolific_id + in prolific_ids_for_invalid_participants + ): + # TODO: In the next iteration of this logic we want to achieve two things + # 1) For a completed journey, we want to keep a record of the annotator id, + # regardless of whether the annotator is assigned to another journey to work on + # 2) Implement logic in which an annotator can't be assigned to journeys belonging + # to the same HIT. This is to prevent the same annotator from annotating the + # same task, whereas assigning multiple journeys to one HIT is a good way to + # achieve inter-annotator agreement evaluations. + + # We take a record of the journey_id for the later redirection + journey_instance_url = journey_instance.get_url() + + # Then we register an Annotator row and associate it with the journey_instance + if journey_instance.annotator is not None: + journey_instance.reset_nodes_annotated_data() + + annotator = Annotator( + prolific_id=prolific_annotator_id, + prolific_study_id=prolific_study_id, + ) + journey_instance.annotator = annotator + app.session.commit() + + break + + if journey_instance_url is None: + # We should here tell the annotator that all tasks have been taken and send an email or something + return render_template( + "annotator_error.html", + message="No pending tasks available. Please contact the study coordinator.", + id=prolific_annotator_id, + ) + + # Debugging URLs + # http://localhost:5000/prolific?PROLIFIC_PID=annotator1&STUDY_ID=66277a8f17e66747a8b4ed16&SESSION_ID=1 + # http://localhost:5000/prolific?PROLIFIC_PID=annotator2&STUDY_ID=66277a8f17e66747a8b4ed16&SESSION_ID=1 + # http://localhost:5000/prolific?PROLIFIC_PID=annotator3&STUDY_ID=66277a8f17e66747a8b4ed16&SESSION_ID=1 + # http://localhost:5000/prolific?PROLIFIC_PID=66278fea4837dd6351dd936c&STUDY_ID=66277a8f17e66747a8b4ed16&SESSION_ID=1 # < Returned participant + + return redirect(journey_instance_url) + + # project www server @frontend.route("/www/") def project_www_file(filename): diff --git a/covfee/server/db.py b/covfee/server/db.py index bbf12caa..c077cc82 100644 --- a/covfee/server/db.py +++ b/covfee/server/db.py @@ -1,21 +1,43 @@ -from sqlalchemy import create_engine +from sqlalchemy import create_engine, Engine from sqlalchemy.orm import sessionmaker +from typing import Optional, NamedTuple, Union -def get_engine(in_memory=False, db_path=None, echo=False): - if in_memory: + +class DatabaseEngineConfig(NamedTuple): + # The path to the database file, if not provided, the database will be in-memory + database_file: Optional[str] = None + + # Whether to display the SQL commands being executed + echo_sql_commands: bool = False + + +def create_database_engine(config: DatabaseEngineConfig) -> Engine: + """ + Creates an SQLAlchemy engine attached to the database file specified in the config + """ + if config.database_file: + print(f"Creating file system engine at {config.database_file}") + return create_engine( + f"sqlite:///{config.database_file}", echo=config.echo_sql_commands + ) + else: print(f"Creating in-memory engine") return create_engine( "sqlite:///file:test?mode=memory&cache=shared&uri=true", connect_args={"check_same_thread": False}, - echo=echo, + echo=config.echo_sql_commands, ) - else: - assert db_path is not None - print(f"Creating file system engine at {db_path}") - return create_engine(f"sqlite:///{db_path}") -def get_session_local(engine=None, **kwargs): - engine = get_engine(**kwargs) if engine is None else engine +def create_database_sessionmaker(engine: Union[Engine, DatabaseEngineConfig]) -> sessionmaker: + """ + Generates the SQLAlchemy sessionmaker for to generate sessions + + Parameters: + - engine (Engine | DatabaseEngineConfig): The engine to use for the sessionmaker. If a + DatabaseEngineConfig is provided, it will be used to create the engine. + """ + if isinstance(engine, DatabaseEngineConfig): + engine = create_database_engine(engine) return sessionmaker(autocommit=False, autoflush=False, bind=engine) diff --git a/covfee/server/orm/__init__.py b/covfee/server/orm/__init__.py index 0e708678..ed160190 100644 --- a/covfee/server/orm/__init__.py +++ b/covfee/server/orm/__init__.py @@ -7,6 +7,7 @@ from .response import * from .user import * from .chat import * +from .annotator import * def set_sessionmaker(sessionmaker): diff --git a/covfee/server/orm/annotator.py b/covfee/server/orm/annotator.py new file mode 100644 index 00000000..462a5f6a --- /dev/null +++ b/covfee/server/orm/annotator.py @@ -0,0 +1,43 @@ +import datetime +from typing import Optional + +from sqlalchemy import ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from .base import Base + + +class Annotator(Base): + """ + Links an annotator to a single journey instance + """ + + __tablename__ = "annotators" + + # A unique identifier for the annotator row + id: Mapped[int] = mapped_column(primary_key=True) + # A user id provided by the Prolific academic platform + prolific_id: Mapped[str] = mapped_column() + # A reference to the study id + prolific_study_id: Mapped[Optional[str]] = mapped_column() + # A reference to the journey instance the annotator is working on + journey_instance_id: Mapped[Optional[int]] = mapped_column( + ForeignKey("journeyinstances.id") + ) + # The journey instance the annotator is working on + journey_instance: Mapped[Optional["JourneyInstance"]] = relationship( + "JourneyInstance", + back_populates="annotator", + foreign_keys=[journey_instance_id], + ) + # The time the annotator row was created, expected to be the time the annotator was first linked + # to a journey instance. + created_at: Mapped[datetime.datetime] = mapped_column(default=datetime.datetime.now) + + def __repr__(self) -> str: + journey_instance_id = ( + self.journey_instance_id.hex() if self.journey_instance_id else "?" + ) + + created_at = self.created_at.isoformat() if self.created_at else "?" + return f"Annotator(id={self.id}, prolific_id={self.prolific_id}, prolific_study_id={self.prolific_study_id}, journey_instance_id={journey_instance_id}, created_at={created_at})" diff --git a/covfee/server/orm/hit.py b/covfee/server/orm/hit.py index c75e43d2..42e4626b 100644 --- a/covfee/server/orm/hit.py +++ b/covfee/server/orm/hit.py @@ -6,12 +6,14 @@ import binascii import hashlib import datetime -from typing import List, TYPE_CHECKING +import secrets +from typing import List, TYPE_CHECKING, Optional, ClassVar from hashlib import sha256 from pprint import pformat from flask import current_app as app -from sqlalchemy import ForeignKey, UniqueConstraint + +from sqlalchemy import ForeignKey from sqlalchemy.orm import relationship, Mapped, mapped_column # from ..db import Base @@ -20,14 +22,20 @@ from .journey import JourneySpec, JourneyInstance from .project import Project from .node import JourneyNode, NodeInstance, NodeSpec +from .utils import NoIndentJSONEncoder class HITSpec(Base): __tablename__ = "hitspecs" - __table_args__ = (UniqueConstraint("project_id", "name"),) id: Mapped[int] = mapped_column(primary_key=True) name: Mapped[str] + # An optional id that the study administrator can attach to this HIT + # making it identifiable through multiple launches of "covfee make" + # and thus being able to add more hits/journeys without destroying + # the database. It's a string as it is intended to be human-readable + global_unique_id: Mapped[Optional[str]] = mapped_column(unique=True) + project_id: Mapped[int] = mapped_column(ForeignKey("projects.id")) # project_id = Column(Integer, ForeignKey("projects.name")) project: Mapped[Project] = relationship(back_populates="hitspecs") @@ -52,10 +60,18 @@ def __init__(self, name=None, journeyspecs: List[JourneySpec] = []): if name is None: name = binascii.b2a_hex(os.urandom(8)).decode("utf-8") self.name = name - self.journeyspecs = journeyspecs - print(f"HIT: {len(journeyspecs)}") - self.nodespecs = [n for js in journeyspecs for n in js.nodespecs] - print(f"HIT: {len(self.nodespecs)}") + self.append_journeyspecs(journeyspecs) + + def append_journeyspecs(self, journeyspecs: List[JourneySpec]): + self.journeyspecs += journeyspecs + print( + f"HIT - Appended {len(journeyspecs)} of {len(self.journeyspecs)} journeyspecs" + ) + new_nodespecs = [n for js in journeyspecs for n in js.nodespecs] + self.nodespecs += new_nodespecs + print( + f"HIT: - Appended {len(new_nodespecs)} of {len(self.nodespecs)} nodespecs" + ) def make_journey(self): journeyspec = JourneySpec() @@ -65,17 +81,10 @@ def make_journey(self): def instantiate(self, n=1): for _ in range(n): instance = HITInstance( - # (id, index) - id=sha256(f"{self.id}_{len(self.journeyspecs)}".encode()).digest(), journeyspecs=self.journeyspecs, ) self.instances.append(instance) - def launch(self, num_instances=1): - if self.project is None: - self.project = Project() - self.project.launch(num_instances) - def get_api_url(self): return f'{app.config["API_URL"]}/hits/{self.id}' @@ -105,7 +114,8 @@ def __str__(self): class HITInstance(Base): - """Represents an instance of a HIT, to be solved by one user + """Represents an instance of a HIT, to be solved by one or multiple users through their + respective journeys - one HIT instance maps to one URL that can be sent to a participant to access and solve the HIT. - a HIT instance is specified by the abstract HIT it is an instance of. - a HIT instance is linked to a list of tasks (instantiated task specifications), @@ -134,15 +144,21 @@ class HITInstance(Base): default=datetime.datetime.now, onupdate=datetime.datetime.now ) - def __init__(self, id: bytes, journeyspecs: List[JourneySpec] = []): + instance_counter: ClassVar[int] = 0 + + def __init__(self, journeyspecs: List[JourneySpec] = []): super().init() - self.id = id - self.preview_id = sha256((id + "preview".encode())).digest() + self.id = HITInstance.generate_new_id() + self.preview_id = sha256((self.id + "preview".encode())).digest() self.submitted = False + # instantiate every node only once + self.instantiate_new_journeys(journeyspecs) + + def instantiate_new_journeys(self, journeyspecs: List[JourneySpec]): # instantiate every node only once nodespec_to_nodeinstance = dict() - for i, journeyspec in enumerate(journeyspecs): + for journeyspec in journeyspecs: journey = journeyspec.instantiate() journey.hit_id = self.id @@ -160,13 +176,27 @@ def __init__(self, id: bytes, journeyspecs: List[JourneySpec] = []): ) ) self.journeys.append(journey) - self.nodes = list(nodespec_to_nodeinstance.values()) + new_nodes = list(nodespec_to_nodeinstance.values()) + self.nodes = self.nodes + new_nodes # call node.reset when the graph is ready # ie. when all the links are set in the ORM - for node in self.nodes: + for node in new_nodes: node.reset() + @staticmethod + def generate_new_id(): + if os.environ.get("COVFEE_ENV") == "dev": + # return predictable id in dev mode + # so that ids don't change on every run + # Note, in a previous implementation, the id was a function of the associated + # len(HITSpec.journeyspecs). Unclear why. + id = hashlib.sha256((str(HITInstance.instance_counter).encode())).digest() + HITInstance.instance_counter += 1 + else: + id = secrets.token_bytes(32) + return id + def get_api_url(self): return f'{app.config["API_URL"]}/instances/{self.id.hex():s}' @@ -213,6 +243,7 @@ def to_dict(self, with_nodes=False): def make_results_dict(self): return { "hit_id": self.id.hex(), + "global_unique_id": self.spec.global_unique_id, "nodes": {node.id: node.make_results_dict() for node in self.nodes}, "journeys": [journey.make_results_dict() for journey in self.journeys], } @@ -220,7 +251,10 @@ def make_results_dict(self): def stream_download(self, z, base_path): results_dict = self.make_results_dict() stream = BytesIO() - stream.write(json.dumps(results_dict).encode()) + + stream.write( + json.dumps(results_dict, indent=4, cls=NoIndentJSONEncoder).encode() + ) stream.seek(0) z.write_iter( os.path.join( diff --git a/covfee/server/orm/journey.py b/covfee/server/orm/journey.py index 37eb8c98..089e8b9e 100644 --- a/covfee/server/orm/journey.py +++ b/covfee/server/orm/journey.py @@ -15,6 +15,7 @@ from sqlalchemy.ext.associationproxy import AssociationProxy, association_proxy from sqlalchemy.orm import Mapped, mapped_column, relationship +from .annotator import Annotator from .base import Base from .chat import Chat, ChatJourney from .node import JourneyNode, JourneySpecNodeSpec @@ -29,6 +30,18 @@ class JourneySpec(Base): id: Mapped[int] = mapped_column(primary_key=True) name: Mapped[Optional[str]] + # An optional id that the study administrator can attach to this Journey + # making it identifiable through multiple launches of "covfee make" + # and thus being able to add more hits/journeys without destroying + # the database. It's a string as it is intended to be human-readable + global_unique_id: Mapped[Optional[str]] = mapped_column(unique=True) + + # Indicates whether this journey will be linked to the given study id + # from prolific academic, such that when annotators are assigned journeys + # , it will only assign journeys corresponding to the same study id as the + # incoming annotator. + prolific_study_id: Mapped[Optional[str]] = mapped_column() + # spec relationships # up hitspec_id: Mapped[int] = mapped_column(ForeignKey("hitspecs.id")) @@ -85,7 +98,12 @@ class JourneyInstanceStatus(enum.Enum): class JourneyInstance(Base): - """Represents an instance of a HIT, to be solved by one user""" + """Represents an instance of a journey, to be solved by one user + - one Journey instance maps to one URL that can be sent to a participant to access and solve the HIT. + - a Journey instance is specified by the abstract JourneySpec it is an instance of. + - a Journey instance is linked to a list of tasks (instantiated task specifications), + which hold the responses for the Journey + """ __tablename__ = "journeyinstances" @@ -107,6 +125,11 @@ class JourneyInstance(Base): back_populates="journey", cascade="all,delete" ) + # annotator associated to this journey + annotator: Mapped[Optional[Annotator]] = relationship( + back_populates="journey_instance", cascade="all,delete" + ) + # down node_associations: Mapped[List[JourneyNode]] = relationship( back_populates="journey", order_by=JourneyNode.order, cascade="all,delete" @@ -150,7 +173,7 @@ class JourneyInstance(Base): ) @staticmethod - def get_id(): + def generate_new_id(): if os.environ.get("COVFEE_ENV") == "dev": # return predictable id in dev mode # so that URLs don't change on every run @@ -164,7 +187,7 @@ def get_id(): def __init__(self): super().init() - self.id = JourneyInstance.get_id() + self.id = JourneyInstance.generate_new_id() self.preview_id = hashlib.sha256((self.id + "preview".encode())).digest() self.submitted = False self.interface = {} @@ -172,6 +195,10 @@ def __init__(self): self.config = {} self.chat = Chat(self) + def reset_nodes_annotated_data(self): + for node in self.nodes: + node.reset_annotated_data() + def get_url(self): return f'{app.config["APP_URL"]}/journeys/{self.id.hex():s}' @@ -248,4 +275,7 @@ def to_dict(self, with_nodes=False, with_response_info=False): return instance_dict def make_results_dict(self): - return {"nodes": [node.id for node in self.nodes]} + return { + "nodes": [node.id for node in self.nodes], + "global_unique_id": self.spec.global_unique_id, + } diff --git a/covfee/server/orm/node.py b/covfee/server/orm/node.py index 063e8dd9..922a5054 100644 --- a/covfee/server/orm/node.py +++ b/covfee/server/orm/node.py @@ -177,6 +177,9 @@ class NodeInstance(Base): ) status: Mapped[NodeInstanceStatus] = mapped_column(default=NodeInstanceStatus.INIT) + # Optional numeric [0-100] progress for the Node + progress: Mapped[Optional[float]] = mapped_column(default=None) + dt_start: Mapped[Optional[datetime]] dt_pause: Mapped[Optional[datetime]] dt_count: Mapped[Optional[datetime]] @@ -209,6 +212,10 @@ def reset(self): except RuntimeError: pass + def reset_annotated_data(self) -> None: + # To be overriden by the respective TaskInstance + return + def eval_expression(self, expression): var_values = { "N": len(self.curr_journeys), diff --git a/covfee/server/orm/project.py b/covfee/server/orm/project.py index 06f825c3..f5f0cd43 100644 --- a/covfee/server/orm/project.py +++ b/covfee/server/orm/project.py @@ -34,10 +34,6 @@ def __init__( self.email = email self.hitspecs = hitspecs - # to keep track of info at launch time - self._conflicts = False - self._filename = None - def get_dataframe(self): rows = list() for hit in self.hitspecs: @@ -47,9 +43,11 @@ def get_dataframe(self): { "hit_name": hit.name, "hit_id": instance.id.hex(), - "journey_name": journey.spec.name - if journey.spec.name is not None - else "unnamed", + "journey_name": ( + journey.spec.name + if journey.spec.name is not None + else "unnamed" + ), "journey_id": journey.id, "url": journey.get_url(), "completion_code": journey.get_completion_code(), diff --git a/covfee/server/orm/task.py b/covfee/server/orm/task.py index ceffcafc..634c0bdd 100644 --- a/covfee/server/orm/task.py +++ b/covfee/server/orm/task.py @@ -107,6 +107,10 @@ def __eq__(self, other): return NotImplemented return hash(self) == hash(other) + def reset_annotated_data(self): + for annotation in self.annotations: + annotation.reset_data() + def get_task_object(self): task_class = getattr(tasks, self.spec.spec["type"], BaseCovfeeTask) task_object = task_class(task=self, session=object_session(self)) @@ -146,6 +150,7 @@ def make_status_payload(self, prev_status: NodeInstanceStatus = None): "dt_count": utils.datetime_to_str(self.dt_count), "dt_pause": utils.datetime_to_str(self.dt_pause), "t_elapsed": self.t_elapsed, + "progress": self.progress, } def pause(self, pause: bool): @@ -154,9 +159,39 @@ def pause(self, pause: bool): self.get_task_object().on_admin_pause() def make_results_dict(self): - return { - "responses": [response.make_results_dict() for response in self.responses] - } + results_list = [] + for response in self.responses: + result_dict = response.make_results_dict() + + # FIXME: #CONFLAB: do this loop for the getattr in the annotations class + result_dict["annotations"] = {} + for annotation in self.annotations: + annotation_dict = { + "participant": annotation.participant, + "category": annotation.category, + } + if annotation.data_json is not None: + annotation_dict["data"] = utils.NoIndentJSON(annotation.data_json) + else: + annotation_dict["data"] = None + + result_dict["annotations"][annotation.id] = annotation_dict + + result_dict["journeys"] = [] + for journey in self.journeys: + journey_dict = {"global_unique_id": journey.spec.global_unique_id} + annotator = journey.annotator + if annotator is not None and annotator.prolific_id is not None: + journey_dict["prolific_id"] = annotator.prolific_id + journey_dict["prolific_study_id"] = annotator.prolific_study_id + else: + journey_dict["prolific_id"] = None + journey_dict["prolific_study_id"] = None + result_dict["journeys"].append(journey_dict) + + results_list.append(result_dict) + + return {"responses": results_list} # after a TaskInstance is inserted, we attach its diff --git a/covfee/server/orm/utils.py b/covfee/server/orm/utils.py index 7910657a..5827c428 100644 --- a/covfee/server/orm/utils.py +++ b/covfee/server/orm/utils.py @@ -2,6 +2,9 @@ from typing import TYPE_CHECKING, Union import enum from datetime import date, datetime +import json +from _ctypes import PyObj_FromPtr +import re if TYPE_CHECKING: from .node import NodeInstance @@ -24,3 +27,60 @@ def to_dict(attrib): return datetime_to_str(attrib) else: return attrib + + +class NoIndentJSON(object): + def __init__(self, value): + self.value = value + + +# JSON encoder that avoids indenting any object of type NoIndentList. +# See https://stackoverflow.com/a/13252112 +# The list of annotations could be very long when formatted in a single column, like so: +# annotations: { [ +# 0, +# 0, +# ... +# 0, +# 0 +# ] } +# +# Instead this class allows skipping indentations of any entry that is a +# subclass of NoIndentJSON, like so: +# annotations: { [0, 0, ..., 0, 0] } +class NoIndentJSONEncoder(json.JSONEncoder): + FORMAT_SPEC = "@@{}@@" + regex = re.compile(FORMAT_SPEC.format(r"(\d+)")) + + def __init__(self, **kwargs): + # Save copy of any keyword argument values needed for use here. + self.__sort_keys = kwargs.get("sort_keys", None) + super(NoIndentJSONEncoder, self).__init__(**kwargs) + + def default(self, obj): + return ( + self.FORMAT_SPEC.format(id(obj)) + if isinstance(obj, NoIndentJSON) + else super(NoIndentJSONEncoder, self).default(obj) + ) + + def encode(self, obj): + format_spec = self.FORMAT_SPEC # Local var to expedite access. + json_repr = super(NoIndentJSONEncoder, self).encode(obj) # Default JSON. + + # Replace any marked-up object ids in the JSON repr with the + # value returned from the json.dumps() of the corresponding + # wrapped Python object. + for match in self.regex.finditer(json_repr): + # see https://stackoverflow.com/a/15012814/355230 + id = int(match.group(1)) + no_indent = PyObj_FromPtr(id) + json_obj_repr = json.dumps(no_indent.value, sort_keys=self.__sort_keys) + + # Replace the matched id string with json formatted representation + # of the corresponding Python object. + json_repr = json_repr.replace( + '"{}"'.format(format_spec.format(id)), json_obj_repr + ) + + return json_repr diff --git a/covfee/server/rest_api/journeys.py b/covfee/server/rest_api/journeys.py index 8e6163e1..d6ff056d 100644 --- a/covfee/server/rest_api/journeys.py +++ b/covfee/server/rest_api/journeys.py @@ -81,3 +81,12 @@ def node_ready(jid, nidx, value): socketio.emit("status", payload, namespace="/admin") return "", 200 + +# Return the annotator data for the annotator working on this journey +@api.route("/journeys//annotator") +@admin_required +def annotator(jid): + res :JourneyInstance = app.session.query(JourneyInstance).get(bytes.fromhex(jid)) + if res.annotator is None: + return {} + return {"prolific_pid": res.annotator.prolific_id, "created_at": str(res.annotator.created_at)} \ No newline at end of file diff --git a/covfee/server/rest_api/nodes.py b/covfee/server/rest_api/nodes.py index 3b81e38b..2d7ee148 100644 --- a/covfee/server/rest_api/nodes.py +++ b/covfee/server/rest_api/nodes.py @@ -67,6 +67,20 @@ def response_submit(nid): return jsonify(res) +@api.route("/nodes//progress/") +def set_progress(nid, progress: float): + node: NodeInstance = app.session.query(NodeInstance).get(int(nid)) + node.progress = progress + + if isinstance(node, TaskInstance): + payload = node.make_status_payload() + socketio.emit("status", payload, to=node.id) + socketio.emit("status", payload, namespace="/admin") + + app.session.commit() + return "", 200 + + # state management @api.route("/nodes//manual/") @admin_required @@ -98,4 +112,3 @@ def restart_node(nid): app.session.commit() return "", 200 - return "", 200 diff --git a/covfee/server/rest_api/projects.py b/covfee/server/rest_api/projects.py index ffa8c89e..3464def2 100644 --- a/covfee/server/rest_api/projects.py +++ b/covfee/server/rest_api/projects.py @@ -86,7 +86,7 @@ def project_download(pid): Returns: [type]: stream response with a compressed archive. 204 if the project has no responses """ - project = app.session.query(Project).get(pid) + project: Project = app.session.query(Project).get(pid) if project is None: return {"msg": "not found"}, 404 diff --git a/covfee/server/rest_api/utils.py b/covfee/server/rest_api/utils.py index 7c0624c6..694515a5 100644 --- a/covfee/server/rest_api/utils.py +++ b/covfee/server/rest_api/utils.py @@ -1,10 +1,18 @@ import json -from typing import Union from enum import Enum +from typing import List, Union + +import requests from flask import jsonify from flask.json.provider import JSONProvider +class ProlificAPIRequestError(Exception): + def __init__(self, message): + self.message = message + super().__init__(self.message) + + def jsonify_or_404(res, **kwargs): if res is None: return {"msg": "not found"}, 404 @@ -30,3 +38,39 @@ def dumps(self, obj, **kwargs): def loads(self, s: Union[str, bytes], **kwargs): return json.loads(s, **kwargs) + + +def fetch_prolific_ids_for_invalid_participants(study_id: str, token: str) -> List[str]: + """ + Use the Prolific Academic API to get the IDs of participants who have failed to do + the study. Either because they abandoned it (RETURNED), because we rejected the data + that they submitted (REJECTED) or they failed to complete it on time (TIMED-OUT). + See https://docs.prolific.com/docs/api-docs/public/#tag/Submissions/Submission-object + """ + headers = { + "Authorization": f"Token {token}", + } + + params = {"study": study_id} + + try: + response = requests.get( + "https://api.prolific.com/api/v1/submissions/", + headers=headers, + params=params, + ) + response.raise_for_status() + except requests.exceptions.HTTPError as err: + raise ProlificAPIRequestError( + f"HTTP error occurred while requesting prolific data: {err}" + ) + except requests.exceptions.RequestException as err: + raise ProlificAPIRequestError( + f"Request error occurred while requesting prolific data: {err}" + ) + else: + return [ + participant["participant_id"] + for participant in response.json()["results"] + if participant["status"] in ["RETURNED", "REJECTED", "TIMED-OUT"] + ] diff --git a/covfee/server/tasks/continuous_annotation.py b/covfee/server/tasks/continuous_annotation.py index 81af8948..0b923f84 100644 --- a/covfee/server/tasks/continuous_annotation.py +++ b/covfee/server/tasks/continuous_annotation.py @@ -34,8 +34,9 @@ def on_create(self): self.session.add( Annotation( task_id=self.task.id, - name=annot["name"], + category=annot["category"], interface=annot["interface"], + participant=annot["participant"], ) ) @@ -80,6 +81,8 @@ def update_annotation(annotid): updates = request.json for key, value in updates.items(): if hasattr(annot, key): + if key in ["created_at", "updated_at"]: + continue setattr(annot, key, value) app.session.commit() @@ -106,9 +109,10 @@ class Annotation(Base): # link to covfee node / task task_id: Mapped[int] = mapped_column(ForeignKey("nodeinstances.id")) - task: Mapped[TaskInstance] = relationship() + task: Mapped[TaskInstance] = relationship("TaskInstance", backref="annotations") - name: Mapped[str] + category: Mapped[str] + participant: Mapped[str] interface: Mapped[Dict[str, Any]] # json column data_json: Mapped[Optional[Dict[str, Any]]] @@ -116,3 +120,6 @@ class Annotation(Base): updated_at: Mapped[datetime.datetime] = mapped_column( default=datetime.datetime.now, onupdate=datetime.datetime.now ) + + def reset_data(self) -> None: + self.data_json = None diff --git a/covfee/server/templates/annotator_error.html b/covfee/server/templates/annotator_error.html new file mode 100644 index 00000000..b3bcc57b --- /dev/null +++ b/covfee/server/templates/annotator_error.html @@ -0,0 +1,9 @@ + + + covfee + + +

    {{ message }}

    +

    Participant id: {{ id }}

    + + diff --git a/covfee/shared/dataclass.py b/covfee/shared/dataclass.py index ca7a4036..d6602f5b 100644 --- a/covfee/shared/dataclass.py +++ b/covfee/shared/dataclass.py @@ -1,7 +1,11 @@ -from typing import Any, List, Tuple +import os +from typing import Any, List, Optional, Tuple + +from sqlalchemy import select +from sqlalchemy.orm import scoped_session -import covfee.launcher as launcher from covfee.logger import logger +from covfee.server.orm import HITInstance, JourneyInstance from covfee.server.orm import ( HITSpec as OrmHit, ) @@ -28,12 +32,14 @@ class BaseDataclass: class CovfeeTask(BaseDataclass, metaclass=PostInitCaller): + _orm_task: Optional[OrmTask] = None + _player_count: int + def __init__(self): super().__init__() - self._orm_task = None self._player_count = -1 - def orm(self): + def create_orm_task_object(self) -> OrmTask: if self._orm_task is not None: return self._orm_task spec = {k: v for k, v in self.__dict__.items() if not k.startswith("_")} @@ -61,16 +67,49 @@ def __post_init__(self): class Journey(BaseDataclass): - def __init__(self, nodes: List[CovfeeTask] = None, name: str = None): + name: str + global_unique_id: Optional[str] + prolific_study_id: Optional[str] + nodes_players: List[Tuple[CovfeeTask, int]] + + def __init__( + self, + nodes: List[CovfeeTask] = None, + name: str = None, + global_unique_id: Optional[str] = None, + prolific_study_id: Optional[str] = None, + ): + """ + Specifies a journey + + Parameters: + nodes (list[CovfeeTask]): The tasks that a study participant will go through for this journey. + name (str): A human readable name. + global_unique_id (Optional[str]): A globally unique identifier for the Journey. Serves to create persistency + in the database across multiple launches of "covfee make" and therefore, + be able to add more hits/journeys into an existing database. + It is expected to be unique even across different projects and it is + responsibility of the study administrator to define a naming pattern. + prolific_study_id (Optional[str]): Indicates whether this journey will be linked to the given study id + from prolific academic, such that when annotators are assigned journeys + , it will only assign journeys corresponding to the same study id as the + incoming annotator. + """ super().__init__() if nodes is not None: self.nodes_players = [(n, n._count()) for n in nodes] else: - self.nodes_players: List[Tuple[CovfeeTask, int]] = list() + self.nodes_players = [] self.name = name + self.global_unique_id = global_unique_id + self.prolific_study_id = prolific_study_id - def orm(self): - journey = OrmJourney([(n.orm(), p) for n, p in self.nodes_players]) + def create_orm_journey_object(self) -> OrmJourney: + journey = OrmJourney( + [(n.create_orm_task_object(), p) for n, p in self.nodes_players] + ) + journey.global_unique_id = self.global_unique_id + journey.prolific_study_id = self.prolific_study_id logger.debug(f"Created ORM journey: {str(journey)}") return journey @@ -81,54 +120,193 @@ def add_node(self, node: CovfeeTask, player: int = None): class HIT(BaseDataclass): - def __init__(self, name: str, repeat=1, config: Any = None): + name: str + repeat: int + config: Any + journeys: List[Journey] + global_unique_id: Optional[str] + + def __init__( + self, + name: str, + repeat: int = 1, + config: Any = None, + global_unique_id: Optional[str] = None, + ): + """ + Specifies a hit + + Parameters: + name (str): A human readable name. + global_unique_id (Optional[str]): A globally unique identifier for the HIT. Serves to create persistency + in the database across multiple launches of "covfee make" and therefore, + be able to add more hits/journeys into an existing database. + It is expected to be unique even across different projects and it is + responsibility of the study administrator to define a naming pattern. + repeat (int): The number of times the HIT should be repeated. Defaults to 1. + config (Any): The configuration for the HIT. Defaults to None. + """ super().__init__() self.name = name self.journeys = [] self.repeat = repeat + self.global_unique_id = global_unique_id self.config = config - def add_journey(self, nodes=None): - j = Journey(nodes) + def add_journey( + self, + nodes=None, + journey_global_unique_id: Optional[str] = None, + prolific_study_id: Optional[str] = None, + ) -> Journey: + j = Journey( + nodes, + global_unique_id=journey_global_unique_id, + prolific_study_id=prolific_study_id, + ) self.journeys.append(j) return j - def orm(self): - hit = OrmHit(self.name, [j.orm() for j in self.journeys]) + def create_orm_hit_object(self) -> OrmHit: + hit = OrmHit(self.name, [j.create_orm_journey_object() for j in self.journeys]) + hit.global_unique_id = self.global_unique_id logger.debug(f"Created ORM HIT: {str(hit)}") return hit class Project(BaseDataclass): + name: str + email: str + hits: List[HIT] + def __init__(self, name: str, email: str, hits: HIT = None): super().__init__() self.name = name self.email = email self.hits = hits if hits is not None else list() - def orm(self): - project = OrmProject(self.name, self.email, [h.orm() for h in self.hits]) - logger.debug(f"Created ORM Project: {str(project)}") + def create_or_update_orm_specs_data(self, session: scoped_session) -> OrmProject: + project: Optional[OrmProject] = session.execute( + select(OrmProject).filter_by(name=self.name) + ).scalar_one_or_none() + if project is not None: + logger.info( + f"Project {self.name} already exists! Will only accept HITs/Journeys with new global_unique_ids." + ) + project.email = self.email + + for new_hit in self.hits: + if new_hit.global_unique_id is None: + logger.info("HIT missing global_unique_id. Skipping...") + continue + hitspec_in_db: Optional[OrmHit] = session.execute( + select(OrmHit).filter_by(global_unique_id=new_hit.global_unique_id) + ).scalar_one_or_none() + if hitspec_in_db is not None: + logger.info( + f"HIT {new_hit.global_unique_id} already exists. Checking whether to add new journeys." + ) + for journey in new_hit.journeys: + if journey.global_unique_id is None: + logger.info( + "Cant' add new journeys to HIT without a global_unique_id. Skipping..." + ) + continue + journey_in_db = session.execute( + select(OrmJourney).filter_by( + global_unique_id=journey.global_unique_id + ) + ).scalar_one_or_none() + if journey_in_db is not None: + logger.info( + f"Journey {journey.global_unique_id} already exists. Skipping..." + ) + else: + logger.info( + f"Journey {journey.global_unique_id} being created." + ) + journey_orm = journey.create_orm_journey_object() + hitspec_in_db.append_journeyspecs([journey_orm]) + else: + project.hitspecs.append(new_hit.create_orm_hit_object()) + else: + logger.info( + f"Project {self.name} did not exist. Will be created from scratch as is." + ) + project = OrmProject( + self.name, self.email, [h.create_orm_hit_object() for h in self.hits] + ) + logger.debug(f"Created ORM Project: {str(project)}") return project + def create_orm_instances_for_hitspecs_journeyspecs_without_instances( + self, orm_project: OrmProject, session: scoped_session + ) -> OrmProject: + # Note: this implementation will create instances + if os.environ.get("COVFEE_ENV") == "dev": + # In dev mode, instances ids are explicitly set through a ClassVar counter. + # In case the project already exists with prior instances, we need to update + # that counter relying on what is in the database. + # already existed, with previous instances, we + HITInstance.instance_counter = session.query(HITInstance).count() + 1 + JourneyInstance.instance_counter = ( + session.query(JourneyInstance).count() + 1 + ) + + for hitspec in orm_project.hitspecs: + if len(hitspec.instances) == 0: + hitspec.instantiate() + for hit_instance in hitspec.instances: + for node in hit_instance.nodes: + # FIXME - It's unclear why chats are not being added when the + # updated orm_project is added to the session. + session.add(node.chat) + else: + # We check now whether the hit instances are missing journey instances + for hit_instance in hitspec.instances: + # We collect the journeyspecs without their own instances + journeyspecs_without_instances = [ + journey_spec + for journey_spec in hitspec.journeyspecs + if len(journey_spec.journeys) == 0 + ] + if len(journeyspecs_without_instances) > 0: + hit_instance.instantiate_new_journeys( + journeyspecs_without_instances + ) + for node in hit_instance.nodes: + # FIXME - It's unclear why chats are not being added when the + # updated orm_project is added to the session. + session.add(node.chat) + class CovfeeApp(BaseDataclass): - def __init__(self, projects: List[Project]): + _projects_specs: List[Project] + + def __init__(self, projects: List[Project]) -> None: super().__init__() - self.projects = projects - - def get_instantiated_projects(self, num_instances=1): - orm_projects = [] - for p in self.projects: - orm_project = p.orm() - for spec in orm_project.hitspecs: - spec.instantiate(num_instances) - orm_projects.append(orm_project) - return orm_projects - - def launch(self, num_instances=1): - orm_projects = self.get_instantiated_projects(num_instances) - l = launcher.Launcher("dev", orm_projects, folder=None) - l.make_database() - l.launch() + self._projects_specs = projects + + def add_to_database_new_or_updated_projects_specifications_and_instances( + self, session: scoped_session + ) -> List[OrmProject]: + """ + If a session is provided, it is assumed that the related projects could already exist and we + could potentially be committing new specs and their related instances. + """ + for project_specs in self._projects_specs: + # We first create or update the project specs data, meaning the respective + # hitspecs and journeyspecs. If the project is new, it will create data for + # all provided HITs. Otherwise, it will create specs only for new HITs + # or journeys with global_unique_ids. + orm_project = project_specs.create_or_update_orm_specs_data(session) + + # We then create instances for hitspecs and journeyspecs without instances. + # This is equally valid for a newly created project, or the newly created specs + # for an existing project. + project_specs.create_orm_instances_for_hitspecs_journeyspecs_without_instances( + orm_project, session + ) + + session.add(orm_project) diff --git a/covfee/shared/task_dataclasses.py b/covfee/shared/task_dataclasses.py index c36656d2..18036e31 100644 --- a/covfee/shared/task_dataclasses.py +++ b/covfee/shared/task_dataclasses.py @@ -4,9 +4,11 @@ class ContinuousAnnotationTaskSpec(CovfeeTask): type: str = "ContinuousAnnotationTask" annotations: List[Any] - media: Any + media: List[Any] name: str userCanAdd: bool + # When specified: True, means audio on is mandatory, False means audio off (muted) is mandatory. + audioRequirement: bool # Seconds countdown after start condition met. countdown: float # Instructions to be displayed for the node @@ -22,8 +24,10 @@ class ContinuousAnnotationTaskSpec(CovfeeTask): # Node is marked as a prerrequisite # Prerrequisite nodes must be completed before the rests of the nodes in the HIT are revealed. prerequisite: bool + prolificCompletionCode: str # If true, this node must have a valid submission before the HIT can be submitted required: bool + taskVariantPopupBulletPoints: List[str] # Time to complete the task timer: float # Empty timer is started everytime the task is empty (no journeys online) @@ -38,47 +42,53 @@ class ContinuousAnnotationTaskSpec(CovfeeTask): # This applies both to multiple clients in the same journey and across journeys. # Internally covfee uses socketio to synchronize task state. useSharedState: bool + videoTutorialUrl: str # If true, all journeys must click ready to start the task wait_for_ready: bool - def __init__(self, annotations, media, name, userCanAdd, countdown = 0, instructions = None, instructions_type = 'default', max_submissions = 0, n_pause = None, n_start = None, prerequisite = False, required = True, timer = None, timer_empty = None, timer_pausable = None, timer_pause = None, useSharedState = None, wait_for_ready = None): + def __init__(self, annotations, media, name, userCanAdd, audioRequirement = None, countdown = 0, instructions = None, instructions_type = 'default', max_submissions = 0, n_pause = None, n_start = None, prerequisite = False, prolificCompletionCode = None, required = True, taskVariantPopupBulletPoints = None, timer = None, timer_empty = None, timer_pausable = None, timer_pause = None, useSharedState = None, videoTutorialUrl = None, wait_for_ready = None): """ ### Parameters 0. annotations : List[Any] - 1. media : Any + 1. media : List[Any] 2. name : str 3. userCanAdd : bool - 4. countdown : float + 4. audioRequirement : bool + - When specified: True, means audio on is mandatory, False means audio off (muted) is mandatory. + 5. countdown : float - Seconds countdown after start condition met. - 5. instructions : str + 6. instructions : str - Instructions to be displayed for the node - 6. instructions_type : str + 7. instructions_type : str - How the instructions will be displayed - 7. max_submissions : float + 8. max_submissions : float - Maximum number of submissions a user can make for the task. - 8. n_pause : float + 9. n_pause : float - If the number of subjects is n_pause or less, the task will be paused - 9. n_start : float + 10. n_start : float - Number of jorneys required to start task - 10. prerequisite : bool + 11. prerequisite : bool - Node is marked as a prerrequisite Prerrequisite nodes must be completed before the rests of the nodes in the HIT are revealed. - 11. required : bool + 12. prolificCompletionCode : str + 13. required : bool - If true, this node must have a valid submission before the HIT can be submitted - 12. timer : float + 14. taskVariantPopupBulletPoints : List[str] + 15. timer : float - Time to complete the task - 13. timer_empty : float + 16. timer_empty : float - Empty timer is started everytime the task is empty (no journeys online) If the timer reaches zero, the task is set to finished state. - 14. timer_pausable : bool + 17. timer_pausable : bool - If true, the timer will pause when the task is paused. - 15. timer_pause : float + 18. timer_pause : float - Pause timer is started every time the task enters paused state If timer reaches zero, the task is set to finished state. - 16. useSharedState : bool + 19. useSharedState : bool - If true, the task state will be synced between clients. This applies both to multiple clients in the same journey and across journeys. Internally covfee uses socketio to synchronize task state. - 17. wait_for_ready : bool + 20. videoTutorialUrl : str + 21. wait_for_ready : bool - If true, all journeys must click ready to start the task """ @@ -88,6 +98,7 @@ def __init__(self, annotations, media, name, userCanAdd, countdown = 0, instruct self.media = media self.name = name self.userCanAdd = userCanAdd + self.audioRequirement = audioRequirement self.countdown = countdown self.instructions = instructions self.instructions_type = instructions_type @@ -95,12 +106,15 @@ def __init__(self, annotations, media, name, userCanAdd, countdown = 0, instruct self.n_pause = n_pause self.n_start = n_start self.prerequisite = prerequisite + self.prolificCompletionCode = prolificCompletionCode self.required = required + self.taskVariantPopupBulletPoints = taskVariantPopupBulletPoints self.timer = timer self.timer_empty = timer_empty self.timer_pausable = timer_pausable self.timer_pause = timer_pause self.useSharedState = useSharedState + self.videoTutorialUrl = videoTutorialUrl self.wait_for_ready = wait_for_ready diff --git a/docs/README.md b/docs/README.md index 8960fa2a..9e4a7e50 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,7 +4,13 @@ This website is built using [Docusaurus 2](https://v2.docusaurus.io/), a modern ## Installation +Install conda, [miniforge](https://conda-forge.org/download/) is recommended, and then install the dependencies a new environment: + ```console +conda create --name covfee-docs +conda activate covfee-docs +conda install -y -c conda-forge nodejs=18 +conda install -y yarn yarn install ``` diff --git a/docs/docs/development.mdx b/docs/docs/development.mdx index 2ab3ad6b..5a98de9c 100644 --- a/docs/docs/development.mdx +++ b/docs/docs/development.mdx @@ -12,22 +12,20 @@ Covfee runs on **Linux, Mac OS X and Windows**, but you are more likely to encou Covfee's frontend is built using [webpack](https://webpack.js.org/), which has a convenient [hot-reloading development server](https://webpack.js.org/guides/development/). The [Flask server](https://flask.palletsprojects.com/en/2.0.x/) used for the backend also supports reloading on changes. This guide will get you to run backend and frontend development servers: -1. Install the latest version of [node.js](https://nodejs.org/en/download/). Make sure that the `npm` command is available in your terminal. - -:::tip -**We strongly recommend that you install covfee in a Python virtual environment**. To create and activate one using _venv_: +1. Install [node.js](https://nodejs.org/en/download/) version 12 and [yarn](https://yarnpkg.com/). +[Miniforge](https://conda-forge.org/download/) is the recommended way to install node.js and yarn. ``` -python3 -m venv ./env -source ./env/bin/activate +conda create --name covfee python=3.9 +conda activate covfee +conda install -c conda-forge nodejs=12 +conda install yarn ``` -::: - 2. Install the covfee Python package in editable mode: ``` -git clone git@github.com:josedvq/covfee.git +git clone git@github.com:TUDelft-SPC-Lab/covfee.git cd covfee python3 -m pip install -e . ``` @@ -37,7 +35,7 @@ The `covfee` command should now be available in the terminal. Type `covfee --hel 3. Install Javascript dependencies: ``` -yarn install +yarn install --ignore-engines ``` 4. Generate schemata (for validation) and build webpack bundles: diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index d46f61f1..dd6ca213 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -11,7 +11,7 @@ module.exports = { onBrokenLinks: "throw", onBrokenMarkdownLinks: "warn", favicon: "img/favicon.ico", - organizationName: "josedvq", // Usually your GitHub org/user name. + organizationName: "TUDelft-SPC-Lab", // Usually your GitHub org/user name. projectName: "covfee", // Usually your repo name. scripts: [], stylesheets: [], @@ -31,7 +31,7 @@ module.exports = { announcementBar: { id: "support_us", content: - '⭐ If you like covfee, give it a star on GitHub!', + '⭐ If you like covfee, give it a star on GitHub!', backgroundColor: "#fafbfc", textColor: "#091E42", isCloseable: true, @@ -50,7 +50,7 @@ module.exports = { position: "left", }, { - href: "https://github.com/josedvq/covfee", + href: "https://github.com/TUDelft-SPC-Lab/covfee", label: "GitHub", position: "right", }, @@ -71,10 +71,10 @@ module.exports = { { title: "Community", items: [ - { - label: "Twitter", - href: "https://twitter.com/josedvq", - }, + // { + // label: "Twitter", + // href: "https://twitter.com/josedvq", + // }, ], }, { @@ -82,7 +82,7 @@ module.exports = { items: [ { label: "GitHub", - href: "https://github.com/josedvq/covfee", + href: "https://github.com/TUDelft-SPC-Lab/covfee", }, ], }, diff --git a/docs/package.json b/docs/package.json index 6533899c..6ecbc76a 100644 --- a/docs/package.json +++ b/docs/package.json @@ -14,20 +14,20 @@ "write-heading-ids": "docusaurus write-heading-ids" }, "dependencies": { - "react": "^18.2.0", - "react-dom": "^18.2.0", "@docusaurus/core": "^3.2.0", "@docusaurus/preset-classic": "^3.2.0", "@mdx-js/react": "^3.0.0", - "prism-react-renderer": "^2.1.0", + "babel-plugin-inline-json-import": "^0.3.2", "babel-plugin-module-resolver": "^4.1.0", "babel-plugin-transform-class-properties": "^6.24.1", - "babel-plugin-inline-json-import": "^0.3.2" + "prism-react-renderer": "^2.1.0", + "react": "^18.2.0", + "react-dom": "^18.2.0" }, "devDependencies": { "@docusaurus/module-type-aliases": "3.0.0", - "@docusaurus/types": "3.0.0", "@docusaurus/tsconfig": "3.0.0", + "@docusaurus/types": "3.0.0", "@types/react": "^18.2.29", "typescript": "~5.2.2" }, diff --git a/samples/annotation/annotation.py b/samples/annotation/annotation.py deleted file mode 100644 index 58d844d0..00000000 --- a/samples/annotation/annotation.py +++ /dev/null @@ -1,21 +0,0 @@ -from covfee import HIT, Project, tasks -from covfee.config import config -from covfee.shared.dataclass import CovfeeApp - -config.load_environment("local") - -my_task_1 = tasks.ContinuousAnnotationTaskSpec( - name="My Task 1", - annotations=[{"name": "Arousal", "interface": "RankTrace"}], - media={ - "type": "video", - "url": "https://file-examples.com/storage/fec71f2ebe65d8e339e8b9c/2017/04/file_example_MP4_640_3MG.mp4", - }, - userCanAdd=False, -) - -hit = HIT("Joint counter") -j1 = hit.add_journey(nodes=[my_task_1]) - -projects = [Project("My Project", email="example@example.com", hits=[hit])] -app = CovfeeApp(projects) # we must always create an app object of class CovfeeApp diff --git a/setup.py b/setup.py index 13c974b5..49340a06 100644 --- a/setup.py +++ b/setup.py @@ -1,10 +1,14 @@ from setuptools import find_packages, setup +import sys import versioneer with open("README.md", "r") as fh: long_description = fh.read() +if not (sys.version_info >= (3, 8) and sys.version_info < (3, 11)): + sys.exit("Python version must be >=3.8 and < 3.11") + setup( name="covfee", # using versioneer for versioning using git tags @@ -53,5 +57,10 @@ "pyparsing == 3.1.1", "json-ref-dict == 0.7.2", ], + extras_require={ + 'dev': [ + 'gevent == 23.9.1', + ] + }, python_requires=">=3.6", )