diff --git a/.github/workflows/actions.yml b/.github/workflows/actions.yml index 4609eb697..e3e24b043 100644 --- a/.github/workflows/actions.yml +++ b/.github/workflows/actions.yml @@ -42,15 +42,28 @@ jobs: exit 1; } + - name: Decrypt Service Account Key File + working-directory: ./ + run: | + openssl enc -aes-256-cbc -d -K "$OPENSSL_KEY" -iv "$OPENSSL_IV" -in ci-mapswipe-firebase-adminsdk-80fzw-ebce84bd5b.json.enc -out mapswipe_workers/serviceAccountKey.json + env: + OPENSSL_PASSPHRASE: ${{ secrets.OPENSSL_PASSPHRASE }} + OPENSSL_KEY: ${{ secrets.OPENSSL_KEY }} + OPENSSL_IV: ${{ secrets.OPENSSL_IV }} + + - name: Build docker images + run: | + # Create a mock file for wal-g setup + touch postgres/serviceAccountKey.json + docker compose build postgres firebase_deploy mapswipe_workers_creation django + - name: Setup Postgres Database Container env: POSTGRES_PASSWORD: postgres POSTGRES_USER: postgres POSTGRES_DB: postgres run: | - # Create a mock file for wal-g setup - touch postgres/serviceAccountKey.json - docker compose up --build --detach postgres + docker compose up --detach postgres for i in {1..5}; do docker compose exec -T postgres pg_isready && s=0 && break || s=$? && sleep 5; done; (docker compose logs postgres && exit $s) - name: Deploy Firebase Rules and Functions @@ -60,15 +73,6 @@ jobs: run: | docker compose run --rm firebase_deploy sh -c "firebase use $FIREBASE_DB && firebase deploy --token $FIREBASE_TOKEN --only database" - - name: Decrypt Service Account Key File - working-directory: ./ - run: | - openssl enc -aes-256-cbc -d -K "$OPENSSL_KEY" -iv "$OPENSSL_IV" -in ci-mapswipe-firebase-adminsdk-80fzw-ebce84bd5b.json.enc -out mapswipe_workers/serviceAccountKey.json - env: - OPENSSL_PASSPHRASE: ${{ secrets.OPENSSL_PASSPHRASE }} - OPENSSL_KEY: ${{ secrets.OPENSSL_KEY }} - OPENSSL_IV: ${{ secrets.OPENSSL_IV }} - - name: Run Tests working-directory: ./mapswipe_workers env: diff --git a/community-dashboard/app/resources/icons/validate-image.svg b/community-dashboard/app/resources/icons/validate-image.svg new file mode 100644 index 000000000..7066c5c2b --- /dev/null +++ b/community-dashboard/app/resources/icons/validate-image.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/community-dashboard/app/views/StatsBoard/index.tsx b/community-dashboard/app/views/StatsBoard/index.tsx index f3b842997..d04b93212 100644 --- a/community-dashboard/app/views/StatsBoard/index.tsx +++ b/community-dashboard/app/views/StatsBoard/index.tsx @@ -43,12 +43,14 @@ import InformationCard from '#components/InformationCard'; import areaSvg from '#resources/icons/area.svg'; import sceneSvg from '#resources/icons/scene.svg'; import featureSvg from '#resources/icons/feature.svg'; +import validateImageSvg from '#resources/icons/validate-image.svg'; import { ContributorTimeStatType, OrganizationSwipeStatsType, ProjectTypeSwipeStatsType, ProjectTypeAreaStatsType, ContributorSwipeStatType, + ProjectTypeEnum, } from '#generated/types'; import { mergeItems } from '#utils/common'; import { @@ -67,17 +69,28 @@ const CHART_BREAKPOINT = 700; export type ActualContributorTimeStatType = ContributorTimeStatType & { totalSwipeTime: number }; const UNKNOWN = '-1'; const BUILD_AREA = 'BUILD_AREA'; +const MEDIA = 'MEDIA'; +const DIGITIZATION = 'DIGITIZATION'; const FOOTPRINT = 'FOOTPRINT'; const CHANGE_DETECTION = 'CHANGE_DETECTION'; +const VALIDATE_IMAGE = 'VALIDATE_IMAGE'; const COMPLETENESS = 'COMPLETENESS'; const STREET = 'STREET'; // FIXME: the name property is not used properly -const projectTypes: Record = { +const projectTypes: Record = { [UNKNOWN]: { color: '#cacaca', name: 'Unknown', }, + [MEDIA]: { + color: '#cacaca', + name: 'Media', + }, + [DIGITIZATION]: { + color: '#cacaca', + name: 'Digitization', + }, [BUILD_AREA]: { color: '#f8a769', name: 'Find', @@ -94,6 +107,10 @@ const projectTypes: Record = { color: '#fb8072', name: 'Completeness', }, + [VALIDATE_IMAGE]: { + color: '#a1b963', + name: 'Validate Image', + }, [STREET]: { color: '#808080', name: 'Street', @@ -376,14 +393,16 @@ function StatsBoard(props: Props) { const sortedProjectSwipeType = useMemo( () => ( swipeByProjectType - ?.map((item) => ({ - ...item, - projectType: ( - isDefined(item.projectType) - && isDefined(projectTypes[item.projectType]) - ) ? item.projectType - : UNKNOWN, - })) + ?.map((item) => { + const projectType: ProjectTypeEnum | '-1' = ( + isDefined(item.projectType) && isDefined(projectTypes[item.projectType]) + ) ? item.projectType : UNKNOWN; + + return ({ + ...item, + projectType, + }); + }) .sort((a, b) => compareNumber(a.totalSwipes, b.totalSwipes, -1)) ?? [] ), [swipeByProjectType], @@ -449,6 +468,10 @@ function StatsBoard(props: Props) { (project) => project.projectType === FOOTPRINT, )?.totalSwipes; + const validateImageTotalSwipes = swipeByProjectType?.find( + (project) => project.projectType === VALIDATE_IMAGE, + )?.totalSwipes; + const organizationColors = scaleOrdinal() .domain(totalSwipesByOrganizationStats?.map( (organization) => (organization.organizationName), @@ -699,6 +722,29 @@ function StatsBoard(props: Props) { subHeading="Compare" variant="stat" /> + + )} + value={( + + )} + label={( +
+ Images Validated +
+ )} + subHeading="Validate Image" + variant="stat" + />
* { flex-basis: 0; flex-grow: 1; - min-width: 12rem; + min-width: 24rem; @media (max-width: 48rem) { min-width: 100%; diff --git a/community-dashboard/docker-compose.yml b/community-dashboard/docker-compose.yml index 39ac61dcc..2b548f3bb 100644 --- a/community-dashboard/docker-compose.yml +++ b/community-dashboard/docker-compose.yml @@ -2,7 +2,6 @@ version: '3.3' services: react: - build: . command: sh -c 'yarn install --frozen-lockfile && yarn start' build: context: ./ @@ -15,4 +14,4 @@ services: volumes: - .:/code ports: - - '3080:3080' + - '3081:3081' diff --git a/django/Dockerfile b/django/Dockerfile index 4f220f7e6..a6330b600 100644 --- a/django/Dockerfile +++ b/django/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.10-buster +FROM python:3.10-bullseye LABEL maintainer="Mapswipe info@mapswipe.org" diff --git a/django/apps/aggregated/management/commands/update_aggregated_data.py b/django/apps/aggregated/management/commands/update_aggregated_data.py index dca5896a4..49f536227 100644 --- a/django/apps/aggregated/management/commands/update_aggregated_data.py +++ b/django/apps/aggregated/management/commands/update_aggregated_data.py @@ -55,6 +55,7 @@ WHEN P.project_type = {Project.Type.CHANGE_DETECTION.value} THEN 11.2 -- FOOTPRINT: Not calculated right now WHEN P.project_type = {Project.Type.FOOTPRINT.value} THEN 6.1 + WHEN P.project_type = {Project.Type.VALIDATE_IMAGE.value} THEN 6.1 WHEN P.project_type = {Project.Type.STREET.value} THEN 65 ELSE 1 END @@ -111,6 +112,7 @@ WHEN P.project_type = {Project.Type.CHANGE_DETECTION.value} THEN 11.2 -- FOOTPRINT: Not calculated right now WHEN P.project_type = {Project.Type.FOOTPRINT.value} THEN 6.1 + WHEN P.project_type = {Project.Type.VALIDATE_IMAGE.value} THEN 6.1 WHEN P.project_type = {Project.Type.STREET.value} THEN 65 ELSE 1 END @@ -136,8 +138,10 @@ G.group_id, ( CASE - -- Hide area for Footprint + -- Hide area for Footprint and Validate Image + -- FIXME: What should we do for Project.Type.STREET.value WHEN P.project_type = {Project.Type.FOOTPRINT.value} THEN 0 + WHEN P.project_type = {Project.Type.VALIDATE_IMAGE.value} THEN 0 ELSE G.total_area END ) as total_task_group_area, diff --git a/django/apps/existing_database/models.py b/django/apps/existing_database/models.py index 5bc85e113..319c28b7c 100644 --- a/django/apps/existing_database/models.py +++ b/django/apps/existing_database/models.py @@ -69,6 +69,7 @@ class Type(models.IntegerChoices): MEDIA = 5, "Media" DIGITIZATION = 6, "Digitization" STREET = 7, "Street" + VALIDATE_IMAGE = 10, "Validate Image" project_id = models.CharField(primary_key=True, max_length=999) created = models.DateTimeField(blank=True, null=True) diff --git a/django/schema.graphql b/django/schema.graphql index b5596fc46..07b9659c4 100644 --- a/django/schema.graphql +++ b/django/schema.graphql @@ -100,6 +100,7 @@ enum ProjectTypeEnum { MEDIA DIGITIZATION STREET + VALIDATE_IMAGE } type ProjectTypeSwipeStatsType { diff --git a/docker-compose.tc.yaml b/docker-compose.tc.yaml index 210c09570..62475284e 100644 --- a/docker-compose.tc.yaml +++ b/docker-compose.tc.yaml @@ -33,6 +33,7 @@ x-mapswipe-workers: &base_mapswipe_workers SLACK_CHANNEL: '${SLACK_CHANNEL}' SENTRY_DSN: '${SENTRY_DSN}' OSMCHA_API_KEY: '${OSMCHA_API_KEY}' + MAPILLARY_API_KEY: '${MAPILLARY_API_KEY}' depends_on: - postgres volumes: diff --git a/docker-compose.yaml b/docker-compose.yaml index d465a704c..f015a71a4 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -230,15 +230,23 @@ services: OSM_OAUTH_API_URL: '${OSM_OAUTH_API_URL}' OSM_OAUTH_CLIENT_ID: '${OSM_OAUTH_CLIENT_ID}' OSM_OAUTH_CLIENT_SECRET: '${OSM_OAUTH_CLIENT_SECRET}' + OSM_OAUTH_REDIRECT_URI_WEB: '${OSM_OAUTH_REDIRECT_URI_WEB}' + OSM_OAUTH_APP_LOGIN_LINK_WEB: '${OSM_OAUTH_APP_LOGIN_LINK_WEB}' + OSM_OAUTH_CLIENT_ID_WEB: '${OSM_OAUTH_CLIENT_ID_WEB}' + OSM_OAUTH_CLIENT_SECRET_WEB: '${OSM_OAUTH_CLIENT_SECRET_WEB}' command: >- sh -c "firebase use $FIREBASE_DB && firebase target:apply hosting auth \"$FIREBASE_AUTH_SITE\" && firebase functions:config:set osm.redirect_uri=\"$OSM_OAUTH_REDIRECT_URI\" + osm.redirect_uri_web=\"$OSM_OAUTH_REDIRECT_URI_WEB\" osm.app_login_link=\"$OSM_OAUTH_APP_LOGIN_LINK\" + osm.app_login_link_web=\"$OSM_OAUTH_APP_LOGIN_LINK_WEB\" osm.api_url=\"$OSM_OAUTH_API_URL\" osm.client_id=\"$OSM_OAUTH_CLIENT_ID\" - osm.client_secret=\"$OSM_OAUTH_CLIENT_SECRET\" && + osm.client_id_web=\"$OSM_OAUTH_CLIENT_ID_WEB\" + osm.client_secret=\"$OSM_OAUTH_CLIENT_SECRET\" + osm.client_secret_web=\"$OSM_OAUTH_CLIENT_SECRET_WEB\" && firebase deploy --token $FIREBASE_TOKEN --only functions,hosting,database" django: diff --git a/example.env b/example.env index 2fa92ae33..a4ee41436 100644 --- a/example.env +++ b/example.env @@ -38,10 +38,15 @@ OSMCHA_API_KEY= # OSM OAuth Configuration OSM_OAUTH_REDIRECT_URI= +OSM_OAUTH_REDIRECT_URI_WEB= OSM_OAUTH_API_URL= OSM_OAUTH_CLIENT_ID= +OSM_OAUTH_CLIENT_ID_WEB= OSM_OAUTH_CLIENT_SECRET= -OSM_APP_LOGIN_LINK= +OSM_OAUTH_CLIENT_SECRET_WEB= +OSM_OAUTH_APP_LOGIN_LINK= +OSM_OAUTH_APP_LOGIN_LINK_WEB= + # DJANGO For more info look at django/mapswipe/settings.py::L22 DJANGO_SECRET_KEY= diff --git a/firebase/README.md b/firebase/README.md index fed47268d..c3381abc9 100644 --- a/firebase/README.md +++ b/firebase/README.md @@ -20,6 +20,11 @@ expose the authentication functions publicly. * `firebase deploy --only functions,hosting` * `firebase deploy --only database:rules` +## Deploy with Makefile +You can also deploy the changes to Firebase using make: +* Make sure to remove the firebase_deploy docker image first: `docker rmi python-mapswipe-workers-firebase_deploy` +* `make update_firebase_functions_and_db_rules` + ## Notes on OAuth (OSM login) Refer to [the notes in the app repository](https://github.com/mapswipe/mapswipe/blob/master/docs/osm_login.md). @@ -30,12 +35,16 @@ Some specifics about the related functions: - Before deploying, set the required firebase config values in environment: FIXME: replace env vars with config value names - OSM_OAUTH_REDIRECT_URI `osm.redirect_uri`: `https://dev-auth.mapswipe.org/token` or `https://auth.mapswipe.org/token` + - OSM_OAUTH_REDIRECT_URI_WEB: `https://dev-auth.mapswipe.org/tokenweb` or `https://auth.mapswipe.org/tokenweb` - OSM_OAUTH_APP_LOGIN_LINK `osm.app_login_link`: 'devmapswipe://login/osm' or 'mapswipe://login/osm' + - OSM_OAUTH_APP_LOGIN_LINK_WEB: `https://web.mapswipe.org/dev/#/osm-callback` or `https://web.mapswipe.org/#/osm-callback` - OSM_OAUTH_API_URL `osm.api_url`: 'https://master.apis.dev.openstreetmap.org/' or 'https://www.openstreetmap.org/' (include the trailing slash) - OSM_OAUTH_CLIENT_ID `osm.client_id`: find it on the OSM application page - OSM_OAUTH_CLIENT_SECRET `osm.client_secret`: same as above. Note that this can only be seen once when the application is created. Do not lose it! + - OSM_OAUTH_CLIENT_ID_WEB: This is the ID of a __different__ registered OSM OAuth client for the web version that needs to have `https://dev-auth.mapswipe.org/tokenweb` or `https://auth.mapswipe.org/tokenweb` set as redirect URI. + - OSM_OAUTH_CLIENT_SECRET_WEB: This is the secret of the OSM OAuth client for MapSwipe web version. - Deploy the functions as explained above - Expose the functions publicly through firebase hosting, this is done in `/firebase/firebase.json` under the `hosting` key. diff --git a/firebase/firebase.json b/firebase/firebase.json index 4c56a3044..b81c02219 100644 --- a/firebase/firebase.json +++ b/firebase/firebase.json @@ -20,6 +20,14 @@ { "source": "/token", "function": "osmAuth-token" + }, + { + "source": "/redirectweb", + "function": "osmAuth-redirectweb" + }, + { + "source": "/tokenweb", + "function": "osmAuth-tokenweb" } ] }, diff --git a/firebase/functions/src/index.ts b/firebase/functions/src/index.ts index 793ed9c71..c448cadc2 100644 --- a/firebase/functions/src/index.ts +++ b/firebase/functions/src/index.ts @@ -8,7 +8,7 @@ admin.initializeApp(); // all functions are bundled together. It's less than ideal, but it does not // seem possible to split them using the split system for multiple sites from // https://firebase.google.com/docs/hosting/multisites -import {redirect, token} from './osm_auth'; +import {redirect, token, redirectweb, tokenweb} from './osm_auth'; import { formatProjectTopic, formatUserName } from './utils'; exports.osmAuth = {}; @@ -23,6 +23,14 @@ exports.osmAuth.token = functions.https.onRequest((req, res) => { token(req, res, admin); }); +exports.osmAuth.redirectweb = functions.https.onRequest((req, res) => { + redirectweb(req, res); +}); + +exports.osmAuth.tokenweb = functions.https.onRequest((req, res) => { + tokenweb(req, res, admin); +}); + /* Log the userIds of all users who finished a group to /v2/userGroups/{projectId}/{groupId}/. Gets triggered when new results of a group are written to the database. diff --git a/firebase/functions/src/osm_auth.ts b/firebase/functions/src/osm_auth.ts index d187b4e4f..9953f2ea9 100644 --- a/firebase/functions/src/osm_auth.ts +++ b/firebase/functions/src/osm_auth.ts @@ -1,4 +1,4 @@ -// Firebase cloud functions to allow authentication with OpenStreet Map +// Firebase cloud functions to allow authentication with OpenStreetMap // // There are really 2 functions, which must be publicly accessible via // an https endpoint. They can be hosted on firebase under a domain like @@ -20,8 +20,10 @@ import axios from 'axios'; // will get a cryptic error about the server not being able to continue // TODO: adjust the prefix based on which deployment is done (prod/dev) const OAUTH_REDIRECT_URI = functions.config().osm?.redirect_uri; +const OAUTH_REDIRECT_URI_WEB = functions.config().osm?.redirect_uri_web; const APP_OSM_LOGIN_DEEPLINK = functions.config().osm?.app_login_link; +const APP_OSM_LOGIN_DEEPLINK_WEB = functions.config().osm?.app_login_link_web; // the scope is taken from https://wiki.openstreetmap.org/wiki/OAuth#OAuth_2.0 // at least one seems to be required for the auth workflow to complete. @@ -36,11 +38,11 @@ const OSM_API_URL = functions.config().osm?.api_url; * Configure the `osm.client_id` and `osm.client_secret` * Google Cloud environment variables for the values below to exist */ -function osmOAuth2Client() { +function osmOAuth2Client(client_id: any, client_secret: any) { const credentials = { client: { - id: functions.config().osm?.client_id, - secret: functions.config().osm?.client_secret, + id: client_id, + secret: client_secret, }, auth: { tokenHost: OSM_API_URL, @@ -58,8 +60,8 @@ function osmOAuth2Client() { * NOT a webview inside MapSwipe, as this would break the promise of * OAuth that we do not touch their OSM credentials */ -export const redirect = (req: any, res: any) => { - const oauth2 = osmOAuth2Client(); +function redirect2OsmOauth(req: any, res: any, redirect_uri: string, client_id: string, client_secret: string) { + const oauth2 = osmOAuth2Client(client_id, client_secret); cookieParser()(req, res, () => { const state = @@ -75,17 +77,31 @@ export const redirect = (req: any, res: any) => { httpOnly: true, }); const redirectUri = oauth2.authorizationCode.authorizeURL({ - redirect_uri: OAUTH_REDIRECT_URI, + redirect_uri: redirect_uri, scope: OAUTH_SCOPES, state: state, }); functions.logger.log('Redirecting to:', redirectUri); res.redirect(redirectUri); }); +} + +export const redirect = (req: any, res: any) => { + const redirect_uri = OAUTH_REDIRECT_URI; + const client_id = functions.config().osm?.client_id; + const client_secret = functions.config().osm?.client_secret; + redirect2OsmOauth(req, res, redirect_uri, client_id, client_secret); +}; + +export const redirectweb = (req: any, res: any) => { + const redirect_uri = OAUTH_REDIRECT_URI_WEB; + const client_id = functions.config().osm?.client_id_web; + const client_secret = functions.config().osm?.client_secret_web; + redirect2OsmOauth(req, res, redirect_uri, client_id, client_secret); }; /** - * The OSM OAuth endpoing does not give us any info about the user, + * The OSM OAuth endpoint does not give us any info about the user, * so we need to get the user profile from this endpoint */ async function getOSMProfile(accessToken: string) { @@ -107,8 +123,8 @@ async function getOSMProfile(accessToken: string) { * The Firebase custom auth token, display name, photo URL and OSM access * token are sent back to the app via a deeplink redirect. */ -export const token = async (req: any, res: any, admin: any) => { - const oauth2 = osmOAuth2Client(); +function fbToken(req: any, res: any, admin: any, redirect_uri: string, osm_login_link: string, client_id: string, client_web: string) { + const oauth2 = osmOAuth2Client(client_id, client_web); try { return cookieParser()(req, res, async () => { @@ -139,7 +155,7 @@ export const token = async (req: any, res: any, admin: any) => { // this doesn't work results = await oauth2.authorizationCode.getToken({ code: req.query.code, - redirect_uri: OAUTH_REDIRECT_URI, + redirect_uri: redirect_uri, scope: OAUTH_SCOPES, state: req.query.state, }); @@ -177,7 +193,7 @@ export const token = async (req: any, res: any, admin: any) => { ); // build a deep link so we can send the token back to the app // from the browser - const signinUrl = `${APP_OSM_LOGIN_DEEPLINK}?token=${firebaseToken}`; + const signinUrl = `${osm_login_link}?token=${firebaseToken}`; functions.logger.log('redirecting user to', signinUrl); res.redirect(signinUrl); }); @@ -187,6 +203,22 @@ export const token = async (req: any, res: any, admin: any) => { // back into the app to allow the user to take action return res.json({ error: error.toString() }); } +} + +export const token = async (req: any, res: any, admin: any) => { + const redirect_uri = OAUTH_REDIRECT_URI; + const osm_login_link = APP_OSM_LOGIN_DEEPLINK; + const client_id = functions.config().osm?.client_id; + const client_secret = functions.config().osm?.client_secret; + fbToken(req, res, admin, redirect_uri, osm_login_link, client_id, client_secret); +}; + +export const tokenweb = async (req: any, res: any, admin: any) => { + const redirect_uri = OAUTH_REDIRECT_URI_WEB; + const osm_login_link = APP_OSM_LOGIN_DEEPLINK_WEB; + const client_id = functions.config().osm?.client_id_web; + const client_secret = functions.config().osm?.client_secret_web; + fbToken(req, res, admin, redirect_uri, osm_login_link, client_id, client_secret); }; /** @@ -204,23 +236,18 @@ async function createFirebaseAccount(admin: any, osmID: any, displayName: any, a // with a variable length. const uid = `osm:${osmID}`; + const profileRef = admin.database().ref(`v2/users/${uid}`); + + // check if profile exists on Firebase Realtime Database + const snapshot = await profileRef.once('value'); + const profileExists = snapshot.exists(); + // Save the access token to the Firebase Realtime Database. const databaseTask = admin .database() .ref(`v2/OSMAccessToken/${uid}`) .set(accessToken); - const profileTask = admin - .database() - .ref(`v2/users/${uid}/`) - .set({ - created: new Date().toISOString(), - groupContributionCount: 0, - projectContributionCount: 0, - taskContributionCount: 0, - displayName, - }); - // Create or update the firebase user account. // This does not login the user on the app, it just ensures that a firebase // user account (linked to the OSM account) exists. @@ -240,8 +267,27 @@ async function createFirebaseAccount(admin: any, osmID: any, displayName: any, a throw error; }); + // If profile exists, only update displayName -- else create new user profile + const tasks = [userCreationTask, databaseTask]; + if (profileExists) { + functions.logger.log('Sign in to existing OSM profile'); + const profileUpdateTask = profileRef.update({ displayName: displayName }); + tasks.push(profileUpdateTask); + } else { + functions.logger.log('Sign up new OSM profile'); + const profileCreationTask = profileRef + .set({ + created: new Date().toISOString(), + groupContributionCount: 0, + projectContributionCount: 0, + taskContributionCount: 0, + displayName, + }); + tasks.push(profileCreationTask); + } + // Wait for all async task to complete then generate and return a custom auth token. - await Promise.all([userCreationTask, databaseTask, profileTask]); + await Promise.all(tasks); // Create a Firebase custom auth token. functions.logger.log('In createFirebaseAccount: createCustomToken'); let authToken; diff --git a/manager-dashboard/app/Base/configs/projectTypes.ts b/manager-dashboard/app/Base/configs/projectTypes.ts index e2f7f74eb..e6344d507 100644 --- a/manager-dashboard/app/Base/configs/projectTypes.ts +++ b/manager-dashboard/app/Base/configs/projectTypes.ts @@ -5,6 +5,7 @@ import { PROJECT_TYPE_CHANGE_DETECTION, PROJECT_TYPE_STREET, PROJECT_TYPE_COMPLETENESS, + PROJECT_TYPE_VALIDATE_IMAGE, } from '#utils/common'; const PROJECT_CONFIG_NAME = process.env.REACT_APP_PROJECT_CONFIG_NAME as string; @@ -15,6 +16,7 @@ const mapswipeProjectTypeOptions: { }[] = [ { value: PROJECT_TYPE_BUILD_AREA, label: 'Find' }, { value: PROJECT_TYPE_FOOTPRINT, label: 'Validate' }, + { value: PROJECT_TYPE_VALIDATE_IMAGE, label: 'Validate Image' }, { value: PROJECT_TYPE_CHANGE_DETECTION, label: 'Compare' }, { value: PROJECT_TYPE_STREET, label: 'Street' }, { value: PROJECT_TYPE_COMPLETENESS, label: 'Completeness' }, diff --git a/manager-dashboard/app/Base/styles.css b/manager-dashboard/app/Base/styles.css index c746dc570..87052b68a 100644 --- a/manager-dashboard/app/Base/styles.css +++ b/manager-dashboard/app/Base/styles.css @@ -105,6 +105,7 @@ p { --height-mobile-preview-builarea-content: 30rem; --height-mobile-preview-footprint-content: 22rem; --height-mobile-preview-change-detection-content: 14rem; + --height-mobile-preview-validate-image-content: 22rem; --radius-popup-border: 0.25rem; --radius-scrollbar-border: 0.25rem; diff --git a/manager-dashboard/app/components/Calendar/CalendarDate/index.tsx b/manager-dashboard/app/components/Calendar/CalendarDate/index.tsx index 2b1fed1a0..2520a21f1 100644 --- a/manager-dashboard/app/components/Calendar/CalendarDate/index.tsx +++ b/manager-dashboard/app/components/Calendar/CalendarDate/index.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { _cs } from '@togglecorp/fujs'; import RawButton, { Props as RawButtonProps } from '../../RawButton'; -import { ymdToDateString, typedMemo } from '../../../utils/common.tsx'; +import { ymdToDateString, typedMemo } from '../../../utils/common'; import styles from './styles.css'; diff --git a/manager-dashboard/app/components/Calendar/index.tsx b/manager-dashboard/app/components/Calendar/index.tsx index d72f9054d..21bd7de4f 100644 --- a/manager-dashboard/app/components/Calendar/index.tsx +++ b/manager-dashboard/app/components/Calendar/index.tsx @@ -15,7 +15,7 @@ import Button from '../Button'; import NumberInput from '../NumberInput'; import SelectInput from '../SelectInput'; import useInputState from '../../hooks/useInputState'; -import { typedMemo } from '../../utils/common.tsx'; +import { typedMemo } from '../../utils/common'; import CalendarDate, { Props as CalendarDateProps } from './CalendarDate'; diff --git a/manager-dashboard/app/components/CocoFileInput/index.tsx b/manager-dashboard/app/components/CocoFileInput/index.tsx new file mode 100644 index 000000000..125e0c592 --- /dev/null +++ b/manager-dashboard/app/components/CocoFileInput/index.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import * as t from 'io-ts'; +import { isRight } from 'fp-ts/Either'; + +import JsonFileInput, { Props as JsonFileInputProps } from '#components/JsonFileInput'; + +const Image = t.type({ + id: t.number, + // width: t.number, + // height: t.number, + file_name: t.string, + // license: t.union([t.number, t.undefined]), + flickr_url: t.union([t.string, t.undefined]), + coco_url: t.union([t.string, t.undefined]), + // date_captured: DateFromISOString, +}); + +const CocoDataset = t.type({ + // info: Info, + // licenses: t.array(License), + images: t.array(Image), + // annotations: t.array(Annotation), + // categories: t.array(Category) +}); +export type CocoDatasetType = t.TypeOf + +interface Props extends Omit, 'onChange' | 'value'> { + value: CocoDatasetType | undefined; + maxLength: number; + onChange: (newValue: CocoDatasetType | undefined, name: N) => void; +} +function CocoFileInput(props: Props) { + const { + name, + onChange, + error, + maxLength, + ...otherProps + } = props; + + const [ + internalErrorMessage, + setInternalErrorMessage, + ] = React.useState(); + + const handleChange = React.useCallback( + (val) => { + const result = CocoDataset.decode(val); + if (!isRight(result)) { + // eslint-disable-next-line no-console + console.error('Invalid COCO format', result.left); + setInternalErrorMessage('Invalid COCO format'); + return; + } + if (result.right.images.length > maxLength) { + setInternalErrorMessage(`Too many images ${result.right.images.length} uploaded. Please do not exceed ${maxLength} images.`); + return; + } + const uniqueIdentifiers = new Set(result.right.images.map((item) => item.id)); + if (uniqueIdentifiers.size < result.right.images.length) { + setInternalErrorMessage('Each image should have a unique id.'); + return; + } + setInternalErrorMessage(undefined); + onChange(result.right, name); + }, + [onChange, maxLength, name], + ); + + return ( + + ); +} + +export default CocoFileInput; diff --git a/manager-dashboard/app/components/DateRangeInput/index.tsx b/manager-dashboard/app/components/DateRangeInput/index.tsx index 6442fc835..0b25782ee 100644 --- a/manager-dashboard/app/components/DateRangeInput/index.tsx +++ b/manager-dashboard/app/components/DateRangeInput/index.tsx @@ -19,7 +19,7 @@ import Button from '../Button'; import Popup from '../Popup'; import Calendar, { Props as CalendarProps } from '../Calendar'; import CalendarDate, { Props as CalendarDateProps } from '../Calendar/CalendarDate'; -import { ymdToDateString, dateStringToDate } from '../../utils/common.tsx'; +import { ymdToDateString, dateStringToDate } from '../../utils/common'; import { predefinedDateRangeOptions, diff --git a/manager-dashboard/app/components/InputSection/styles.css b/manager-dashboard/app/components/InputSection/styles.css index 0c0012c77..f729ff036 100644 --- a/manager-dashboard/app/components/InputSection/styles.css +++ b/manager-dashboard/app/components/InputSection/styles.css @@ -24,7 +24,7 @@ display: flex; flex-direction: column; border-radius: var(--radius-card-border); - gap: var(--spacing-extra-large); + gap: var(--spacing-large); background-color: var(--color-foreground); padding: var(--spacing-large); min-height: 14rem; diff --git a/manager-dashboard/app/components/JsonFileInput/index.tsx b/manager-dashboard/app/components/JsonFileInput/index.tsx index bda27a599..023abde95 100644 --- a/manager-dashboard/app/components/JsonFileInput/index.tsx +++ b/manager-dashboard/app/components/JsonFileInput/index.tsx @@ -23,7 +23,7 @@ function readUploadedFileAsText(inputFile: File) { const ONE_MB = 1024 * 1024; const DEFAULT_MAX_FILE_SIZE = ONE_MB; -interface Props extends Omit, 'value' | 'onChange' | 'accept'> { +export interface Props extends Omit, 'value' | 'onChange' | 'accept'> { maxFileSize?: number; value: T | undefined | null; onChange: (newValue: T | undefined, name: N) => void; diff --git a/manager-dashboard/app/utils/common.tsx b/manager-dashboard/app/utils/common.tsx index 53338d34f..ea4f777fa 100644 --- a/manager-dashboard/app/utils/common.tsx +++ b/manager-dashboard/app/utils/common.tsx @@ -66,8 +66,9 @@ export const PROJECT_TYPE_FOOTPRINT = 2; export const PROJECT_TYPE_CHANGE_DETECTION = 3; export const PROJECT_TYPE_COMPLETENESS = 4; export const PROJECT_TYPE_STREET = 7; +export const PROJECT_TYPE_VALIDATE_IMAGE = 10; -export type ProjectType = 1 | 2 | 3 | 4 | 7; +export type ProjectType = 1 | 2 | 3 | 4 | 7 | 10; export const projectTypeLabelMap: { [key in ProjectType]: string @@ -77,6 +78,7 @@ export const projectTypeLabelMap: { [PROJECT_TYPE_CHANGE_DETECTION]: 'Compare', [PROJECT_TYPE_COMPLETENESS]: 'Completeness', [PROJECT_TYPE_STREET]: 'Street', + [PROJECT_TYPE_VALIDATE_IMAGE]: 'Validate Image', }; export type IconKey = 'add-outline' diff --git a/manager-dashboard/app/views/NewProject/ImageInput/index.tsx b/manager-dashboard/app/views/NewProject/ImageInput/index.tsx new file mode 100644 index 000000000..93f4dcde2 --- /dev/null +++ b/manager-dashboard/app/views/NewProject/ImageInput/index.tsx @@ -0,0 +1,73 @@ +import React from 'react'; + +import { + SetValueArg, + Error, + useFormObject, + getErrorObject, +} from '@togglecorp/toggle-form'; +import TextInput from '#components/TextInput'; + +import { + ImageType, +} from '../utils'; + +import styles from './styles.css'; + +const defaultImageValue: ImageType = { + sourceIdentifier: '', +}; + +interface Props { + value: ImageType; + onChange: (value: SetValueArg, index: number) => void | undefined; + index: number; + error: Error | undefined; + disabled?: boolean; + readOnly?: boolean; +} + +export default function ImageInput(props: Props) { + const { + value, + onChange, + index, + error: riskyError, + disabled, + readOnly, + } = props; + + const onImageChange = useFormObject(index, onChange, defaultImageValue); + + const error = getErrorObject(riskyError); + + return ( +
+ + + +
+ ); +} diff --git a/manager-dashboard/app/views/NewProject/ImageInput/styles.css b/manager-dashboard/app/views/NewProject/ImageInput/styles.css new file mode 100644 index 000000000..a6e6f1707 --- /dev/null +++ b/manager-dashboard/app/views/NewProject/ImageInput/styles.css @@ -0,0 +1,5 @@ +.image-input { + display: flex; + flex-direction: column; + gap: var(--spacing-medium); +} diff --git a/manager-dashboard/app/views/NewProject/index.tsx b/manager-dashboard/app/views/NewProject/index.tsx index 811279723..cbc94b5ad 100644 --- a/manager-dashboard/app/views/NewProject/index.tsx +++ b/manager-dashboard/app/views/NewProject/index.tsx @@ -3,6 +3,7 @@ import { _cs, isDefined, isNotDefined, + randomString, } from '@togglecorp/fujs'; import { useForm, @@ -10,6 +11,7 @@ import { createSubmitHandler, analyzeErrors, nonFieldError, + useFormArray, } from '@togglecorp/toggle-form'; import { getStorage, @@ -29,7 +31,11 @@ import { import { MdOutlinePublishedWithChanges, MdOutlineUnpublished, + MdAdd, } from 'react-icons/md'; +import { + IoIosTrash, +} from 'react-icons/io'; import { Link } from 'react-router-dom'; import UserContext from '#base/context/UserContext'; @@ -40,6 +46,7 @@ import TextInput from '#components/TextInput'; import NumberInput from '#components/NumberInput'; import SegmentInput from '#components/SegmentInput'; import GeoJsonFileInput from '#components/GeoJsonFileInput'; +import CocoFileInput, { CocoDatasetType } from '#components/CocoFileInput'; import TileServerInput, { TILE_SERVER_BING, TILE_SERVER_ESRI, @@ -48,6 +55,7 @@ import TileServerInput, { import InputSection from '#components/InputSection'; import Button from '#components/Button'; import NonFieldError from '#components/NonFieldError'; +import EmptyMessage from '#components/EmptyMessage'; import AnimatedSwipeIcon from '#components/AnimatedSwipeIcon'; import ExpandableContainer from '#components/ExpandableContainer'; import AlertBanner from '#components/AlertBanner'; @@ -60,6 +68,7 @@ import { ProjectInputType, PROJECT_TYPE_BUILD_AREA, PROJECT_TYPE_FOOTPRINT, + PROJECT_TYPE_VALIDATE_IMAGE, PROJECT_TYPE_COMPLETENESS, PROJECT_TYPE_CHANGE_DETECTION, PROJECT_TYPE_STREET, @@ -73,6 +82,7 @@ import CustomOptionPreview from '#views/NewTutorial/CustomOptionInput/CustomOpti import { projectFormSchema, ProjectFormType, + ImageType, PartialProjectFormType, projectInputTypeOptions, filterOptions, @@ -84,8 +94,10 @@ import { getGroupSize, validateAoiOnOhsome, validateProjectIdOnHotTaskingManager, + MAX_IMAGES, } from './utils'; import BasicProjectInfoForm from './BasicProjectInfoForm'; +import ImageInput from './ImageInput'; // eslint-disable-next-line postcss-modules/no-unused-class import styles from './styles.css'; @@ -448,11 +460,67 @@ function NewProject(props: Props) { })), }))), [customOptionsFromValue]); - const optionsError = React.useMemo( + const customOptionsError = React.useMemo( () => getErrorObject(error?.customOptions), [error?.customOptions], ); + const { images } = value; + + const imagesError = React.useMemo( + () => getErrorObject(error?.images), + [error?.images], + ); + + const { + setValue: setImageValue, + removeValue: onImageRemove, + } = useFormArray< + 'images', + ImageType + >('images', setFieldValue); + + const handleCocoImport = React.useCallback( + (val: CocoDatasetType | undefined) => { + if (isNotDefined(val)) { + setFieldValue( + [], + 'images', + ); + return; + } + setFieldValue( + () => val.images.map((image) => ({ + sourceIdentifier: String(image.id), + fileName: image.file_name, + url: image.flickr_url || image.coco_url, + })), + 'images', + ); + }, + [setFieldValue], + ); + + const handleAddImage = React.useCallback( + () => { + setFieldValue( + (oldValue: PartialProjectFormType['images']) => { + const safeOldValues = oldValue ?? []; + + const newDefineOption: ImageType = { + sourceIdentifier: randomString(), + }; + + return [...safeOldValues, newDefineOption]; + }, + 'images', + ); + }, + [ + setFieldValue, + ], + ); + // eslint-disable-next-line @typescript-eslint/no-empty-function const noOp = () => {}; @@ -492,8 +560,84 @@ function NewProject(props: Props) { disabled={submissionPending || projectTypeEmpty} /> + {(value.projectType === PROJECT_TYPE_VALIDATE_IMAGE) && ( + + + + )} + > + + + {(images && images.length > 0) ? ( +
+ {images.map((image, index) => ( + + + + )} + > + + + ))} +
+ ) : ( + + )} +
+ )} {( (value.projectType === PROJECT_TYPE_FOOTPRINT + || value.projectType === PROJECT_TYPE_VALIDATE_IMAGE || value.projectType === PROJECT_TYPE_STREET) && customOptions && customOptions.length > 0 @@ -502,7 +646,7 @@ function NewProject(props: Props) { heading="Custom Options" > {(customOptions && customOptions.length > 0) ? (
@@ -517,7 +661,7 @@ function NewProject(props: Props) { value={option} index={index} onChange={noOp} - error={optionsError?.[option.value]} + error={customOptionsError?.[option.value]} readOnly /> @@ -743,7 +887,7 @@ function NewProject(props: Props) { value={value?.organizationId} onChange={setFieldValue} error={error?.organizationId} - label="Mapillary Organization ID" + label="Mapillary Organization IidD" hint="Provide a valid Mapillary organization ID to filter for images belonging to a specific organization. Empty indicates that no filter is set on organization." disabled={submissionPending || projectTypeEmpty} /> diff --git a/manager-dashboard/app/views/NewProject/styles.css b/manager-dashboard/app/views/NewProject/styles.css index cbfa76230..45aedf1bc 100644 --- a/manager-dashboard/app/views/NewProject/styles.css +++ b/manager-dashboard/app/views/NewProject/styles.css @@ -13,6 +13,14 @@ max-width: 70rem; gap: var(--spacing-large); + + .image-list { + display: flex; + flex-direction: column; + flex-grow: 1; + gap: var(--spacing-medium); + } + .custom-option-container { display: flex; gap: var(--spacing-large); diff --git a/manager-dashboard/app/views/NewProject/utils.ts b/manager-dashboard/app/views/NewProject/utils.ts index ce419e42d..e2ac2731f 100644 --- a/manager-dashboard/app/views/NewProject/utils.ts +++ b/manager-dashboard/app/views/NewProject/utils.ts @@ -34,6 +34,7 @@ import { ProjectInputType, PROJECT_TYPE_BUILD_AREA, PROJECT_TYPE_FOOTPRINT, + PROJECT_TYPE_VALIDATE_IMAGE, PROJECT_TYPE_CHANGE_DETECTION, PROJECT_TYPE_COMPLETENESS, PROJECT_TYPE_STREET, @@ -68,13 +69,14 @@ export interface ProjectFormType { projectImage: File; // image verificationNumber: number; groupSize: number; + maxTasksPerUser: number; + zoomLevel: number; geometry?: GeoJSON.GeoJSON | string; inputType?: ProjectInputType; TMId?: string; filter?: string; filterText?: string; - maxTasksPerUser: number; tileServer: TileServer; tileServerB?: TileServer; customOptions?: CustomOptionsForProject; @@ -87,6 +89,11 @@ export interface ProjectFormType { panoOnly?: boolean; isPano?: boolean | null; samplingThreshold?: number; + images?: { + sourceIdentifier: string; + fileName: string; + url: string; + }[]; } export const PROJECT_INPUT_TYPE_UPLOAD = 'aoi_file'; @@ -115,9 +122,11 @@ export const filterOptions = [ export type PartialProjectFormType = PartialForm< Omit & { projectImage?: File }, // NOTE: we do not want to change File and FeatureCollection to partials - 'geometry' | 'projectImage' | 'value' + 'geometry' | 'projectImage' | 'value' | 'sourceIdentifier' >; +export type ImageType = NonNullable[number]; + type ProjectFormSchema = ObjectSchema; type ProjectFormSchemaFields = ReturnType; @@ -127,6 +136,12 @@ type CustomOptionSchemaFields = ReturnType type CustomOptionFormSchema = ArraySchema; type CustomOptionFormSchemaMember = ReturnType; +type PartialImages = NonNullable[number]; +type ImageSchema = ObjectSchema; +type ImageSchemaFields = ReturnType +type ImageFormSchema = ArraySchema; +type ImageFormSchemaMember = ReturnType; + // FIXME: break this into multiple geometry conditions const DEFAULT_MAX_FEATURES = 20; // const DEFAULT_MAX_FEATURES = 10; @@ -194,6 +209,8 @@ function validGeometryCondition(zoomLevel: number | undefined | null) { return validGeometryConditionForZoom; } +export const MAX_IMAGES = 2000; + export const MAX_OPTIONS = 6; export const MIN_OPTIONS = 2; export const MAX_SUB_OPTIONS = 6; @@ -275,49 +292,16 @@ export const projectFormSchema: ProjectFormSchema = { lessThanOrEqualToCondition(250), ], }, - tileServer: { - fields: tileServerFieldsSchema, - }, maxTasksPerUser: { validations: [ integerCondition, greaterThanCondition(0), ], }, - dateRange: { - required: false, - }, - creatorId: { - required: false, - validations: [ - integerCondition, - greaterThanCondition(0), - ], - }, - organizationId: { - required: false, - validations: [ - integerCondition, - greaterThanCondition(0), - ], - }, - samplingThreshold: { - required: false, - validation: [ - greaterThanCondition(0), - ], - }, - panoOnly: { - required: false, - }, - isPano: { - required: false, - }, - randomizeOrder: { - required: false, - }, }; + // Common + baseSchema = addCondition( baseSchema, value, @@ -325,6 +309,7 @@ export const projectFormSchema: ProjectFormSchema = { ['customOptions'], (formValues) => { if (formValues?.projectType === PROJECT_TYPE_FOOTPRINT + || formValues?.projectType === PROJECT_TYPE_VALIDATE_IMAGE || formValues?.projectType === PROJECT_TYPE_STREET) { return { customOptions: { @@ -388,8 +373,8 @@ export const projectFormSchema: ProjectFormSchema = { const projectType = v?.projectType; if ( projectType === PROJECT_TYPE_BUILD_AREA - || projectType === PROJECT_TYPE_COMPLETENESS || projectType === PROJECT_TYPE_CHANGE_DETECTION + || projectType === PROJECT_TYPE_COMPLETENESS ) { return { zoomLevel: { @@ -408,24 +393,6 @@ export const projectFormSchema: ProjectFormSchema = { }, ); - baseSchema = addCondition( - baseSchema, - value, - ['projectType'], - ['inputType'], - (v) => { - const projectType = v?.projectType; - if (projectType === PROJECT_TYPE_FOOTPRINT) { - return { - inputType: { required: true }, - }; - } - return { - inputType: { forceValue: nullValue }, - }; - }, - ); - baseSchema = addCondition( baseSchema, value, @@ -437,8 +404,8 @@ export const projectFormSchema: ProjectFormSchema = { const zoomLevel = v?.zoomLevel; if ( projectType === PROJECT_TYPE_BUILD_AREA - || projectType === PROJECT_TYPE_COMPLETENESS || projectType === PROJECT_TYPE_CHANGE_DETECTION + || projectType === PROJECT_TYPE_COMPLETENESS || projectType === PROJECT_TYPE_STREET || (projectType === PROJECT_TYPE_FOOTPRINT && ( inputType === PROJECT_INPUT_TYPE_UPLOAD @@ -483,6 +450,51 @@ export const projectFormSchema: ProjectFormSchema = { }, ); + baseSchema = addCondition( + baseSchema, + value, + ['projectType'], + ['tileServer'], + (v) => { + const projectType = v?.projectType; + if ( + projectType === PROJECT_TYPE_BUILD_AREA + || projectType === PROJECT_TYPE_COMPLETENESS + || projectType === PROJECT_TYPE_CHANGE_DETECTION + || projectType === PROJECT_TYPE_FOOTPRINT + ) { + return { + tileServer: { + fields: tileServerFieldsSchema, + }, + }; + } + return { + tileServer: { forceValue: nullValue }, + }; + }, + ); + + // Validate + + baseSchema = addCondition( + baseSchema, + value, + ['projectType'], + ['inputType'], + (v) => { + const projectType = v?.projectType; + if (projectType === PROJECT_TYPE_FOOTPRINT) { + return { + inputType: { required: true }, + }; + } + return { + inputType: { forceValue: nullValue }, + }; + }, + ); + baseSchema = addCondition( baseSchema, value, @@ -560,6 +572,108 @@ export const projectFormSchema: ProjectFormSchema = { }, ); + // Street + + baseSchema = addCondition( + baseSchema, + value, + ['projectType'], + ['dateRange', 'creatorId', 'organizationId', 'samplingThreshold', 'panoOnly', 'isPano', 'randomizeOrder'], + (formValues) => { + if (formValues?.projectType === PROJECT_TYPE_STREET) { + return { + dateRange: { + required: false, + }, + creatorId: { + required: false, + validations: [ + integerCondition, + greaterThanCondition(0), + ], + }, + organizationId: { + required: false, + validations: [ + integerCondition, + greaterThanCondition(0), + ], + }, + samplingThreshold: { + required: false, + validations: [ + greaterThanCondition(0), + ], + }, + panoOnly: { + required: false, + }, + // FIXME: This is not used. + isPano: { + required: false, + }, + randomizeOrder: { + required: false, + }, + }; + } + return { + dateRange: { forceValue: nullValue }, + creatorId: { forceValue: nullValue }, + organizationId: { forceValue: nullValue }, + samplingThreshold: { forceValue: nullValue }, + panoOnly: { forceValue: nullValue }, + isPano: { forceValude: nullValue }, + randomizeOrder: { forceValue: nullValue }, + }; + }, + ); + + // Validate Image + + baseSchema = addCondition( + baseSchema, + value, + ['projectType'], + ['images'], + (formValues) => { + // FIXME: Add "unique" constraint for sourceIdentifier and fileName + if (formValues?.projectType === PROJECT_TYPE_VALIDATE_IMAGE) { + return { + images: { + keySelector: (key) => key.sourceIdentifier, + validation: (values) => { + if (values && values.length > MAX_IMAGES) { + return `Too many images ${values.length}. Please do not exceed ${MAX_IMAGES} images.`; + } + return undefined; + }, + member: (): ImageFormSchemaMember => ({ + fields: (): ImageSchemaFields => ({ + sourceIdentifier: { + required: true, + requiredValidation: requiredStringCondition, + }, + fileName: { + required: true, + requiredValidation: requiredStringCondition, + }, + url: { + required: true, + requiredValidation: requiredStringCondition, + validations: [urlCondition], + }, + }), + }), + }, + }; + } + return { + images: { forceValue: nullValue }, + }; + }, + ); + return baseSchema; }, }; @@ -588,6 +702,7 @@ export function getGroupSize(projectType: ProjectType | undefined) { } if (projectType === PROJECT_TYPE_FOOTPRINT + || projectType === PROJECT_TYPE_VALIDATE_IMAGE || projectType === PROJECT_TYPE_CHANGE_DETECTION || projectType === PROJECT_TYPE_STREET) { return 25; diff --git a/manager-dashboard/app/views/NewTutorial/ImageInput/index.tsx b/manager-dashboard/app/views/NewTutorial/ImageInput/index.tsx new file mode 100644 index 000000000..ca10b5806 --- /dev/null +++ b/manager-dashboard/app/views/NewTutorial/ImageInput/index.tsx @@ -0,0 +1,135 @@ +import React, { useMemo } from 'react'; + +import { + SetValueArg, + Error, + useFormObject, + getErrorObject, +} from '@togglecorp/toggle-form'; +import { isNotDefined, isDefined, unique } from '@togglecorp/fujs'; +import TextInput from '#components/TextInput'; +import SelectInput from '#components/SelectInput'; +import NumberInput from '#components/NumberInput'; + +import { + ImageType, + PartialCustomOptionsType, +} from '../utils'; + +import styles from './styles.css'; + +const defaultImageValue: ImageType = { + sourceIdentifier: '', +}; + +interface Props { + value: ImageType; + onChange: (value: SetValueArg, index: number) => void | undefined; + index: number; + error: Error | undefined; + disabled?: boolean; + readOnly?: boolean; + customOptions: PartialCustomOptionsType | undefined; +} + +export default function ImageInput(props: Props) { + const { + value, + onChange, + index, + error: riskyError, + disabled, + readOnly, + customOptions, + } = props; + + const flattenedOptions = useMemo( + () => { + const opts = customOptions?.flatMap( + (option) => ([ + { + key: option.value, + label: option.title, + }, + ...(option.subOptions ?? []).map( + (subOption) => ({ + key: subOption.value, + label: subOption.description, + }), + ), + ]), + ) ?? []; + + const validOpts = opts.map( + (option) => { + if (isNotDefined(option.key)) { + return undefined; + } + return { + ...option, + key: option.key, + }; + }, + ).filter(isDefined); + return unique( + validOpts, + (option) => option.key, + ); + }, + [customOptions], + ); + + const onImageChange = useFormObject(index, onChange, defaultImageValue); + + const error = getErrorObject(riskyError); + + return ( +
+ + + + + option.key} + labelSelector={(option) => option.label ?? `Option ${option.key}`} + options={flattenedOptions} + error={error?.referenceAnswer} + disabled={disabled || readOnly} + /> +
+ ); +} diff --git a/manager-dashboard/app/views/NewTutorial/ImageInput/styles.css b/manager-dashboard/app/views/NewTutorial/ImageInput/styles.css new file mode 100644 index 000000000..a6e6f1707 --- /dev/null +++ b/manager-dashboard/app/views/NewTutorial/ImageInput/styles.css @@ -0,0 +1,5 @@ +.image-input { + display: flex; + flex-direction: column; + gap: var(--spacing-medium); +} diff --git a/manager-dashboard/app/views/NewTutorial/ScenarioPageInput/FootprintGeoJsonPreview/index.tsx b/manager-dashboard/app/views/NewTutorial/ScenarioPageInput/FootprintGeoJsonPreview/index.tsx index f381ff4f9..2ab9cbe36 100644 --- a/manager-dashboard/app/views/NewTutorial/ScenarioPageInput/FootprintGeoJsonPreview/index.tsx +++ b/manager-dashboard/app/views/NewTutorial/ScenarioPageInput/FootprintGeoJsonPreview/index.tsx @@ -15,7 +15,7 @@ import { import styles from './styles.css'; // NOTE: the padding is selected wrt the size of the preview -const footprintGeojsonPadding = [140, 140]; +const footprintGeojsonPadding: [number, number] = [140, 140]; interface Props { className?: string; diff --git a/manager-dashboard/app/views/NewTutorial/ScenarioPageInput/ValidateImagePreview/index.tsx b/manager-dashboard/app/views/NewTutorial/ScenarioPageInput/ValidateImagePreview/index.tsx new file mode 100644 index 000000000..3dfa8fb98 --- /dev/null +++ b/manager-dashboard/app/views/NewTutorial/ScenarioPageInput/ValidateImagePreview/index.tsx @@ -0,0 +1,81 @@ +import React from 'react'; +import { _cs } from '@togglecorp/fujs'; + +import MobilePreview from '#components/MobilePreview'; +import { IconKey, iconMap } from '#utils/common'; + +import { + ImageType, + colorKeyToColorMap, + PartialCustomOptionsType, +} from '../../utils'; +import styles from './styles.css'; + +interface Props { + className?: string; + image?: ImageType; + previewPopUp?: { + title?: string; + description?: string; + icon?: IconKey; + } + customOptions: PartialCustomOptionsType | undefined; + lookFor: string | undefined; +} + +export default function ValidateImagePreview(props: Props) { + const { + className, + previewPopUp, + customOptions, + lookFor, + image, + } = props; + + const Comp = previewPopUp?.icon ? iconMap[previewPopUp.icon] : undefined; + + return ( + } + popupTitle={previewPopUp?.title || '{title}'} + popupDescription={previewPopUp?.description || '{description}'} + > + Preview +
+ {customOptions?.map((option) => { + const Icon = option.icon + ? iconMap[option.icon] + : iconMap['flag-outline']; + return ( +
+
+ {Icon && ( + + )} +
+
+ ); + })} +
+
+ ); +} diff --git a/manager-dashboard/app/views/NewTutorial/ScenarioPageInput/ValidateImagePreview/styles.css b/manager-dashboard/app/views/NewTutorial/ScenarioPageInput/ValidateImagePreview/styles.css new file mode 100644 index 000000000..5f708d4a5 --- /dev/null +++ b/manager-dashboard/app/views/NewTutorial/ScenarioPageInput/ValidateImagePreview/styles.css @@ -0,0 +1,36 @@ +.validate-image-preview { + .content { + display: flex; + flex-direction: column; + gap: var(--spacing-large); + + .image-preview { + position: relative; + width: 100%; + height: var(--height-mobile-preview-validate-image-content); + } + + .options { + display: grid; + flex-grow: 1; + grid-template-columns: 1fr 1fr 1fr; + grid-gap: var(--spacing-large); + + .option-container { + display: flex; + align-items: center; + justify-content: center; + + .option { + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + width: 2.5rem; + height: 2.5rem; + font-size: var(--font-size-extra-large); + } + } + } + } +} diff --git a/manager-dashboard/app/views/NewTutorial/ScenarioPageInput/index.tsx b/manager-dashboard/app/views/NewTutorial/ScenarioPageInput/index.tsx index b309be7ff..2ba9d05fe 100644 --- a/manager-dashboard/app/views/NewTutorial/ScenarioPageInput/index.tsx +++ b/manager-dashboard/app/views/NewTutorial/ScenarioPageInput/index.tsx @@ -18,6 +18,7 @@ import { PROJECT_TYPE_CHANGE_DETECTION, PROJECT_TYPE_COMPLETENESS, PROJECT_TYPE_STREET, + PROJECT_TYPE_VALIDATE_IMAGE, } from '#utils/common'; import TextInput from '#components/TextInput'; import Heading from '#components/Heading'; @@ -25,6 +26,7 @@ import SelectInput from '#components/SelectInput'; import SegmentInput from '#components/SegmentInput'; import { + ImageType, TutorialTasksGeoJSON, FootprintGeoJSON, BuildAreaGeoJSON, @@ -34,6 +36,7 @@ import { import BuildAreaGeoJsonPreview from './BuildAreaGeoJsonPreview'; import FootprintGeoJsonPreview from './FootprintGeoJsonPreview'; import ChangeDetectionGeoJsonPreview from './ChangeDetectionGeoJsonPreview'; +import ValidateImagePreview from './ValidateImagePreview'; import styles from './styles.css'; type ScenarioType = { @@ -78,6 +81,7 @@ interface Props { index: number, error: Error | undefined; geoJson: TutorialTasksGeoJSON | undefined; + images: ImageType[] | undefined; projectType: ProjectType | undefined; urlA: string | undefined; urlB: string | undefined; @@ -94,6 +98,7 @@ export default function ScenarioPageInput(props: Props) { index, error: riskyError, geoJson: geoJsonFromProps, + images, urlA, projectType, urlB, @@ -171,7 +176,21 @@ export default function ScenarioPageInput(props: Props) { [geoJsonFromProps, scenarioId], ); - const activeSegmentInput: ScenarioSegmentType['value'] = projectType && projectType !== PROJECT_TYPE_FOOTPRINT + const image = React.useMemo( + () => { + if (!images) { + return undefined; + } + return images.find((img) => img.screen === scenarioId); + }, + [images, scenarioId], + ); + + const activeSegmentInput: ScenarioSegmentType['value'] = ( + projectType + && projectType !== PROJECT_TYPE_FOOTPRINT + && projectType !== PROJECT_TYPE_VALIDATE_IMAGE + ) ? activeSegmentInputFromState : 'instructions'; @@ -214,7 +233,11 @@ export default function ScenarioPageInput(props: Props) { disabled={disabled} />
- {projectType && projectType !== PROJECT_TYPE_FOOTPRINT && ( + {( + projectType + && projectType !== PROJECT_TYPE_FOOTPRINT + && projectType !== PROJECT_TYPE_VALIDATE_IMAGE + ) && ( <> Hint @@ -252,7 +275,11 @@ export default function ScenarioPageInput(props: Props) {
)} - {projectType && projectType !== PROJECT_TYPE_FOOTPRINT && ( + {( + projectType + && projectType !== PROJECT_TYPE_FOOTPRINT + && projectType !== PROJECT_TYPE_VALIDATE_IMAGE + ) && ( <> Success @@ -319,6 +346,14 @@ export default function ScenarioPageInput(props: Props) { lookFor={lookFor} /> )} + {projectType === PROJECT_TYPE_VALIDATE_IMAGE && ( + + )} {projectType === PROJECT_TYPE_STREET && (
Preview not available. @@ -326,6 +361,7 @@ export default function ScenarioPageInput(props: Props) { )} {(projectType && projectType !== PROJECT_TYPE_FOOTPRINT + && projectType !== PROJECT_TYPE_VALIDATE_IMAGE && projectType !== PROJECT_TYPE_STREET) && ( ( @@ -157,6 +162,7 @@ function getGeoJSONError( return 'GeoJSON does not contain iterable features'; } + // FIXME: Use io-ts // Check properties schema const projectSchemas: { [key in ProjectType]: Record; @@ -195,6 +201,9 @@ function getGeoJSONError( reference: 'number', screen: 'number', }, + [PROJECT_TYPE_VALIDATE_IMAGE]: { + // NOTE: We do not use geojson import for validate image project + }, }; const schemaErrors = tutorialTasks.features.map( (feature) => checkSchema( @@ -319,6 +328,27 @@ function getGeoJSONWarning( return errors; } +function getImagesWarning( + images: ImageType[], + customOptions: number[], +) { + const errors = []; + + const usedValues = images.map((item) => item.referenceAnswer).filter(isDefined); + + const usedValuesSet = new Set(usedValues); + const customOptionsSet = new Set(customOptions); + + const invalidUsedValuesSet = difference(usedValuesSet, customOptionsSet); + + if (invalidUsedValuesSet.size === 1) { + errors.push(`Reference in images should be either ${customOptions.join(', ')}. The invalid reference is ${[...invalidUsedValuesSet].join(', ')}`); + } else if (invalidUsedValuesSet.size > 1) { + errors.push(`Reference in images should be either ${customOptions.join(', ')}. The invalid references are ${[...invalidUsedValuesSet].sort().join(', ')}`); + } + return errors; +} + type CustomScreen = Omit; function sanitizeScreens(scenarioPages: TutorialFormType['scenarioPages']) { const screens = scenarioPages.reduce>( @@ -406,6 +436,14 @@ function NewTutorial(props: Props) { InformationPagesType >('informationPages', setFieldValue); + const { + setValue: setImageValue, + // removeValue: onImageRemove, + } = useFormArray< + 'images', + ImageType + >('images', setFieldValue); + const handleSubmission = React.useCallback(( finalValuesFromProps: PartialTutorialFormType, ) => { @@ -600,7 +638,6 @@ function NewTutorial(props: Props) { })); return; } - setFieldValue(tutorialTasks, 'tutorialTasks'); const uniqueArray = unique( @@ -616,7 +653,6 @@ function NewTutorial(props: Props) { success: {}, } )); - setFieldValue(tutorialTaskArray, 'scenarioPages'); }, [setFieldValue, setError, value?.projectType]); @@ -645,6 +681,45 @@ function NewTutorial(props: Props) { [setFieldValue], ); + const handleCocoImport = React.useCallback( + (val: CocoDatasetType | undefined) => { + if (isNotDefined(val)) { + setFieldValue( + [], + 'images', + ); + return; + } + const newImages = val.images.map((image, index) => ({ + sourceIdentifier: String(image.id), + fileName: image.file_name, + url: image.flickr_url || image.coco_url, + screen: index + 1, + // referenceAnswer: 1, + })); + setFieldValue( + newImages, + 'images', + ); + + const uniqueArray = unique( + newImages, + ((img) => img.screen), + ); + const sorted = uniqueArray.sort((a, b) => a.screen - b.screen); + const tutorialTaskArray = sorted?.map((img) => ( + { + scenarioId: img.screen, + hint: {}, + instructions: {}, + success: {}, + } + )); + setFieldValue(tutorialTaskArray, 'scenarioPages'); + }, + [setFieldValue], + ); + const submissionPending = ( tutorialSubmissionStatus === 'started' || tutorialSubmissionStatus === 'imageUpload' @@ -678,6 +753,11 @@ function NewTutorial(props: Props) { [error?.informationPages], ); + const imagesError = React.useMemo( + () => getErrorObject(error?.images), + [error?.images], + ); + const hasErrors = React.useMemo( () => analyzeErrors(error), [error], @@ -693,6 +773,14 @@ function NewTutorial(props: Props) { ...options, ...subOptions, ].filter(isDefined); + + if (value?.projectType === PROJECT_TYPE_VALIDATE_IMAGE) { + return getImagesWarning( + value?.images ?? [], + selectedValues, + ); + } + return getGeoJSONWarning( value?.tutorialTasks, value?.projectType, @@ -700,7 +788,13 @@ function NewTutorial(props: Props) { value?.zoomLevel, ); }, - [value?.tutorialTasks, value?.projectType, value?.customOptions, value?.zoomLevel], + [ + value?.tutorialTasks, + value?.images, + value?.projectType, + value?.customOptions, + value?.zoomLevel, + ], ); const getTileServerUrl = (val: PartialTutorialFormType['tileServer']) => { @@ -719,12 +813,14 @@ function NewTutorial(props: Props) { const { customOptions, informationPages, + images, } = value; const handleProjectTypeChange = React.useCallback( (newValue: ProjectType | undefined) => { setFieldValue(undefined, 'tutorialTasks'); setFieldValue(undefined, 'scenarioPages'); + setFieldValue(undefined, 'images'); setFieldValue(newValue, 'projectType'); setFieldValue(getDefaultOptions(newValue), 'customOptions'); }, @@ -774,6 +870,7 @@ function NewTutorial(props: Props) { {( value.projectType === PROJECT_TYPE_FOOTPRINT + || value.projectType === PROJECT_TYPE_VALIDATE_IMAGE || value.projectType === PROJECT_TYPE_STREET ) && ( ) } + {value.projectType === PROJECT_TYPE_VALIDATE_IMAGE && ( + + + + {(images && images.length > 0) ? ( +
+ {images.map((image, index) => ( + + + + ))} +
+ ) : ( + + )} +
+ )} - + {value?.projectType !== PROJECT_TYPE_VALIDATE_IMAGE && ( + + )}
{value.scenarioPages?.map((task, index) => ( ))} {(value.scenarioPages?.length ?? 0) === 0 && ( - + <> + {value.projectType !== PROJECT_TYPE_VALIDATE_IMAGE ? ( + + ) : ( + + )} + )}
diff --git a/manager-dashboard/app/views/NewTutorial/styles.css b/manager-dashboard/app/views/NewTutorial/styles.css index 7242f2344..40b8cbdf6 100644 --- a/manager-dashboard/app/views/NewTutorial/styles.css +++ b/manager-dashboard/app/views/NewTutorial/styles.css @@ -20,6 +20,13 @@ gap: var(--spacing-medium); } + .image-list { + display: flex; + flex-direction: column; + flex-grow: 1; + gap: var(--spacing-medium); + } + .custom-option-container { display: flex; gap: var(--spacing-large); diff --git a/manager-dashboard/app/views/NewTutorial/utils.ts b/manager-dashboard/app/views/NewTutorial/utils.ts index 67f5e4af5..e0533080a 100644 --- a/manager-dashboard/app/views/NewTutorial/utils.ts +++ b/manager-dashboard/app/views/NewTutorial/utils.ts @@ -8,6 +8,7 @@ import { nullValue, ArraySchema, addCondition, + urlCondition, } from '@togglecorp/toggle-form'; import { isDefined, @@ -27,6 +28,7 @@ import { PROJECT_TYPE_COMPLETENESS, PROJECT_TYPE_FOOTPRINT, PROJECT_TYPE_STREET, + PROJECT_TYPE_VALIDATE_IMAGE, IconKey, } from '#utils/common'; @@ -285,6 +287,36 @@ export const defaultStreetCustomOptions: PartialTutorialFormType['customOptions' }, ]; +export const defaultValidateImageCustomOptions: PartialTutorialFormType['customOptions'] = [ + { + optionId: 1, + value: 1, + title: 'Yes', + icon: 'checkmark-outline', + iconColor: colorKeyToColorMap.green, + // FIXME: Add description + description: 'Yes', + }, + { + optionId: 2, + value: 0, + title: 'No', + icon: 'close-outline', + iconColor: colorKeyToColorMap.red, + // FIXME: Add description + description: 'No', + }, + { + optionId: 3, + value: 2, + title: 'Not Sure', + icon: 'remove-outline', + iconColor: colorKeyToColorMap.gray, + // FIXME: Add description + description: 'Not Sure', + }, +]; + export function deleteKey( value: T, key: K, @@ -305,6 +337,10 @@ export function getDefaultOptions(projectType: ProjectType | undefined) { return defaultStreetCustomOptions; } + if (projectType === PROJECT_TYPE_VALIDATE_IMAGE) { + return defaultValidateImageCustomOptions; + } + return undefined; } @@ -426,7 +462,6 @@ export interface TutorialFormType { title: string; }; }[]; - tutorialTasks?: TutorialTasksGeoJSON, exampleImage1: File; exampleImage2: File; projectType: ProjectType; @@ -434,6 +469,15 @@ export interface TutorialFormType { zoomLevel?: number; customOptions?: CustomOptions; informationPages: InformationPages; + + tutorialTasks?: TutorialTasksGeoJSON, + images?: { + sourceIdentifier: string; + fileName: string; + url: string; + referenceAnswer: number; + screen: number; + }[]; } export type PartialTutorialFormType = PartialForm< @@ -442,9 +486,11 @@ export type PartialTutorialFormType = PartialForm< exampleImage2?: File; }, // NOTE: we do not want to change File and FeatureCollection to partials - 'image' | 'tutorialTasks' | 'exampleImage1' | 'exampleImage2' | 'scenarioId' | 'optionId' | 'subOptionsId' | 'pageNumber' | 'blockNumber' | 'blockType' | 'imageFile' + 'image' | 'tutorialTasks' | 'exampleImage1' | 'exampleImage2' | 'scenarioId' | 'optionId' | 'subOptionsId' | 'pageNumber' | 'blockNumber' | 'blockType' | 'imageFile' | 'sourceIdentifier' >; +export type ImageType = NonNullable[number]; + type TutorialFormSchema = ObjectSchema; type TutorialFormSchemaFields = ReturnType; @@ -462,6 +508,12 @@ export type CustomOptionSchemaFields = ReturnType export type CustomOptionFormSchema = ArraySchema; export type CustomOptionFormSchemaMember = ReturnType; +type PartialImages = NonNullable[number]; +type ImageSchema = ObjectSchema; +type ImageSchemaFields = ReturnType +type ImageFormSchema = ArraySchema; +type ImageFormSchemaMember = ReturnType; + export type InformationPagesType = NonNullable[number] type InformationPagesSchema = ObjectSchema; type InformationPagesSchemaFields = ReturnType @@ -473,6 +525,8 @@ export type PartialInformationPagesType = PartialTutorialFormType['informationPa export type PartialCustomOptionsType = PartialTutorialFormType['customOptions']; export type PartialBlocksType = NonNullable[number]>['blocks']; +export const MAX_IMAGES = 20; + export const MAX_OPTIONS = 6; export const MIN_OPTIONS = 2; export const MAX_SUB_OPTIONS = 6; @@ -500,12 +554,6 @@ export const tutorialFormSchema: TutorialFormSchema = { requiredValidation: requiredStringCondition, validations: [getNoMoreThanNCharacterCondition(MD_TEXT_MAX_LENGTH)], }, - tileServer: { - fields: tileServerFieldsSchema, - }, - tutorialTasks: { - required: true, - }, informationPages: { validation: (info) => { if (info && info.length > MAX_INFO_PAGES) { @@ -564,6 +612,8 @@ export const tutorialFormSchema: TutorialFormSchema = { }, }; + // common + baseSchema = addCondition( baseSchema, value, @@ -601,7 +651,11 @@ export const tutorialFormSchema: TutorialFormSchema = { }), }, }; - if (projectType && projectType !== PROJECT_TYPE_FOOTPRINT) { + if ( + projectType + && projectType !== PROJECT_TYPE_FOOTPRINT + && projectType !== PROJECT_TYPE_VALIDATE_IMAGE + ) { fields = { ...fields, hint: { @@ -776,6 +830,7 @@ export const tutorialFormSchema: TutorialFormSchema = { }; if (formValues?.projectType === PROJECT_TYPE_FOOTPRINT + || formValues?.projectType === PROJECT_TYPE_VALIDATE_IMAGE || formValues?.projectType === PROJECT_TYPE_STREET) { return { customOptions: customOptionField, @@ -809,6 +864,23 @@ export const tutorialFormSchema: TutorialFormSchema = { }), ); + baseSchema = addCondition( + baseSchema, + value, + ['projectType'], + ['tileServer'], + (v) => ( + v?.projectType !== PROJECT_TYPE_VALIDATE_IMAGE + ? { + tileServer: { + fields: tileServerFieldsSchema, + }, + } : { + tileServer: { forceValue: nullValue }, + } + ), + ); + baseSchema = addCondition( baseSchema, value, @@ -824,6 +896,77 @@ export const tutorialFormSchema: TutorialFormSchema = { tileServerB: { forceValue: nullValue }, }), ); + + baseSchema = addCondition( + baseSchema, + value, + ['projectType'], + ['tutorialTasks'], + (formValues) => { + if (formValues?.projectType === PROJECT_TYPE_VALIDATE_IMAGE) { + return { + tutorialTasks: { forceValue: nullValue }, + }; + } + return { + tutorialTasks: { + required: true, + }, + }; + }, + ); + + // validate image + + baseSchema = addCondition( + baseSchema, + value, + ['projectType'], + ['images'], + (formValues) => { + // FIXME: Add "unique" constraint for sourceIdentifier and fileName + if (formValues?.projectType === PROJECT_TYPE_VALIDATE_IMAGE) { + return { + images: { + keySelector: (key) => key.sourceIdentifier, + validation: (values) => { + if (values && values.length > MAX_IMAGES) { + return `Too many images ${values.length}. Please do not exceed ${MAX_IMAGES} images.`; + } + return undefined; + }, + member: (): ImageFormSchemaMember => ({ + fields: (): ImageSchemaFields => ({ + sourceIdentifier: { + required: true, + requiredValidation: requiredStringCondition, + }, + fileName: { + required: true, + requiredValidation: requiredStringCondition, + }, + url: { + required: true, + requiredValidation: requiredStringCondition, + validations: [urlCondition], + }, + referenceAnswer: { + required: true, + }, + screen: { + required: true, + }, + }), + }), + }, + }; + } + return { + images: { forceValue: nullValue }, + }; + }, + ); + return baseSchema; }, }; diff --git a/manager-dashboard/package.json b/manager-dashboard/package.json index de3c020bd..4b0ee3c1a 100644 --- a/manager-dashboard/package.json +++ b/manager-dashboard/package.json @@ -44,8 +44,11 @@ "apollo-upload-client": "^16.0.0", "core-js": "3", "firebase": "^9.9.0", + "fp-ts": "^2.16.10", "graphql": "^15.5.1", "graphql-anywhere": "^4.2.7", + "io-ts": "^2.2.22", + "io-ts-types": "^0.5.19", "leaflet": "^1.8.0", "react": "^17.0.2", "react-dom": "^17.0.2", diff --git a/manager-dashboard/user_scripts/generate_coco_from_drive.js b/manager-dashboard/user_scripts/generate_coco_from_drive.js new file mode 100644 index 000000000..957eee989 --- /dev/null +++ b/manager-dashboard/user_scripts/generate_coco_from_drive.js @@ -0,0 +1,33 @@ +function main() { + const exportFileName = 'your_coco_export.json'; + const folderId = 'your_public_folder_id'; + const folder = DriveApp.getFolderById(folderId); + const files = folder.getFiles(); + + const images = []; + + let id = 1; + while (files.hasNext()) { + const file = files.next(); + const name = file.getName(); + const fileId = file.getId(); + // const url = https://drive.google.com/uc?export=view&id=" + fileId; + const url = `https://drive.google.com/thumbnail?id=${fileId}&sz=w1000`; + images.push({ + coco_url: url, + file_name: name, + id, + }); + id += 1; + } + + const exportContent = JSON.stringify({ images }); + const exportFile = DriveApp.createFile( + exportFileName, + exportContent, + MimeType.PLAIN_TEXT, + ); + const exportFileUrl = exportFile.getUrl(); + + Logger.log(`COCO file available at: ${exportFileUrl}`); +} diff --git a/manager-dashboard/user_scripts/generate_coco_from_dropbox.py b/manager-dashboard/user_scripts/generate_coco_from_dropbox.py new file mode 100644 index 000000000..6f3bedfe8 --- /dev/null +++ b/manager-dashboard/user_scripts/generate_coco_from_dropbox.py @@ -0,0 +1,157 @@ +# /// script +# dependencies = [ +# "requests<3", +# ] +# /// +from pathlib import Path +from argparse import ArgumentParser +import requests +import json +import re + +def dropbox_request(endpoint: str, data: object, *, access_token: str): + url = f"https://api.dropboxapi.com/2/{endpoint}" + headers = { + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json", + } + res = requests.post( + url, + headers=headers, + data=json.dumps(data), + ) + res.raise_for_status() + return res.json() + +def dropbox_content_request(endpoint: str, path: str, data: object, *, access_token: str): + url = f"https://content.dropboxapi.com/2/{endpoint}" + headers = { + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/octet-stream", + "Dropbox-API-Arg": json.dumps({ + "path": path, + "mode": "overwrite", # overwrite if exists + "autorename": False, + "mute": False + }) + } + res = requests.post( + url, + headers=headers, + data=json.dumps(data).encode("utf-8"), + ) + res.raise_for_status() + return res.json() + +def list_all_files(folder_path: str, *, access_token: str): + ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp"} + files = [] + + data = {"path": folder_path, "recursive": False} + response = dropbox_request("files/list_folder", data, access_token=access_token) + + files.extend(response.get("entries", [])) + + while response.get("has_more", False): + cursor = response["cursor"] + response = dropbox_request( + "files/list_folder/continue", + {"cursor": cursor}, + access_token=access_token, + ) + files.extend(response.get("entries", [])) + + # Sort files by name (just in case) + files = sorted(files, key=lambda file: file["name"].lower()) + # Filter out only files (not folders) that are supported + files = [ + file for file in files + if file[".tag"] == "file" and Path(file["name"]).suffix.lower() in ALLOWED_EXTENSIONS + ] + return files + +def share_file_and_get_links(files, *, access_token: str): + total = len(files) + images = [] + for i, file in enumerate(files): + path = file["path_lower"] + actual_path = file["path_display"] + + # First try to list existing shared links + data = {"path": path, "direct_only": True} + print(f"{i + 1}/{total} Getting public URL") + res = dropbox_request( + "sharing/list_shared_links", + data, + access_token=access_token, + ) + if res.get("links"): + link = res["links"][0]["url"] + else: + data = { + "path": path, + "settings": { + "requested_visibility": "public" + } + } + res_create = dropbox_request( + "sharing/create_shared_link_with_settings", + data, + access_token=access_token, + ) + link = res_create["url"] + + raw_url = re.sub(r'&dl=0\b', '', link) + '&raw=1' + + images.append({ + "id": i + 1, + "file_name": actual_path, + "coco_url": raw_url, + }) + return images + + +def main(): + parser = ArgumentParser(description="Generate COCO file from images folder.") + parser.add_argument("access_token", help="Access token for authentication") + parser.add_argument("images_folder", help="Path to the images folder") + parser.add_argument("export_file_name", help="Name of the export COCO file") + + args = parser.parse_args() + + access_token = args.access_token + images_folder = args.images_folder + export_file_name = args.export_file_name + + # Get all the files on given path + files = list_all_files( + images_folder, + access_token=access_token, + ) + + # Share individual file publically and get public link + public_images = share_file_and_get_links( + files, + access_token=access_token, + ) + + # Upload coco format export to dropbox + print("Uploading COCO file") + absolute_export_file_name = str(Path(images_folder) / Path(export_file_name)) + dropbox_content_request( + "files/upload", + absolute_export_file_name, + { "images": public_images }, + access_token=access_token, + ) + + # Get temporary link + res = dropbox_request( + "files/get_temporary_link", + { "path": absolute_export_file_name }, + access_token=access_token, + ) + print(f"COCO file available at {res["link"]}") + +if __name__ == "__main__": + main() diff --git a/manager-dashboard/yarn.lock b/manager-dashboard/yarn.lock index a3597eea5..66718bef1 100644 --- a/manager-dashboard/yarn.lock +++ b/manager-dashboard/yarn.lock @@ -6668,6 +6668,11 @@ forwarded@0.2.0: resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow== +fp-ts@^2.16.10: + version "2.16.10" + resolved "https://registry.yarnpkg.com/fp-ts/-/fp-ts-2.16.10.tgz#829b82a46571c2dc202bed38a9c2eeec603e38c4" + integrity sha512-vuROzbNVfCmUkZSUbnWSltR1sbheyQbTzug7LB/46fEa1c0EucLeBaCEUE0gF3ZGUGBt9lVUiziGOhhj6K1ORA== + fraction.js@^4.1.1: version "4.1.2" resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.1.2.tgz#13e420a92422b6cf244dff8690ed89401029fbe8" @@ -7480,6 +7485,16 @@ invariant@^2.2.4: dependencies: loose-envify "^1.0.0" +io-ts-types@^0.5.19: + version "0.5.19" + resolved "https://registry.yarnpkg.com/io-ts-types/-/io-ts-types-0.5.19.tgz#9c04fa73f15992436605218a5686b610efa7a5d3" + integrity sha512-kQOYYDZG5vKre+INIDZbLeDJe+oM+4zLpUkjXyTMyUfoCpjJNyi29ZLkuEAwcPufaYo3yu/BsemZtbdD+NtRfQ== + +io-ts@^2.2.22: + version "2.2.22" + resolved "https://registry.yarnpkg.com/io-ts/-/io-ts-2.2.22.tgz#5ab0d3636fe8494a275f0266461ab019da4b8d0b" + integrity sha512-FHCCztTkHoV9mdBsHpocLpdTAfh956ZQcIkWQxxS0U5HT53vtrcuYdQneEJKH6xILaLNzXVl2Cvwtoy8XNN0AA== + ip-regex@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/ip-regex/-/ip-regex-2.1.0.tgz#fa78bf5d2e6913c911ce9f819ee5146bb6d844e9" diff --git a/mapswipe_workers/mapswipe_workers/definitions.py b/mapswipe_workers/mapswipe_workers/definitions.py index aa32d3aac..afa7cb6ff 100644 --- a/mapswipe_workers/mapswipe_workers/definitions.py +++ b/mapswipe_workers/mapswipe_workers/definitions.py @@ -137,6 +137,7 @@ class ProjectType(Enum): MEDIA_CLASSIFICATION = 5 DIGITIZATION = 6 STREET = 7 + VALIDATE_IMAGE = 10 @property def constructor(self): @@ -149,6 +150,7 @@ def constructor(self): FootprintProject, MediaClassificationProject, StreetProject, + ValidateImageProject, ) project_type_classes = { @@ -159,6 +161,7 @@ def constructor(self): 5: MediaClassificationProject, 6: DigitizationProject, 7: StreetProject, + 10: ValidateImageProject, } return project_type_classes[self.value] @@ -171,6 +174,7 @@ def tutorial(self): CompletenessTutorial, FootprintTutorial, StreetTutorial, + ValidateImageTutorial, ) project_type_classes = { @@ -179,5 +183,6 @@ def tutorial(self): 3: ChangeDetectionTutorial, 4: CompletenessTutorial, 7: StreetTutorial, + 10: ValidateImageTutorial, } return project_type_classes[self.value] diff --git a/mapswipe_workers/mapswipe_workers/firebase/firebase.py b/mapswipe_workers/mapswipe_workers/firebase/firebase.py index 809b6c801..b91256985 100644 --- a/mapswipe_workers/mapswipe_workers/firebase/firebase.py +++ b/mapswipe_workers/mapswipe_workers/firebase/firebase.py @@ -14,6 +14,7 @@ def save_project_to_firebase(self, project): # if a geometry exists in projects we want to delete it. # This geometry is not used in clients. project.pop("geometry", None) + # FIXME: We might need to pop images # save project self.ref.update({f"v2/projects/{project['projectId']}": project}) logger.info( @@ -82,6 +83,7 @@ def save_tutorial_to_firebase( tutorialDict.pop("raw_tasks", None) tutorialDict.pop("examplesFile", None) tutorialDict.pop("tutorial_tasks", None) + tutorialDict.pop("images", None) if not tutorial.projectId or tutorial.projectId == "": raise CustomError( diff --git a/mapswipe_workers/mapswipe_workers/project_types/__init__.py b/mapswipe_workers/mapswipe_workers/project_types/__init__.py index 9560c76ef..3fb4e722b 100644 --- a/mapswipe_workers/mapswipe_workers/project_types/__init__.py +++ b/mapswipe_workers/mapswipe_workers/project_types/__init__.py @@ -10,6 +10,8 @@ from .tile_map_service.classification.tutorial import ClassificationTutorial from .tile_map_service.completeness.project import CompletenessProject from .tile_map_service.completeness.tutorial import CompletenessTutorial +from .validate_image.project import ValidateImageProject +from .validate_image.tutorial import ValidateImageTutorial __all__ = [ "ClassificationProject", @@ -21,6 +23,8 @@ "MediaClassificationProject", "FootprintProject", "FootprintTutorial", + "ValidateImageProject", + "ValidateImageTutorial", "DigitizationProject", "StreetProject", "StreetTutorial", diff --git a/mapswipe_workers/mapswipe_workers/project_types/validate_image/__init__.py b/mapswipe_workers/mapswipe_workers/project_types/validate_image/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/mapswipe_workers/mapswipe_workers/project_types/validate_image/project.py b/mapswipe_workers/mapswipe_workers/project_types/validate_image/project.py new file mode 100644 index 000000000..e0aee5f4d --- /dev/null +++ b/mapswipe_workers/mapswipe_workers/project_types/validate_image/project.py @@ -0,0 +1,108 @@ +import math +from dataclasses import dataclass +from typing import Dict, List + +from mapswipe_workers.definitions import logger +from mapswipe_workers.firebase.firebase import Firebase +from mapswipe_workers.firebase_to_postgres.transfer_results import ( + results_to_file, + save_results_to_postgres, + truncate_temp_results, +) +from mapswipe_workers.generate_stats.project_stats import ( + get_statistics_for_integer_result_project, +) +from mapswipe_workers.project_types.project import BaseGroup, BaseProject + + +@dataclass +class ValidateImageGroup(BaseGroup): + pass + + +@dataclass +class ValidateImageTask: + # TODO(tnagorra): We need to check if fileName should be saved on project + # NOTE: We do not need to add projectId and groupId so we are not extending BaseTask + + # NOTE: taskId is the sourceIdentifier + taskId: str + + fileName: str + url: str + + # NOTE: This is not required but required by the base class + geometry: str + + +class ValidateImageProject(BaseProject): + def __init__(self, project_draft): + super().__init__(project_draft) + self.groups: Dict[str, ValidateImageGroup] = {} + self.tasks: Dict[str, List[ValidateImageTask]] = {} # dict keys are group ids + + # NOTE: This is a standard structure defined on manager dashboard. + # It's derived from other formats like COCO. + # The transfromation is done in manager dashboard. + self.images = project_draft["images"] + + def save_tasks_to_firebase(self, projectId: str, tasks: dict): + firebase = Firebase() + firebase.save_tasks_to_firebase(projectId, tasks, useCompression=False) + + @staticmethod + def results_to_postgres(results: dict, project_id: str, filter_mode: bool): + """How to move the result data from firebase to postgres.""" + results_file, user_group_results_file = results_to_file(results, project_id) + truncate_temp_results() + save_results_to_postgres(results_file, project_id, filter_mode) + return user_group_results_file + + @staticmethod + def get_per_project_statistics(project_id, project_info): + """How to aggregate the project results.""" + return get_statistics_for_integer_result_project( + project_id, project_info, generate_hot_tm_geometries=False + ) + + def validate_geometries(self): + pass + + def save_to_files(self, project): + """We do not have any geometry so we pass here""" + pass + + def create_groups(self): + self.numberOfGroups = math.ceil(len(self.images) / self.groupSize) + for group_index in range(self.numberOfGroups): + self.groups[f"g{group_index + 100}"] = ValidateImageGroup( + projectId=self.projectId, + groupId=f"g{group_index + 100}", + progress=0, + finishedCount=0, + requiredCount=0, + numberOfTasks=self.groupSize, + ) + logger.info(f"{self.projectId} - create_groups - created groups dictionary") + + def create_tasks(self): + if len(self.groups) == 0: + raise ValueError("Groups needs to be created before tasks can be created.") + for group_id, group in self.groups.items(): + self.tasks[group_id] = [] + for i in range(self.groupSize): + # FIXME: We should try not to mutate values + image_metadata = self.images.pop() + task = ValidateImageTask( + taskId=image_metadata["sourceIdentifier"], + fileName=image_metadata["fileName"], + url=image_metadata["url"], + geometry="", + ) + self.tasks[group_id].append(task) + + # list now empty? if usual group size is not reached + # the actual number of tasks for the group is updated + if not self.images: + group.numberOfTasks = i + 1 + break diff --git a/mapswipe_workers/mapswipe_workers/project_types/validate_image/tutorial.py b/mapswipe_workers/mapswipe_workers/project_types/validate_image/tutorial.py new file mode 100644 index 000000000..b42b0be61 --- /dev/null +++ b/mapswipe_workers/mapswipe_workers/project_types/validate_image/tutorial.py @@ -0,0 +1,80 @@ +from dataclasses import dataclass + +from mapswipe_workers.definitions import logger +from mapswipe_workers.firebase.firebase import Firebase +from mapswipe_workers.project_types.tutorial import BaseTutorial +from mapswipe_workers.project_types.validate_image.project import ( + ValidateImageGroup, + ValidateImageTask, +) + + +@dataclass +class ValidateImageTutorialTask(ValidateImageTask): + # TODO(tnagorra): Check if we need projectId and groupId in tutorial task + projectId: str + groupId: str + referenceAnswer: int + screen: int + + +class ValidateImageTutorial(BaseTutorial): + + def __init__(self, tutorial_draft): + # this will create the basis attributes + super().__init__(tutorial_draft) + + self.groups = dict() + self.tasks = dict() + self.images = tutorial_draft["images"] + + def create_tutorial_groups(self): + """Create group for the tutorial based on provided examples in images.""" + + # NOTE: The groupId must be a numeric 101. It's hardcoded in save_tutorial_to_firebase + group = ValidateImageGroup( + groupId=101, + projectId=self.projectId, + numberOfTasks=len(self.images), + progress=0, + finishedCount=0, + requiredCount=0, + ) + self.groups[101] = group + + logger.info( + f"{self.projectId} - create_tutorial_groups - created groups dictionary" + ) + + def create_tutorial_tasks(self): + """Create the tasks dict based on provided examples in geojson file.""" + task_list = [] + for image_metadata in self.images: + image_metadata = ValidateImageTutorialTask( + projectId=self.projectId, + groupId=101, + taskId=image_metadata["sourceIdentifier"], + fileName=image_metadata["fileName"], + url=image_metadata["url"], + geometry="", + referenceAnswer=image_metadata["referenceAnswer"], + screen=image_metadata["screen"], + ) + task_list.append(image_metadata) + + if task_list: + self.tasks[101] = task_list + else: + logger.info(f"group in project {self.projectId} is not valid.") + + logger.info( + f"{self.projectId} - create_tutorial_tasks - created tasks dictionary" + ) + + def save_tutorial(self): + firebase = Firebase() + firebase.save_tutorial_to_firebase( + self, self.groups, self.tasks, useCompression=False + ) + logger.info(self.tutorialDraftId) + firebase.drop_tutorial_draft(self.tutorialDraftId) diff --git a/mapswipe_workers/tests/integration/set_up_db.sql b/mapswipe_workers/tests/integration/set_up_db.sql index 66583bb2d..b2b23f328 100644 --- a/mapswipe_workers/tests/integration/set_up_db.sql +++ b/mapswipe_workers/tests/integration/set_up_db.sql @@ -31,7 +31,7 @@ CREATE TABLE IF NOT EXISTS groups ( required_count int, progress int, project_type_specifics json, - -- total_area & time_spent_max_allowed are maintaned and used by aggregated module + -- total_area & time_spent_max_allowed are maintained and used by aggregated module total_area float DEFAULT NULL, time_spent_max_allowed float DEFAULT NULL, PRIMARY KEY (project_id, group_id), diff --git a/postgres/initdb.sql b/postgres/initdb.sql index 66583bb2d..b2b23f328 100644 --- a/postgres/initdb.sql +++ b/postgres/initdb.sql @@ -31,7 +31,7 @@ CREATE TABLE IF NOT EXISTS groups ( required_count int, progress int, project_type_specifics json, - -- total_area & time_spent_max_allowed are maintaned and used by aggregated module + -- total_area & time_spent_max_allowed are maintained and used by aggregated module total_area float DEFAULT NULL, time_spent_max_allowed float DEFAULT NULL, PRIMARY KEY (project_id, group_id),