diff --git a/.circleci/config.yml b/.circleci/config.yml index 7169753e2..e7407c13a 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -131,8 +131,8 @@ jobs: ssh lightsail 'python3 get-pip.py'; ssh lightsail '/home/ubuntu/.local/bin/pip3 install --upgrade awscli'; echo 'Initiating lightsail self-destruct sequence...'; - ssh lightsail 'export AWS_ACCESS_KEY_ID='"'$AWS_ACCESS_KEY_ID'"';export AWS_SECRET_ACCESS_KEY='"'$AWS_SECRET_ACCESS_KEY'"';export AWS_DEFAULT_REGION='"'$AWS_DEFAULT_REGION'"';export GIT_REVISION='"'$CIRCLE_SHA1'"';sh -c "sleep 10800 && /home/ubuntu/.local/bin/aws lightsail delete-instance --instance-name tator-ci-$GIT_REVISION" >/dev/null 2>&1 &'; - ssh lightsail 'echo "This lightsail instance will self-destruct in 3 hours."'; + ssh lightsail 'export AWS_ACCESS_KEY_ID='"'$AWS_ACCESS_KEY_ID'"';export AWS_SECRET_ACCESS_KEY='"'$AWS_SECRET_ACCESS_KEY'"';export AWS_DEFAULT_REGION='"'$AWS_DEFAULT_REGION'"';export GIT_REVISION='"'$CIRCLE_SHA1'"';sh -c "sleep 14400 && /home/ubuntu/.local/bin/aws lightsail delete-instance --instance-name tator-ci-$GIT_REVISION" >/dev/null 2>&1 &'; + ssh lightsail 'echo "This lightsail instance will self-destruct in 4 hours."'; - run: name: Clone source on lightsail command: | @@ -155,7 +155,7 @@ jobs: ssh lightsail 'wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb'; ssh lightsail 'sudo -E apt-get -yq --no-install-suggests --no-install-recommends install ./google-chrome-stable_current_amd64.deb'; ssh lightsail 'sudo -E apt-get update && sudo -E apt-get -yq --no-install-suggests --no-install-recommends install tesseract-ocr python3-pip ffmpeg wget unzip'; - ssh lightsail 'pip3 install pytest pytest-xdist pandas playwright==1.27.1 pytest-playwright==0.3.0 pytesseract==0.3.9 opencv-python pytest-rerunfailures==10.2'; + ssh lightsail 'pip3 install pytest pytest-xdist pandas playwright==1.37.0 pytest-playwright==0.4.2 pytesseract==0.3.9 opencv-python pytest-rerunfailures==10.2'; ssh lightsail 'export PATH=$PATH:$HOME/.local/bin:/snap/bin && playwright install'; ssh lightsail 'wget http://zebulon.bok.net/Bento4/binaries/Bento4-SDK-1-6-0-632.x86_64-unknown-linux.zip'; ssh lightsail 'unzip Bento4-SDK-1-6-0-632.x86_64-unknown-linux.zip'; @@ -187,7 +187,13 @@ jobs: command: ssh lightsail 'cd tator && make testinit' - run: name: Run REST tests - command: ssh lightsail 'cd tator && for i in $(seq 1 3); do make rq-empty && make test && s=0 && break || s=$? && sleep 10; done; (exit $s)' + command: | + mkdir -p rest-test-results; + ssh lightsail 'cd tator && for i in $(seq 1 3); do make rq-empty && make test && break || sleep 10; done;'; + ssh lightsail 'test -e tator/rest-junit.xml' && scp lightsail:tator/rest-junit.xml rest-test-results/ || exit 0; + [ $(sed -n '1s/.*failures="\([0-9]*\)".*/\1/p' rest-test-results/rest-junit.xml) -eq "0" ] && exit 0 || exit 1; + - store_test_results: + path: rest-test-results front-end-tests: machine: image: ubuntu-2004:202010-01 @@ -209,9 +215,14 @@ jobs: command: | ssh lightsail 'cd tator && make rq-empty'; mkdir -p /tmp/videos; + mkdir -p frontend-test-results; ssh lightsail 'mkdir -p /tmp/videos'; sshfs -o default_permissions lightsail:/tmp/videos /tmp/videos; - ssh lightsail 'mkdir -p /tmp/videos && export PATH=$PATH:$HOME/.local/bin:/snap/bin && export PYTHONUNBUFFERED=1 && cd tator && for i in $(seq 1 3); do pytest test --slowmo 30 --base-url=http://localhost:8080 --browser=chromium --username=admin --password=admin --videos=/tmp/videos -s && s=0 && break || s=$? && sleep 10; done; (exit $s)'; + ssh lightsail 'mkdir -p /tmp/videos && export PATH=$PATH:$HOME/.local/bin:/snap/bin && export PYTHONUNBUFFERED=1 && cd tator && for i in $(seq 1 3); do if [ -d "test-results" ]; then rm -f test-results/*; else mkdir -p test-results; fi; pytest test --slowmo 30 --base-url=http://localhost:8080 --browser=chromium --username=admin --password=admin --videos=/tmp/videos -s --junitxml=test-results/frontend-junit.xml && break || sleep 10; done;'; + ssh lightsail 'test -e tator/test-results/frontend-junit.xml' && scp lightsail:tator/test-results/frontend-junit.xml frontend-test-results/ || exit 0; + [ $(sed -n '1s/.*failures="\([0-9]*\)".*/\1/p' frontend-test-results/frontend-junit.xml) -eq "0" ] && exit 0 || exit 1; + - store_test_results: + path: frontend-test-results - store_artifacts: path: /tmp/videos destination: videos @@ -232,7 +243,13 @@ jobs: scp lightsail:~/token.txt ~/token.txt - run: name: Run tator-py tests - command: ssh lightsail 'export TOKEN='"'$(cat ~/token.txt)'"';export PATH=$PATH:$HOME/.local/bin:/snap/bin && cd tator && for i in $(seq 1 3); do pytest tatorpy_test --ignore tatorpy_test/test_algorithm_launch.py --ignore tatorpy_test/test_job_cancel.py --host=http://localhost:8080 --token=$TOKEN -s --keep && s=0 && break || s=$? && sleep 10; done; (exit $s)'; + command: | + mkdir -p tatorpy-test-results; + ssh lightsail 'export TATOR_TOKEN='"'$(cat ~/token.txt)'"'; export TATOR_HOST=http://localhost:8080; export PATH=$PATH:$HOME/.local/bin:/snap/bin && cd tator && for i in $(seq 1 3); do if [ -d "test-results" ]; then rm -f test-results/*; else mkdir -p test-results; fi; pytest tatorpy_test --ignore tatorpy_test/test_algorithm_launch.py --ignore tatorpy_test/test_job_cancel.py -s --keep --junitxml=test-results/tatorpy-junit.xml && break || sleep 10; done;'; + ssh lightsail 'test -e tator/test-results/tatorpy-junit.xml' && scp lightsail:tator/test-results/tatorpy-junit.xml tatorpy-test-results/ || exit 0; + [ $(sed -n '1s/.*failures="\([0-9]*\)".*/\1/p' tatorpy-test-results/tatorpy-junit.xml) -eq "0" ] && exit 0 || exit 1; + - store_test_results: + path: tatorpy-test-results - run: name: Check db-worker logs for clean running command: ssh lightsail 'cd tator && make check-clean-db-logs' @@ -245,7 +262,6 @@ jobs: - store_artifacts: path: /tmp/logs destination: container_logs - when: always cron-job-tests: machine: image: ubuntu-2004:202010-01 diff --git a/.gitignore b/.gitignore index 520079364..597b60bcb 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,8 @@ helm/tator/configs/* **/debug.log **/.pylint-venv .env +test-results +rest-junit.xml # Hidden mac files ._. diff --git a/Makefile b/Makefile index 527e6b9ae..b35c20e22 100644 --- a/Makefile +++ b/Makefile @@ -37,7 +37,9 @@ endif # Set this ENV to http://iad-ad-1.clouds.archive.ubuntu.com/ubuntu/ for # faster builds on Oracle OCI APT_REPO_HOST ?= $(shell cat /etc/apt/sources.list | grep -E "focal main|jammy main" | grep -v cdrom | head -n1 | awk '{print $$2}') - +ifeq ($(APT_REPO_HOST),) +APT_REPO_HOST=http://archive.ubuntu.com/ubuntu/ +endif ############################# ## Help Rule + Generic targets @@ -214,6 +216,10 @@ endif collect-static: webpack @scripts/collect-static.sh +force-static: + @rm -fr scripts/packages/tator-js/pkg/ + $(MAKE) collect-static + dev-push: @scripts/dev-push.sh @@ -251,7 +257,8 @@ testinit: .PHONY: test test: docker exec gunicorn sh -c 'bash scripts/addExtensionsToInit.sh' - docker exec gunicorn sh -c 'pytest --ds=tator_online.settings -n 4 --reuse-db --create-db main/tests.py' + docker exec gunicorn sh -c 'pytest --ds=tator_online.settings -n 4 --reuse-db --create-db --junitxml=./test-results/rest-junit.xml main/tests.py' + docker cp gunicorn:/tator_online/test-results/rest-junit.xml rest-junit.xml .PHONY: cache_clear cache-clear: diff --git a/api/main/_import_image.py b/api/main/_import_image.py index cc6085904..3ae84e915 100644 --- a/api/main/_import_image.py +++ b/api/main/_import_image.py @@ -58,36 +58,23 @@ def _import_image(name, url, thumbnail_url, media_id, reference_only): temp_image = tempfile.NamedTemporaryFile(delete=False) download_file(url, temp_image.name, 5) image = Image.open(temp_image.name) - media_obj.width, media_obj.height = image.size image_format = image.format image = ImageOps.exif_transpose(image) + media_obj.width, media_obj.height = image.size # Add a png for compatibility purposes in case of HEIF or AVIF import. - # always make AVIF, but keep HEIF originals. + # always make AVIF if reference_only is False: - if image_format == "HEIF": - alt_image = tempfile.NamedTemporaryFile(delete=False, suffix=".png") - image.save(alt_image, format="png") - alt_images.append(alt_image) - alt_formats.append("png") - - alt_image = tempfile.NamedTemporaryFile(delete=False, suffix=".avif") - image.save(alt_image, format="avif") - alt_images.append(alt_image) - alt_formats.append("avif") - - elif image_format == "AVIF": - alt_image = tempfile.NamedTemporaryFile(delete=False, suffix=".png") - image.save(alt_image, format="png") - alt_images.append(alt_image) - alt_formats.append("png") - else: - # convert image upload to AVIF - alt_image = tempfile.NamedTemporaryFile(delete=False, suffix=".avif") - image.save(alt_image, format="avif") - alt_images.append(alt_image) - alt_formats.append("avif") + alt_image = tempfile.NamedTemporaryFile(delete=False, suffix=".png") + image.save(alt_image, format="png", quality=100, subsampling=0) + alt_images.append(alt_image) + alt_formats.append("png") + + alt_image = tempfile.NamedTemporaryFile(delete=False, suffix=".avif") + image.save(alt_image, format="avif", quality=100) + alt_images.append(alt_image) + alt_formats.append("avif") # Download or create the thumbnail. if thumbnail_url is None: @@ -112,6 +99,7 @@ def _import_image(name, url, thumbnail_url, media_id, reference_only): thumb_height = thumb.height thumb.close() + media_obj.media_files = {} if reference_only and url: if media_obj.media_files is None: media_obj.media_files = {} @@ -123,23 +111,10 @@ def _import_image(name, url, thumbnail_url, media_id, reference_only): "mime": f"image/{image_format.lower()}", } ] - elif url: - if media_obj.media_files is None: - media_obj.media_files = {} - # Upload image. - image_key = f"{project_obj.organization.pk}/{project_obj.pk}/{media_obj.pk}/{name}" - tator_store.put_object(image_key, temp_image) - media_obj.media_files["image"] = [ - { - "path": image_key, - "size": os.stat(temp_image.name).st_size, - "resolution": [media_obj.height, media_obj.width], - "mime": f"image/{image_format.lower()}", - } - ] - os.remove(temp_image.name) - Resource.add_resource(image_key, media_obj) + else: + media_obj.media_files["image"] = [] + # Handle all formats the same way for alt_image, alt_format in zip(alt_images, alt_formats): alt_name = f"image.{alt_format}" if media_obj.media_files is None: diff --git a/api/main/auth.py b/api/main/auth.py index 93f14d86c..02edeb6a6 100644 --- a/api/main/auth.py +++ b/api/main/auth.py @@ -46,10 +46,11 @@ def authenticate(self, request, username=None, password=None, **kwargs): user.save() lockout_lifted = user.last_failed_login + LOCKOUT_TIME time_left = lockout_lifted - now - msg = f" *SECURITY ALERT:* Attempt to login during lockout" - msg += f" User={user}/{user.id}" - msg += f" Attempt count = {user.failed_login_count}" - msg += f" Lockout will be lifted in '{time_left}' at '{lockout_lifted}'" + msg = ( + f" *SECURITY ALERT:* Attempt to login during lockout: User={user}/{user.id}, " + f" Attempt count = {user.failed_login_count}. Lockout will be lifted in " + f"'{time_left}' at '{lockout_lifted}'" + ) Notify.notify_admin_msg(msg) # Run the default password hasher once to reduce the timing # difference (#20760). @@ -59,30 +60,32 @@ def authenticate(self, request, username=None, password=None, **kwargs): if user.check_password(password) and self.user_can_authenticate(user): user.last_login = datetime.now(timezone.utc) if user.failed_login_count >= LOCKOUT_LIMIT: - msg = "Login proceeded after lock expiry" - msg += f" User={user}/{user.id}" + msg = f"Login proceeded after lock expiry User={user}/{user.id}" Notify.notify_admin_msg(msg) user.failed_login_count = 0 user.save() return user - else: - user.last_failed_login = datetime.now(timezone.utc) - user.failed_login_count += 1 - user.save() - if user.failed_login_count >= LOCKOUT_LIMIT: - msg = f"*SECURITY ALERT:* Bad Login Attempt for {user}/{user.id}" - msg += f" Attempt count = {user.failed_login_count}" - Notify.notify_admin_msg(msg) - # Send an email if the failed login count has been reached. - if (user.failed_login_count == LOCKOUT_LIMIT) and settings.TATOR_EMAIL_ENABLED: - get_email_service().email( - sender=settings.TATOR_EMAIL_SENDER, - recipients=[user.email], - title=f"Tator account has been locked", - text="This message is to notify you that your Tator account (username " - f"{user.username}) has been locked due to {LOCKOUT_LIMIT} failed logins. " - "Your account will be unlocked automatically after 10 minutes, or you " - "can unlock your account now by resetting your password. To reset your " - "password, follow the procedure described here:\n\n" - "https://tator.io/tutorials/2021-06-11-reset-your-password/", - ) + + user.last_failed_login = datetime.now(timezone.utc) + user.failed_login_count += 1 + user.save() + if user.failed_login_count >= LOCKOUT_LIMIT: + msg = ( + f"*SECURITY ALERT:* Bad Login Attempt for {user}/{user.id}. Attempt count = " + f"{user.failed_login_count}" + ) + Notify.notify_admin_msg(msg) + # Send an email if the failed login count has been reached. + email_service = get_email_service() + if user.failed_login_count == LOCKOUT_LIMIT and email_service: + email_service.email( + sender=settings.TATOR_EMAIL_SENDER, + recipients=[user.email], + title=f"Tator account has been locked", + text="This message is to notify you that your Tator account (username " + f"{user.username}) has been locked due to {LOCKOUT_LIMIT} failed logins. " + "Your account will be unlocked automatically after 10 minutes, or you " + "can unlock your account now by resetting your password. To reset your " + "password, follow the procedure described here:\n\n" + "https://tator.io/tutorials/2021-06-11-reset-your-password/", + ) diff --git a/api/main/kube.py b/api/main/kube.py index cf54c506b..863c782ba 100644 --- a/api/main/kube.py +++ b/api/main/kube.py @@ -285,10 +285,12 @@ def __init__(self, alg): api_client = ApiClient(conf) self.corev1 = CoreV1Api(api_client) self.custom = CustomObjectsApi(api_client) + self.host = f'{PROTO}{os.getenv("MAIN_HOST")}' else: load_incluster_config() self.corev1 = CoreV1Api() self.custom = CustomObjectsApi() + self.host = "http://gunicorn-svc:8000" # Read in the manifest. if alg.manifest: @@ -348,11 +350,11 @@ def start_algorithm( }, { "name": "host", - "value": f'{PROTO}{os.getenv("MAIN_HOST")}', + "value": self.host, }, { "name": "rest_url", - "value": f'{PROTO}{os.getenv("MAIN_HOST")}/rest', + "value": f"{self.host}/rest", }, { "name": "rest_token", @@ -360,7 +362,7 @@ def start_algorithm( }, { "name": "tus_url", - "value": f'{PROTO}{os.getenv("MAIN_HOST")}/files/', + "value": f"{self.host}/files/", }, { "name": "project_id", @@ -394,6 +396,21 @@ def start_algorithm( "name": self.alg.name, } + # Set any steps in the templates to disable eviction + for tidx in range(len(manifest["spec"]["templates"])): + if "container" in manifest["spec"]["templates"][tidx]: + metadata = manifest["spec"]["templates"][tidx].get("metadata", {}) + annotations = metadata.get("annotations", {}) + annotations = { + "cluster-autoscaler.kubernetes.io/safe-to-evict": "false", + **annotations, + } + metadata = { + **metadata, + "annotations": annotations, + } + manifest["spec"]["templates"][tidx]["metadata"] = metadata + # Set exit handler that sends an email if email specs are given if success_email_spec is not None or failure_email_spec is not None: manifest["spec"]["onExit"] = "exit-handler" @@ -422,7 +439,7 @@ def start_algorithm( f"Authorization: Token {token}", "-d", json.dumps(success_email_spec), - f'{PROTO}{os.getenv("MAIN_HOST")}/rest/Email/{project}', + f"{self.host}/rest/Email/{project}", ], }, } @@ -450,7 +467,7 @@ def start_algorithm( f"Authorization: Token {token}", "-d", json.dumps(failure_email_spec), - f'{PROTO}{os.getenv("MAIN_HOST")}/rest/Email/{project}', + f"{self.host}/rest/Email/{project}", ], }, } diff --git a/api/main/models.py b/api/main/models.py index bdc75b062..f963f4b18 100644 --- a/api/main/models.py +++ b/api/main/models.py @@ -372,36 +372,32 @@ def get_description(self): ) -@receiver(post_save, sender=User) -def user_save(sender, instance, created, **kwargs): +def block_user_save_email(instance, method, *args, **kwargs): # Create random attribute name with static prefix for determining if this is the root trigger of # this signal attr_prefix = "_saving_" random_attr = f"{attr_prefix}{''.join(random.sample(string.ascii_lowercase, 16))}" + # Adds random attribute to suppress email from save during creation, then removes it + setattr(instance, random_attr, True) + getattr(instance, method)(*args, **kwargs) + delattr(instance, random_attr) + + +@receiver(post_save, sender=User) +def user_save(sender, instance, created, **kwargs): if os.getenv("COGNITO_ENABLED") == "TRUE": if created: - # Adds random attribute to suppress email from save during creation, then removes it - setattr(instance, random_attr, True) - instance.move_to_cognito() - delattr(instance, random_attr) + block_user_save_email(instance, "move_to_cognito") else: TatorCognito().update_attributes(instance) if created: if instance.username: instance.username = instance.username.strip() - - # Adds random attribute to suppress email from save during creation, then removes it - setattr(instance, random_attr, True) - instance.save() - delattr(instance, random_attr) + block_user_save_email(instance, "save") if settings.SAML_ENABLED and not instance.email: instance.email = instance.username - - # Adds random attribute to suppress email from save during creation, then removes it - setattr(instance, random_attr, True) - instance.save() - delattr(instance, random_attr) + block_user_save_email(instance, "save") invites = Invitation.objects.filter(email=instance.email, status="Pending") if (invites.count() == 0) and (os.getenv("AUTOCREATE_ORGANIZATIONS")): organization = Organization.objects.create(name=f"{instance}'s Team") @@ -413,49 +409,53 @@ def user_pre_save(sender, instance, **kwargs): # Prefix for random attribute name to determine if this is the root trigger of this signal attr_prefix = "_saving_" user_desc = instance.get_description() - is_root = all(not attr.startswith(attr_prefix) for attr in dir(instance)) - created = not instance.pk - - if created: - msg = ( - f"You are being notified that a new user {instance} (username {instance.username}, " - f"email {instance.email}) has been added to the Tator deployment with the following " - f"attributes:\n\n{user_desc}" - ) - is_monitored = True - else: - msg = ( - f"You are being notified that an existing user {instance} been modified with the " - f"following values:\n\n{user_desc}" - ) - - # Only send an email if this is the root `post_save` trigger, i.e. does not have a random - # attribute added to it, and is a modification of a monitored field. - original_instance = type(instance).objects.get(pk=instance.id) - monitored_fields = [ - "username", - "first_name", - "last_name", - "email", - "is_staff", - "profile", - "password", - ] - is_monitored = any( - getattr(instance, fieldname, None) != getattr(original_instance, fieldname, None) - for fieldname in monitored_fields - ) + if all(not attr.startswith(attr_prefix) for attr in dir(instance)): + created = not instance.pk + if created: + msg = ( + f"You are being notified that a new user {instance} (username {instance.username}, " + f"email {instance.email}) has been added to the Tator deployment with the " + f"following attributes:\n\n{user_desc}" + ) + is_monitored = True + password_modified = False + else: + msg = ( + f"You are being notified that an existing user {instance} been modified with the " + f"following values:\n\n{user_desc}" + ) - if is_root and is_monitored: - logger.info(msg) - email_service = get_email_service() - if email_service: - email_service.email_staff( - sender=settings.TATOR_EMAIL_SENDER, - title=f"{'Created' if created else 'Modified'} user", - text=msg, + # Only send an email if this is the root `pre_save` trigger, i.e. does not have a random + # attribute added to it, and is a modification of a monitored field. + original_instance = type(instance).objects.get(pk=instance.id) + monitored_fields = [ + "username", + "first_name", + "last_name", + "email", + "is_staff", + "profile", + "password", + ] + password_modified = instance.password != original_instance.password + is_monitored = password_modified or any( + getattr(instance, fieldname, None) != getattr(original_instance, fieldname, None) + for fieldname in monitored_fields ) + if is_monitored: + if password_modified: + instance.failed_login_count = 0 + block_user_save_email(instance, "save") + logger.info(msg) + email_service = get_email_service() + if email_service: + email_service.email_staff( + sender=settings.TATOR_EMAIL_SENDER, + title=f"{'Created' if created else 'Modified'} user", + text=msg, + ) + @receiver(post_delete, sender=User) def user_post_delete(sender, instance, **kwargs): @@ -794,6 +794,7 @@ def project_save(sender, instance, created, **kwargs): if created: make_default_version(instance) add_org_users(instance) + TatorSearch().create_section_index(instance) if instance.thumb: Resource.add_resource(instance.thumb, None) @@ -1409,7 +1410,7 @@ def media_def_iterator(self, keys: List[str] = None) -> Generator[Tuple[str, dic :type keys: List[str] :rtype: Generator[Tuple[str, dict], None, None] """ - whitelisted_keys = [ + accepted_keys = [ "archival", "streaming", "audio", @@ -1424,7 +1425,7 @@ def media_def_iterator(self, keys: List[str] = None) -> Generator[Tuple[str, dic keys = [] for key in keys: - if key not in whitelisted_keys: + if key not in accepted_keys: continue files = self.media_files.get(key, []) if files is None: @@ -1710,8 +1711,8 @@ def file_save(sender, instance, created, **kwargs): @receiver(post_delete, sender=File) def file_post_delete(sender, instance, **kwargs): # Delete the path reference - if not instance.path is None: - safe_delete(instance.path, instance.project.id) + if not (instance.path is None and getattr(instance.path, "name") is None): + safe_delete(instance.path.name, instance.project.id) class Localization(Model, ModelDiffMixin): @@ -1946,6 +1947,10 @@ class Section(Model): name = CharField(max_length=128) """ Name of the section. """ + + path = PathField(null=True, blank=True) + """ Path of the section. Can only have A-Za-z0-9_- in the path name, versus any ASCII for name """ + lucene_search = CharField(max_length=1024, null=True, blank=True) """ Optional lucene query syntax search string. """ @@ -2218,6 +2223,8 @@ class Meta: {"name": "$created_datetime", "dtype": "native"}, {"name": "$modified_datetime", "dtype": "native"}, {"name": "tator_user_sections", "dtype": "section"}, + {"name": "tator_user_sections", "dtype": "section_btree"}, + # {"name": "tator_user_sections", "dtype": "section_uuid_btree"}, # This doesn't work well, because we don't enforce uuids well enough, leaving in for growth {"name": "$restoration_requested", "dtype": "native"}, {"name": "$archive_status_date", "dtype": "native"}, {"name": "$archive_state", "dtype": "native_string"}, diff --git a/api/main/notify.py b/api/main/notify.py index e71d2bdba..6cf1ffcb4 100644 --- a/api/main/notify.py +++ b/api/main/notify.py @@ -12,38 +12,35 @@ class Notify: + @staticmethod def notification_enabled(): """Returns true if notification is enabled""" return settings.TATOR_SLACK_TOKEN and settings.TATOR_SLACK_CHANNEL - def notify_admin_msg(msg): + @classmethod + def notify_admin_msg(cls, msg): """Sends a given message to administrators""" try: - if Notify.notification_enabled(): + if cls.notification_enabled(): client = slack.WebClient(token=settings.TATOR_SLACK_TOKEN) response = client.chat_postMessage(channel=settings.TATOR_SLACK_CHANNEL, text=msg) - if response["ok"]: - return True - else: - return False + return bool(response["ok"]) except: - logger.warning("Slack Comms failed") + logger.warning("Slack Comms failed", exc_info=True) return False - def notify_admin_file(title, content): + @classmethod + def notify_admin_file(cls, title, content): """Send a given file to administrators""" try: - if Notify.notification_enabled(): + if cls.notification_enabled(): client = slack.WebClient(token=settings.TATOR_SLACK_TOKEN) response = client.files_upload( channels=settings.TATOR_SLACK_CHANNEL, content=content, title=title ) - if response["ok"]: - return True - else: - return False + return bool(response["ok"]) except: - logger.warning("Slack Comms failed") + logger.warning("Slack Comms failed", exc_info=True) return False diff --git a/api/main/rest/_annotation_query.py b/api/main/rest/_annotation_query.py index c260ca618..998038ca4 100644 --- a/api/main/rest/_annotation_query.py +++ b/api/main/rest/_annotation_query.py @@ -20,6 +20,7 @@ get_attribute_psql_queryset, get_attribute_psql_queryset_from_query_obj, supplied_name_to_field, + _look_for_section_uuid, ) logger = logging.getLogger(__name__) @@ -154,7 +155,7 @@ def _get_annotation_psql_queryset(project, filter_ops, params, annotation_type): related_object_search = section.related_object_search media_qs = Media.objects.filter(project=project, type=media_type_id) if section_uuid: - media_qs = media_qs.filter(attributes__tator_user_sections=section_uuid) + media_qs = _look_for_section_uuid(media_qs, section_uuid) if object_search: media_qs = get_attribute_psql_queryset_from_query_obj(media_qs, object_search) if related_object_search: @@ -198,7 +199,6 @@ def _get_annotation_psql_queryset(project, filter_ops, params, annotation_type): query = query | Q(media__in=r) qs = qs.filter(query).distinct() - qs = _do_object_search(qs, params) if params.get("encoded_related_search"): search_obj = json.loads( base64.b64decode(params.get("encoded_related_search").encode()).decode() @@ -208,7 +208,7 @@ def _get_annotation_psql_queryset(project, filter_ops, params, annotation_type): for entity_type in related_media_types: media_qs = Media.objects.filter(project=project, type=entity_type) media_qs = get_attribute_psql_queryset_from_query_obj(media_qs, search_obj) - if media_qs.count(): + if media_qs.exists(): related_matches.append(media_qs) if related_matches: related_match = related_matches.pop() @@ -251,8 +251,8 @@ def _get_annotation_psql_queryset(project, filter_ops, params, annotation_type): qs = qs[:stop] # Useful for profiling / checking out query complexity - logger.info(qs.query) - logger.info(qs.explain()) + logger.info(f"QUERY={qs.query}") + logger.info(f"EXPLAIN={qs.explain()}") return qs diff --git a/api/main/rest/_attribute_query.py b/api/main/rest/_attribute_query.py index 066f1420b..79584fd68 100644 --- a/api/main/rest/_attribute_query.py +++ b/api/main/rest/_attribute_query.py @@ -4,8 +4,9 @@ from dateutil.parser import parse as dateutil_parse import pytz import re +import uuid -from django.db.models.functions import Cast, Coalesce +from django.db.models.functions import Cast, Greatest from django.db.models import Func, F, Q, Count, Subquery, OuterRef, Value from django.contrib.gis.db.models import ( BigIntegerField, @@ -14,7 +15,11 @@ DateTimeField, FloatField, PointField, + TextField, + UUIDField, ) +from enumfields import EnumField + from django.contrib.gis.geos import Point from django.contrib.gis.measure import Distance from django.http import Http404 @@ -59,6 +64,18 @@ } +def _sanitize(name): + return re.sub(r"[^a-zA-Z]", "_", name) + + +def _look_for_section_uuid(media_qs, maybe_uuid_val): + media_qs = media_qs.annotate( + section_val=Cast(F("attributes__tator_user_sections"), TextField()) + ) + # Note: This escape is required because of database_qs usage + return media_qs.filter(section_val=f'"{maybe_uuid_val}"') + + def supplied_name_to_field(supplied_name): logger.info(f"SNAME={supplied_name}") if supplied_name.startswith("-"): @@ -89,15 +106,16 @@ def _related_search( project=project, type=entity_type, deleted=False, variant_deleted=False ) state_qs = get_attribute_psql_queryset_from_query_obj(state_qs, search_obj) - if state_qs.count(): + if state_qs.exists(): related_matches.append(state_qs) for entity_type in related_localization_types: local_qs = Localization.objects.filter( project=project, type=entity_type, deleted=False, variant_deleted=False ) local_qs = get_attribute_psql_queryset_from_query_obj(local_qs, search_obj) - if local_qs.count(): + if local_qs.exists(): related_matches.append(local_qs) + if related_matches: # Convert result matches to use Media model because related_matches might be States or Localizations # Note: 'media' becomes 'id' when this happens. The two columns are 'id','count' in this result. @@ -105,56 +123,28 @@ def _related_search( # for any matching metadata. # Finally reselect all media in this concatenated set by id. Annotate the incident with the count from the previous record set, which is # now the sum of any hit across any metadata type. + orig_list = [*related_matches] related_match = related_matches.pop() - media_vals = related_match.values("media").annotate(count=Count("media")) - media_matches = ( - qs.filter(pk__in=media_vals.values("media")) - .annotate( - count=Subquery( - media_vals.filter(media=OuterRef("id")).order_by("-count")[:1].values("count") - ) - ) - .values("id", "count") - ) - logger.info( - f"related matches interim count = {related_match[0]._meta.db_table} this={media_vals} total={media_matches}" - ) - for r in related_matches: - this_vals = r.values("media").annotate(count=Count("media")) - this_run = ( - qs.filter( - Q(pk__in=this_vals.values("media")) | Q(pk__in=media_matches.values("id")) - ) - .annotate( - this_count=Coalesce( - Subquery( - this_vals.filter(media=OuterRef("id")) - .order_by("-count")[:1] - .values("count") - ), - 0, - ), - last_count=Coalesce( - Subquery( - media_vals.filter(media=OuterRef("id")) - .order_by("-count")[:1] - .values("count") - ), - Value(0), - ), - count=F("this_count") + F("last_count"), - ) - .values("id", "count") - ) - media_matches = this_run - logger.info( - f"related matches interim count = {r[0]._meta.db_table} this={this_run} , total={media_matches}" - ) - qs = ( - qs.filter(pk__in=media_matches.values("id")) - .annotate(incident=Subquery(media_matches.filter(id=OuterRef("id")).values("count"))) - .distinct() - ) + # Pop and process the list + media_vals = related_match.values("media") + for related_match in related_matches: + this_vals = related_match.values("media") + media_vals = media_vals.union(this_vals) + + # We now have all the matching media, but lost the score information + # going back to the original set, make a bunch of subqueries to calculate the + # greatest score for a particular media, if there were duplicates + # list comp didn't play nice here, but this is easier to read anyway + score = [] + for x in orig_list: + annotated_x = x.values("media").annotate(count=Count("media")) + filtered_x = annotated_x.filter(media=OuterRef("id")) + values_x = filtered_x.values("count").order_by("-count")[:1] + score.append(Subquery(values_x)) + if len(score) > 1: + qs = qs.filter(pk__in=media_vals.values("media")).annotate(incident=Greatest(*score)) + else: + qs = qs.filter(pk__in=media_vals.values("media")).annotate(incident=score[0]) else: qs = qs.filter(pk=-1).annotate(incident=Value(0)) return qs @@ -192,7 +182,7 @@ def _get_info_for_attribute(entity_type, key): retval = {"name": key[1:], "dtype": "int"} elif key in ["$created_datetime", "$modified_datetime"]: retval = {"name": key[1:], "dtype": "datetime"} - elif key in ["$name"]: + elif key in ["$name", "$elemental_id"]: retval = {"name": key[1:], "dtype": "string"} elif key == "tator_user_sections": retval = {"name": "tator_user_sections", "dtype": "string"} @@ -241,7 +231,11 @@ def _convert_attribute_filter_value(pair, annotation_type, operation): value = dateutil_parse(value) elif dtype == "geopos": distance, lat, lon = value.split("::") - value = (Point(float(lon), float(lat), srid=4326), Distance(km=float(distance)), "spheroid") + value = ( + Point(float(lon), float(lat), srid=4326), + Distance(km=float(distance)), + "spheroid", + ) logger.info(f"{distance}, {lat},{lon}") return key, value, dtype @@ -256,16 +250,18 @@ def get_attribute_filter_ops(params, data_type): return filter_ops -def build_query_recursively(query_object, castLookup, is_media, project): +def build_query_recursively(query_object, castLookup, is_media, project, all_casts): query = Q() if "method" in query_object: method = query_object["method"].lower() - sub_queries = [ - build_query_recursively(x, castLookup, is_media, project) - for x in query_object["operations"] - ] + sub_queries = [] + for x in query_object["operations"]: + query, casts = build_query_recursively(x, castLookup, is_media, project, all_casts) + sub_queries.append(query) + for cast in casts: + all_casts.add(cast) if len(sub_queries) == 0: - return Q() + return Q(), [] if method == "not": if len(sub_queries) != 1: raise (Exception("NOT operator can only be applied to one suboperation")) @@ -295,7 +291,7 @@ def build_query_recursively(query_object, castLookup, is_media, project): media_qs = Media.objects.filter(project=project) section_uuid = section[0].tator_user_sections if section_uuid: - media_qs = media_qs.filter(attributes__tator_user_sections=section_uuid) + media_qs = _look_for_section_uuid(media_qs, section_uuid) if section[0].object_search: media_qs = get_attribute_psql_queryset_from_query_obj( @@ -310,17 +306,18 @@ def build_query_recursively(query_object, castLookup, is_media, project): relevant_localization_type_ids, section[0].related_object_search, ) - if media_qs.count() == 0: + if media_qs.exists() == False: query = Q(pk=-1) elif is_media: query = Q(pk__in=media_qs) else: query = Q(media__in=media_qs) + all_casts.add("tator_user_sections") else: if attr_name.startswith("$"): db_lookup = attr_name[1:] else: - db_lookup = f"attributes__{attr_name}" + db_lookup = f"casted_{_sanitize(attr_name)}" if operation.startswith("date_"): # python is more forgiving then SQL so convert any partial dates to # full-up ISO8601 datetime strings WITH TIMEZONE. @@ -343,25 +340,36 @@ def build_query_recursively(query_object, castLookup, is_media, project): ) castFunc = castLookup.get(attr_name, None) + # NOTE: For string functions avoid the '"' work around due to the django + # string handling bug + # only apply if cast func is active + if castFunc and operation in ["icontains", "iendswith", "istartswith"]: + castFunc = lambda x: x + # Don't use casts for these operations either + if attr_name.startswith("$") == False: + db_lookup = f"attributes__{attr_name}" if operation in ["isnull"]: value = _convert_boolean(value) elif castFunc: value = castFunc(value) else: - return Q(pk=-1) + return Q(pk=-1), [] if operation in ["date_eq", "eq"]: query = Q(**{f"{db_lookup}": value}) else: query = Q(**{f"{db_lookup}__{operation}": value}) + # If we actually use the entity, add it to casts. + if attr_name.startswith("$") is False: + all_casts.add(attr_name) if inverse: query = ~query - return query + return query, all_casts def get_attribute_psql_queryset_from_query_obj(qs, query_object): - if qs.count() == 0: + if qs.exists() == False: return qs.filter(pk=-1) is_media = False @@ -375,24 +383,32 @@ def get_attribute_psql_queryset_from_query_obj(qs, query_object): Leaf: LeafType, File: FileType, } + # NOTE: Usage of database_qs requires escaping string values manually + # Else lookups will result in misses. castLookup = { "bool": _convert_boolean, "int": int, "float": float, - "enum": str, - "string": str, + "enum": lambda x: f'"{x}"', + "string": lambda x: f'"{x}"', "datetime": str, "geopos": lambda x: x, "float_array": None, } + attributeCast = {} + annotateField = {} typeModel = typeLookup[type(qs[0])] - typeObjects = qs.values("type").distinct() - for typeObjectPk in typeObjects: - typeObject = typeModel.objects.get(pk=typeObjectPk["type"]) + typeObjects = typeModel.objects.filter(project=qs[0].project) + for typeObject in typeObjects: for attributeType in typeObject.attribute_types: attributeCast[attributeType["name"]] = castLookup[attributeType["dtype"]] - attributeCast["tator_user_sections"] = str + annotateField[attributeType["name"]], _ = _get_field_for_attribute( + typeObject, attributeType["name"] + ) + + annotateField["tator_user_sections"] = TextField + attributeCast["tator_user_sections"] = lambda x: f'"{x}"' for key in ["$x", "$y", "$u", "$v", "$width", "$height", "$fps"]: attributeCast[key] = float for key in [ @@ -407,10 +423,48 @@ def get_attribute_psql_queryset_from_query_obj(qs, query_object): "$id", ]: attributeCast[key] = int - for key in ["$created_datetime", "$modified_datetime", "$name", "$archive_state"]: + for key in [ + "$created_datetime", + "$modified_datetime", + "$name", + "$archive_state", + "$elemental_id", + ]: attributeCast[key] = str - q_object = build_query_recursively(query_object, attributeCast, is_media, qs[0].project) + q_object, required_annotations = build_query_recursively( + query_object, attributeCast, is_media, qs[0].project, set() + ) + + logger.info(f"Q_Object = {q_object}") + logger.info(f"Query requires the following annotations: {required_annotations}") + for annotation in required_annotations: + logger.info(f"\t {annotation} to {annotateField[annotation]()}") + if annotateField[annotation] == DateTimeField: + # Cast DateTime to text first + qs = qs.annotate( + **{ + f"casted_{_sanitize(annotation)}_text": Cast( + F(f"attributes__{annotation}"), TextField() + ) + } + ) + qs = qs.annotate( + **{ + f"casted_{_sanitize(annotation)}": Cast( + F(f"casted_{_sanitize(annotation)}_text"), + annotateField[annotation](), + ) + } + ) + else: + qs = qs.annotate( + **{ + f"casted_{_sanitize(annotation)}": Cast( + F(f"attributes__{annotation}"), annotateField[annotation]() + ) + } + ) return qs.filter(q_object) @@ -461,8 +515,18 @@ def get_attribute_psql_queryset(entity_type, qs, params, filter_ops): **{f"{alias_key}_typed": Cast(f"{alias_key}_text", DateTimeField())} ) qs = qs.filter(**{f"{alias_key}_typed{OPERATOR_SUFFIXES[op]}": value}) - elif field_type == CharField: - qs = qs.filter(**{f"attributes__{key}{OPERATOR_SUFFIXES[op]}": value}) + elif field_type == CharField or field_type == EnumField: + qs = qs.annotate( + **{f"{alias_key}_typed": Cast(f"attributes__{key}", field_type())} + ) + if OPERATOR_SUFFIXES[op]: + qs = qs.filter(**{f"{alias_key}_typed{OPERATOR_SUFFIXES[op]}": value}) + else: + # BUG: database_qs mangles the SQL and requires this workaround: + # This is only on equal for some reason. + qs = qs.filter( + **{f"{alias_key}_typed{OPERATOR_SUFFIXES[op]}": f'"{value}"'} + ) else: qs = qs.annotate( **{f"{alias_key}_typed": Cast(f"attributes__{key}", field_type())} diff --git a/api/main/rest/_media_query.py b/api/main/rest/_media_query.py index 6a242d244..d2b74da7e 100644 --- a/api/main/rest/_media_query.py +++ b/api/main/rest/_media_query.py @@ -8,6 +8,8 @@ from django.db.models import Q from django.http import Http404 +from django.db.models.functions import Cast +from django.db.models import UUIDField, TextField, F from ..models import LocalizationType, Media, MediaType, Localization, Section, State, StateType @@ -18,6 +20,7 @@ get_attribute_psql_queryset, get_attribute_psql_queryset_from_query_obj, supplied_name_to_field, + _look_for_section_uuid, ) logger = logging.getLogger(__name__) @@ -186,7 +189,7 @@ def _get_media_psql_queryset(project, filter_ops, params): section_uuid = section[0].tator_user_sections if section_uuid: - qs = qs.filter(attributes__tator_user_sections=section_uuid) + qs = _look_for_section_uuid(qs, section_uuid) if section[0].object_search: qs = get_attribute_psql_queryset_from_query_obj(qs, section[0].object_search) diff --git a/api/main/rest/attribute_type.py b/api/main/rest/attribute_type.py index a957c9fa4..b834d2759 100644 --- a/api/main/rest/attribute_type.py +++ b/api/main/rest/attribute_type.py @@ -118,7 +118,6 @@ def _modify_attribute_type(cls, params: Dict, mod_type: str) -> Dict: ts = TatorSearch() old_name = params["current_name"] - old_dtype = None old_attribute_type = None attribute_type_update = params["attribute_type_update"] @@ -128,11 +127,9 @@ def _modify_attribute_type(cls, params: Dict, mod_type: str) -> Dict: # Get the old and new dtypes with transaction.atomic(): entity_type, obj_qs = cls._get_objects(params) - has_related_objects = cls._has_related_objects(entity_type, old_name) for attribute_type in entity_type.attribute_types: if attribute_type["name"] == old_name: - old_dtype = attribute_type["dtype"] old_attribute_type = dict(attribute_type) break else: @@ -199,10 +196,8 @@ def _modify_attribute_type(cls, params: Dict, mod_type: str) -> Dict: if dtype_mutated: if obj_qs.exists(): # Get the new attribute type to convert the existing value - new_attribute = None for attribute_type in entity_type.attribute_types: if attribute_type["name"] == new_name: - new_attribute = attribute_type break if mod_type == "update": @@ -255,7 +250,7 @@ def _post(self, params: Dict) -> Dict: if entity_type.attribute_types: existing_names = [a["name"] for a in entity_type.attribute_types] if attribute_type_update["name"] in existing_names: - raise ValueError(f"{a['name']} is already an attribute.") + raise ValueError(f"{attribute_type_update['name']} is already an attribute.") entity_type.attribute_types.append(attribute_type_update) else: entity_type.attribute_types = [] diff --git a/api/main/rest/clone_media.py b/api/main/rest/clone_media.py index f2926322f..68ad37142 100644 --- a/api/main/rest/clone_media.py +++ b/api/main/rest/clone_media.py @@ -49,7 +49,6 @@ def _post(self, params): os.makedirs(os.path.join("/media", str(dest)), exist_ok=True) # Retrieve media that will be cloned. - response_data = [] original_medias = get_media_queryset(self.kwargs["project"], params) # If there are too many Media to create at once, raise an exception. diff --git a/api/main/rest/media.py b/api/main/rest/media.py index 2f1e53533..39f7b58f1 100644 --- a/api/main/rest/media.py +++ b/api/main/rest/media.py @@ -14,9 +14,6 @@ from django.db.models import Case, When from django.http import Http404 from PIL import Image -import pillow_avif # add AVIF support to pillow -import rawpy -import imageio from ..models import ( Media, @@ -32,7 +29,6 @@ ) from ..schema import MediaListSchema, MediaDetailSchema, parse from ..schema.components import media as media_schema -from ..notify import Notify from ..download import download_file from ..store import get_tator_store, get_storage_lookup from ..cache import TatorCache diff --git a/api/main/rest/password_reset.py b/api/main/rest/password_reset.py index 5ef86bd9b..d37079a54 100644 --- a/api/main/rest/password_reset.py +++ b/api/main/rest/password_reset.py @@ -1,15 +1,11 @@ -import uuid -import os import logging +import uuid -from django.db import transaction from django.conf import settings -from django.http import Http404 -from ..models import PasswordReset -from ..models import User -from ..schema import PasswordResetListSchema from ..mail import get_email_service +from ..models import PasswordReset, User +from ..schema import PasswordResetListSchema from ._base_views import BaseListView @@ -31,17 +27,21 @@ def _post(self, params): raise RuntimeError(f"Email {email} is in use by multiple users!") user = users[0] reset = PasswordReset(user=user, reset_token=uuid.uuid1()) - url = f"{os.getenv('MAIN_HOST')}/password-reset?reset_token={reset.reset_token}&user={user.id}" - if settings.TATOR_EMAIL_ENABLED: - get_email_service().email( + url = f"{settings.PROTO}://{settings.MAIN_HOST}/password-reset?reset_token={reset.reset_token}&user={user.id}" + email_service = get_email_service() + if email_service: + text = ( + f"A password reset has been requested for this email address ({email}). If you did " + f"not initiate the reset this message can be ignored. To reset your password, " + f"please visit: \n\n{url}\n\nThis URL will expire in 24 hours." + ) + failure_msg = f"Unable to send email to {email}! Password reset creation failed." + email_service.email( sender=settings.TATOR_EMAIL_SENDER, recipients=[email], title=f"Tator password reset", - text=f"A password reset has been requested for this email address ({email}). " - f"If you did not initiate the reset this message can be ignored. " - f"To reset your password, please visit: \n\n{url}\n\n" - "This URL will expire in 24 hours.", - raise_on_failure=f"Unable to send email to {email}! Password reset creation failed.", + text=text, + raise_on_failure=failure_msg, ) else: raise RuntimeError( diff --git a/api/main/rest/section.py b/api/main/rest/section.py index e61595cd4..2a7e651dc 100644 --- a/api/main/rest/section.py +++ b/api/main/rest/section.py @@ -1,4 +1,5 @@ import logging +import re import uuid from django.db import transaction @@ -34,12 +35,22 @@ def _get(self, params): # Then construct where clause manually. safe = uuid.UUID(elemental_id) qs = qs.extra(where=[f"elemental_id='{str(safe)}'"]) + + # Just in case something slips by the schema, have a look up table from schema to db operation + op_table = {"match": "match", "ancestors": "ancestors", "descendants": "descendants"} + for schema_key, db_operation in op_table.items(): + value = params.get(schema_key, None) + if value: + # NOTE: we need to escape with ' here because of `database_qs` shenanigans... + qs = qs.filter(**{f"path__{db_operation}": f"'{value}'"}) qs = qs.order_by("name") return database_qs(qs) def _post(self, params): project = params["project"] name = params["name"] + path = params.get("path", name) + path = re.sub(r"[^A-Za-z0-9_.]", "_", path) object_search = params.get("object_search", None) related_search = params.get("related_search", None) tator_user_sections = params.get("tator_user_sections", None) @@ -49,10 +60,14 @@ def _post(self, params): if Section.objects.filter(project=project, name__iexact=params["name"]).exists(): raise Exception("Section with this name already exists!") + if Section.objects.filter(project=project, path__match=path).exists(): + raise Exception("Section with this path already exists!") + project = Project.objects.get(pk=project) section = Section.objects.create( project=project, name=name, + path=path, object_search=object_search, related_object_search=related_search, tator_user_sections=tator_user_sections, @@ -87,6 +102,10 @@ def _patch(self, params): ).exists(): raise Exception("Section with this name already exists!") section.name = params["name"] + if "path" in params: + if Section.objects.filter(project=section.project, path__match=params["path"]).exists(): + raise Exception("Section with this path already exists!") + section.path = params["path"] if "object_search" in params: section.object_search = params["object_search"] if "tator_user_sections" in params: diff --git a/api/main/rest/transcode.py b/api/main/rest/transcode.py index 8c8bbd9a0..1a9135be4 100644 --- a/api/main/rest/transcode.py +++ b/api/main/rest/transcode.py @@ -15,7 +15,6 @@ from ..models import Media from ..schema import TranscodeListSchema from ..schema import TranscodeDetailSchema -from ..notify import Notify from .media import _create_media from ._util import url_to_key @@ -27,9 +26,7 @@ logger = logging.getLogger(__name__) -SCHEME = "https://" if os.getenv("REQUIRE_HTTPS") == "TRUE" else "http://" - -HOST = f"{SCHEME}{os.getenv('MAIN_HOST')}" +HOST = "http://gunicorn-svc:8000" GUNICORN_HOST = os.getenv("GUNICORN_HOST") COMPOSE_DEPLOY = os.getenv("COMPOSE_DEPLOY") if GUNICORN_HOST is not None and COMPOSE_DEPLOY is not None: diff --git a/api/main/schema/components/attribute_type.py b/api/main/schema/components/attribute_type.py index 060783a6c..cc66cff14 100644 --- a/api/main/schema/components/attribute_type.py +++ b/api/main/schema/components/attribute_type.py @@ -13,6 +13,22 @@ "type": "boolean", "default": False, }, + "mode": { + "description": "Change flavor of autocomplete to use built-in WoRMs support. For information on WoRMs see https://www.marinespecies.org/rest/", + "type": "string", + "default": "tator", + "enum": ["tator", "worms"], + }, + "minLevel": { + "description": "If using WoRMS, set this to the minimum returnable taxonomic level" + "See https://www.marinespecies.org/rest/AphiaTaxonRanksByID/-1?AphiaID=2" + " for the levels, Note: 220 is species", + "type": "integer", + }, + "useCommon": { + "description": "If using WoRMS, if set to true, use common names (vernacular in their API)", + "type": "boolean", + }, }, } diff --git a/api/main/schema/components/file.py b/api/main/schema/components/file.py index 418205c79..591f8b3a4 100644 --- a/api/main/schema/components/file.py +++ b/api/main/schema/components/file.py @@ -118,6 +118,13 @@ "description": "Unique integer identifying a FileType.", "schema": {"type": "integer"}, }, + { + "name": file_fields.name, + "in": "query", + "required": False, + "description": "Name of the file.", + "schema": {"type": "string"}, + }, { "name": "after", "in": "query", diff --git a/api/main/schema/components/section.py b/api/main/schema/components/section.py index 4cf5fa18c..3961ac910 100644 --- a/api/main/schema/components/section.py +++ b/api/main/schema/components/section.py @@ -3,6 +3,11 @@ "type": "string", "description": "Unique name of the section.", }, + "path": { + "type": "string", + "description": "A path to represent nested sections. If not supplied, defaults to `re.sub(r'[^A-Za-z0-9_-]',path)`", + "nullable": True, + }, "tator_user_sections": { "type": "string", "description": "Attribute that is applied to media to identify membership to a section.", diff --git a/api/main/schema/section.py b/api/main/schema/section.py index 98fc4fd81..286c99160 100644 --- a/api/main/schema/section.py +++ b/api/main/schema/section.py @@ -14,6 +14,48 @@ """ ) +lquery_docs = """ + - foo Match the exact label path foo + - *.foo.* Match any label path containing the label foo + - *.foo Match any label path whose last label is foo + + Modifiers: + - @ Match case-insensitively, for example a@ matches A + - * Match any label with this prefix, for example foo* matches foobar + - % Match initial underscore-separated words + + American@.Foot@* + + would match both + america.Football and America.footwear + + For more information: https://www.postgresql.org/docs/current/ltree.html +""" +# These are LTREE-based operations we can apply to section paths +section_path_filters = [ + { + "name": "match", + "in": "query", + "required": False, + "description": f"""Find any sections matching using an lquery. \n\n{lquery_docs}""", + "schema": {"type": "string"}, + }, + { + "name": "ancestors", + "in": "query", + "required": False, + "description": f"""Find ancestors using using an lquery. \n\n{lquery_docs}""", + "schema": {"type": "string"}, + }, + { + "name": "descendants", + "in": "query", + "required": False, + "description": f"""Find descendants using using an lquery. \n\n{lquery_docs}""", + "schema": {"type": "string"}, + }, +] + class SectionListSchema(AutoSchema): def get_operation(self, path, method): @@ -57,6 +99,7 @@ def get_filter_parameters(self, path, method): "schema": {"type": "string"}, }, *type_filter_parameter_schema, + *section_path_filters, ] return params diff --git a/api/main/search.py b/api/main/search.py index 779b29523..ead195266 100644 --- a/api/main/search.py +++ b/api/main/search.py @@ -66,6 +66,18 @@ def _get_unique_index_name(entity_type, attribute): type_name_sanitized = entity_type.__class__.__name__.lower() entity_name_sanitized = re.sub(r"[^a-zA-Z0-9]", "_", entity_type.name).lower() attribute_name_sanitized = re.sub(r"[^a-zA-Z0-9]", "_", attribute_name).lower() + + if attribute["dtype"] == "section_btree": + attribute_name_sanitized += "_btree" + if attribute["dtype"] == "section_uuid_btree": + attribute_name_sanitized += "_uuid_btree" + if attribute["dtype"] == "string_btree": + attribute_name_sanitized += "_btree" + if attribute["dtype"] == "native_string_btree": + attribute_name_sanitized += "_btree" + if attribute["dtype"] == "upper_string_btree": + attribute_name_sanitized += "_upper_btree" + if attribute["name"].startswith("$"): # Native fields are only scoped to project, native-string types are project/type bound # Both need to incorporate type name in the name for uniqueness. @@ -87,6 +99,70 @@ def _get_column_name(attribute): return f"attributes->>'{name}'" # embedded in JSONB field +def make_section_path_btree_index( + db_name, + project_id, + index_name, + flush, + concurrent, +): + concurrent_str = "" + if concurrent: + concurrent_str = "CONCURRENTLY" + with get_connection(db_name).cursor() as cursor: + if flush: + cursor.execute( + sql.SQL("DROP INDEX {concurrent} IF EXISTS {index_name}").format( + index_name=sql.Identifier(index_name), concurrent=sql.SQL(concurrent_str) + ) + ) + cursor.execute( + "SELECT tablename,indexname,indexdef from pg_indexes where indexname = %s", + (index_name,), + ) + if bool(cursor.fetchall()): + return + sql_str = sql.SQL( + """CREATE INDEX {concurrent} {index_name} ON main_section + USING btree (path) + WHERE project=%s""" + ).format(index_name=sql.SQL(index_name), concurrent=sql.SQL(concurrent_str)) + cursor.execute(sql_str, (project_id,)) + print(sql_str) + + +def make_section_path_gist_index( + db_name, + project_id, + index_name, + flush, + concurrent, +): + concurrent_str = "" + if concurrent: + concurrent_str = "CONCURRENTLY" + with get_connection(db_name).cursor() as cursor: + if flush: + cursor.execute( + sql.SQL("DROP INDEX {concurrent} IF EXISTS {index_name}").format( + index_name=sql.Identifier(index_name), concurrent=sql.SQL(concurrent_str) + ) + ) + cursor.execute( + "SELECT tablename,indexname,indexdef from pg_indexes where indexname = %s", + (index_name,), + ) + if bool(cursor.fetchall()): + return + sql_str = sql.SQL( + """CREATE INDEX {concurrent} {index_name} ON main_section + USING GIST (path gist_ltree_ops(siglen=100)) + WHERE project=%s""" + ).format(index_name=sql.SQL(index_name), concurrent=sql.SQL(concurrent_str)) + cursor.execute(sql_str, (project_id,)) + print(sql_str) + + def make_btree_index( db_name, project_id, @@ -207,6 +283,41 @@ def make_float_index( ) +def make_string_btree_index( + db_name, project_id, entity_type_id, table_name, index_name, attribute, flush, concurrent +): + col_name = _get_column_name(attribute) + concurrent_str = "" + if concurrent: + concurrent_str = "CONCURRENTLY" + with get_connection(db_name).cursor() as cursor: + if flush: + cursor.execute( + sql.SQL("DROP INDEX {concurrent} IF EXISTS {index_name}").format( + index_name=sql.SQL(index_name), concurrent=sql.SQL(concurrent_str) + ) + ) + cursor.execute( + "SELECT tablename,indexname,indexdef from pg_indexes where indexname = %s", + (index_name,), + ) + if bool(cursor.fetchall()): + return + col_name = _get_column_name(attribute) + sql_str = sql.SQL( + """CREATE INDEX {concurrent} {index_name} ON {table_name} + USING btree (CAST({col_name} AS text)) + WHERE project=%s and meta=%s""" + ).format( + index_name=sql.SQL(index_name), + concurrent=sql.SQL(concurrent_str), + table_name=sql.Identifier(table_name), + col_name=sql.SQL(col_name), + ) + logger.info(sql_str.as_string(cursor)) + cursor.execute(sql_str, (project_id, entity_type_id)) + + def make_string_index( db_name, project_id, entity_type_id, table_name, index_name, attribute, flush, concurrent ): @@ -282,6 +393,41 @@ def make_upper_string_index( print(sql_str.as_string(cursor)) +def make_upper_string_btree_index( + db_name, project_id, entity_type_id, table_name, index_name, attribute, flush, concurrent +): + col_name = _get_column_name(attribute) + concurrent_str = "" + if concurrent: + concurrent_str = "CONCURRENTLY" + with get_connection(db_name).cursor() as cursor: + if flush: + cursor.execute( + sql.SQL("DROP INDEX {concurrently} IF EXISTS {index_name}").format( + index_name=sql.SQL(index_name), concurrent=sql.SQL(concurrent_str) + ) + ) + cursor.execute( + "SELECT tablename,indexname,indexdef from pg_indexes where indexname = %s", + (index_name,), + ) + if bool(cursor.fetchall()): + return + col_name = _get_column_name(attribute) + sql_str = sql.SQL( + """CREATE INDEX {concurrent} {index_name} ON {table_name} + USING btree (UPPER(CAST({col_name} AS text))) + WHERE project=%s and meta=%s""" + ).format( + index_name=sql.SQL(index_name), + concurrent=sql.SQL(concurrent_str), + table_name=sql.Identifier(table_name), + col_name=sql.SQL(col_name), + ) + cursor.execute(sql_str, (project_id, entity_type_id)) + print(sql_str.as_string(cursor)) + + def make_section_index( db_name, project_id, entity_type_id, table_name, index_name, attribute, flush, concurrent ): @@ -319,6 +465,117 @@ def make_section_index( print(sql_str.as_string(cursor)) +def make_section_generic_index( + db_name, + project_id, + entity_type_id, + table_name, + index_name, + attribute, + flush, + concurrent, + cast_type, +): + col_name = _get_column_name(attribute) + concurrent_str = "" + if concurrent: + concurrent_str = "CONCURRENTLY" + with get_connection(db_name).cursor() as cursor: + if flush: + cursor.execute( + sql.SQL("DROP INDEX {concurrent} IF EXISTS {index_name}").format( + index_name=sql.SQL(index_name) + ) + ) + cursor.execute( + "SELECT tablename,indexname,indexdef from pg_indexes where indexname = %s", + (index_name,), + ) + if bool(cursor.fetchall()): + return + col_name = _get_column_name(attribute) + sql_str = sql.SQL( + """CREATE INDEX {concurrent} {index_name} ON {table_name} + USING btree (CAST({col_name} AS {cast_type})) + WHERE project=%s""" + ).format( + index_name=sql.SQL(index_name), + concurrent=sql.SQL(concurrent_str), + cast_type=sql.SQL(cast_type), + table_name=sql.Identifier(table_name), + col_name=sql.SQL(col_name), + ) + cursor.execute(sql_str, (project_id,)) + print(sql_str.as_string(cursor)) + + +def make_section_btree_index( + db_name, project_id, entity_type_id, table_name, index_name, attribute, flush, concurrent +): + make_section_generic_index( + db_name, + project_id, + entity_type_id, + table_name, + index_name, + attribute, + flush, + concurrent, + "text", + ) + + +def make_section_uuid_index( + db_name, project_id, entity_type_id, table_name, index_name, attribute, flush, concurrent +): + make_section_generic_index( + db_name, + project_id, + entity_type_id, + table_name, + index_name, + attribute, + flush, + concurrent, + "uuid", + ) + + +def make_native_string_btree_index( + db_name, project_id, entity_type_id, table_name, index_name, attribute, flush, concurrent +): + col_name = _get_column_name(attribute) + concurrent_str = "" + if concurrent: + concurrent_str = "CONCURRENTLY" + with get_connection(db_name).cursor() as cursor: + if flush: + cursor.execute( + sql.SQL("DROP INDEX {concurrent} IF EXISTS {index_name}").format( + index_name=sql.SQL(index_name), concurrent=sql.SQL(concurrent_str) + ) + ) + cursor.execute( + "SELECT tablename,indexname,indexdef from pg_indexes where indexname = %s", + (index_name,), + ) + if bool(cursor.fetchall()): + return + col_name = _get_column_name(attribute) + sql_str = sql.SQL( + """CREATE INDEX {concurrent} {index_name} ON {table_name} + USING btree ({col_name}) + WHERE project=%s""" + ).format( + index_name=sql.SQL(index_name), + concurrent=sql.SQL(concurrent_str), + table_name=sql.Identifier(table_name), + col_name=sql.SQL(col_name), + ) + cursor.execute(sql_str, (project_id,)) + print(sql_str.as_string(cursor)) + + def make_native_string_index( db_name, project_id, entity_type_id, table_name, index_name, attribute, flush, concurrent ): @@ -493,13 +750,18 @@ class TatorSearch: "float": make_float_index, "enum": make_string_index, "string": make_string_index, + "string_btree": make_string_btree_index, "datetime": make_datetime_index, "geopos": make_geopos_index, "float_array": make_vector_index, "native": make_native_index, "native_string": make_native_string_index, + "native_string_btree": make_native_string_btree_index, "section": make_section_index, + "section_btree": make_section_btree_index, + # "section_uuid_btree": make_section_uuid_index, "upper_string": make_upper_string_index, + "upper_string_btree": make_upper_string_btree_index, } def list_indices(self, project): @@ -551,8 +813,35 @@ def is_index_present(self, entity_type, attribute): result = cursor.fetchall() return bool(result) + def is_index_present_by_name(self, index_name): + with get_connection(connection.settings_dict["NAME"]).cursor() as cursor: + cursor.execute( + "SELECT tablename,indexname,indexdef from pg_indexes where indexname = '{}'".format( + index_name + ) + ) + result = cursor.fetchall() + return bool(result) + def create_psql_index(self, entity_type, attribute, flush=False, concurrent=True): """Create a psql index for the given attribute""" + + # Handle btrees too + if attribute["dtype"] == "string": + temp_attr = {**attribute} + temp_attr["dtype"] = "string_btree" + self.create_psql_index(entity_type, temp_attr, flush, concurrent) + + if attribute["dtype"] == "native_string": + temp_attr = {**attribute} + temp_attr["dtype"] = "native_string_btree" + self.create_psql_index(entity_type, temp_attr, flush, concurrent) + + if attribute["dtype"] == "upper_string": + temp_attr = {**attribute} + temp_attr["dtype"] = "upper_string_btree" + self.create_psql_index(entity_type, temp_attr, flush, concurrent) + index_name = _get_unique_index_name(entity_type, attribute) if self.is_index_present(entity_type, attribute) and flush == False: logger.info(f"Index '{index_name}' already exists.") @@ -594,6 +883,37 @@ def create_mapping(self, entity_type, flush=False, concurrent=True): for attribute in entity_type.attribute_types: self.create_psql_index(entity_type, attribute, flush=flush, concurrent=concurrent) + def create_section_index(self, project, flush=False, concurrent=True): + btree_index_name = f"tator_proj_{project.pk}_internalv2_path_btree" + gist_index_name = f"tator_proj_{project.pk}_internalv2_path_gist" + if self.is_index_present_by_name(btree_index_name) is False or flush is True: + push_job( + "db_jobs", + make_section_path_btree_index, + args=( + connection.settings_dict["NAME"], + project.pk, + btree_index_name, + flush, + concurrent, + ), + result_ttl=0, + ) + + if self.is_index_present_by_name(gist_index_name) is False or flush is True: + push_job( + "db_jobs", + make_section_path_gist_index, + args=( + connection.settings_dict["NAME"], + project.pk, + gist_index_name, + flush, + concurrent, + ), + result_ttl=0, + ) + def rename_alias(self, entity_type, old_name, new_name): """ Adds an alias corresponding to an attribute type rename. Note that the old alias will still diff --git a/api/main/store.py b/api/main/store.py index 81e448a0f..fc9685bc4 100644 --- a/api/main/store.py +++ b/api/main/store.py @@ -449,7 +449,8 @@ def complete_multipart_upload(self, path, parts, upload_id): MultipartUpload={"Parts": parts}, UploadId=upload_id, ) - except Exception: + except Exception as excep: + logger.info(f"Multipart failed: {excep}") return False return True diff --git a/api/main/tests.py b/api/main/tests.py index 58449ecbe..22dc10508 100644 --- a/api/main/tests.py +++ b/api/main/tests.py @@ -12,6 +12,7 @@ import requests import io import base64 +import unittest from main.models import * @@ -55,7 +56,22 @@ def _fixture_teardown(self): def wait_for_indices(entity_type): built_ins = BUILT_IN_INDICES.get(type(entity_type), []) - for attribute in [*entity_type.attribute_types, *built_ins]: + types_to_scan = [*entity_type.attribute_types, *built_ins] + # Wait for btree indices too + for t in types_to_scan: + if t["dtype"] == "string": + new_obj = {**t} + new_obj["dtype"] = "string_btree" + types_to_scan.append(new_obj) + if t["dtype"] == "native_string": + new_obj = {**t} + new_obj["dtype"] = "native_string_btree" + types_to_scan.append(new_obj) + if t["dtype"] == "upper_string": + new_obj = {**t} + new_obj["dtype"] = "upper_string_btree" + types_to_scan.append(new_obj) + for attribute in types_to_scan: found_it = False for i in range(1, 600): if TatorSearch().is_index_present(entity_type, attribute) == True: @@ -265,6 +281,28 @@ def create_test_box(user, entity_type, project, media, frame, attributes={}): ) +def make_box_obj(user, entity_type, project, media, frame, attributes={}): + x = random.uniform(0.0, float(media.width)) + y = random.uniform(0.0, float(media.height)) + w = random.uniform(0.0, float(media.width) - x) + h = random.uniform(0.0, float(media.height) - y) + return Localization( + user=user, + created_by=user, + modified_by=user, + type=entity_type, + project=project, + version=project.version_set.all()[0], + media=media, + frame=frame, + x=x, + y=y, + width=w, + height=h, + attributes=attributes, + ) + + def create_test_box_with_attributes(user, entity_type, project, media, frame, attributes): test_box = create_test_box(user, entity_type, project, media, frame) test_box.attributes.update(attributes) @@ -345,7 +383,7 @@ def create_test_attribute_types(): dict( name="Enum Test", dtype="enum", - choices=["enum_val1", "enum_val2", "enum_val3"], + choices=["enum_val1", "enum_val2", "enum_val3", "enum_val4"], default="enum_val1", ), dict( @@ -2087,59 +2125,131 @@ def test_search(self): response = self.client.get(f"/rest/Medias/{self.project.pk}", format="json") assert response.data[0].get("incident", None) == None - create_test_box( - self.user, box_type, self.project, self.entities[0], 0, {"String Test": "Foo"} - ) - create_test_box( - self.user, box_type, self.project, self.entities[0], 0, {"String Test": "Foo"} - ) - create_test_box( - self.user, box_type, self.project, self.entities[0], 0, {"String Test": "Foo"} + print("About to create a bunch of boxes") + # Make a whole bunch of boxes to make sure indices get utilized + boxes = [] + foo_box = make_box_obj( + self.user, + box_type, + self.project, + self.entities[0], + 0, + { + "String Test": "Foo", + "Enum Test": "enum_val1", + "Int Test": 1, + "Float Test": 1.0, + "Bool Test": True, + }, ) + boxes.append(foo_box) + boxes.append(foo_box) + boxes.append(foo_box) - create_test_box( - self.user, box_type, self.project, self.entities[1], 0, {"String Test": "Foo"} + box = make_box_obj( + self.user, + box_type, + self.project, + self.entities[1], + 0, + { + "String Test": "Foo", + "Enum Test": "enum_val1", + "Int Test": 1, + "Float Test": 1.0, + "Bool Test": True, + }, ) - create_test_box( - self.user, box_type, self.project, self.entities[1], 0, {"String Test": "Bar"} + boxes.append(box) + boxes.append( + make_box_obj( + self.user, + box_type, + self.project, + self.entities[1], + 0, + { + "String Test": "Bar", + "Enum Test": "enum_val2", + "Int Test": 2, + "Float Test": 2.0, + "Bool Test": False, + }, + ) ) - create_test_box( - self.user, box_type, self.project, self.entities[1], 0, {"String Test": "Baz"} + boxes.append( + make_box_obj( + self.user, + box_type, + self.project, + self.entities[1], + 0, + { + "String Test": "Baz", + "Enum Test": "enum_val3", + "Int Test": 3, + "Float Test": 3.0, + "Bool Test": False, + }, + ) ) - create_test_box( - self.user, box_type, self.project, self.entities[1], 0, {"String Test": "Zoo"} + boxes.append( + make_box_obj( + self.user, + box_type, + self.project, + self.entities[1], + 0, + { + "String Test": "Zoo", + "Enum Test": "enum_val4", + "Int Test": 4, + "Float Test": 4.0, + "Bool Test": False, + }, + ) ) + Localization.objects.bulk_create(boxes) - response = self.client.get( - f"/rest/Localizations/{self.project.pk}?attribute=String Test::Foo", format=json - ) - self.assertEqual(len(response.data), 4) - encoded_search = base64.b64encode( - json.dumps({"attribute": "String Test", "operation": "eq", "value": "Foo"}).encode() - ) - response = self.client.get( - f"/rest/Medias/{self.project.pk}?encoded_related_search={encoded_search.decode()}&sort_by=-$incident", - format="json", - ) + searches = [ + ["String Test", "Foo"], + ["Int Test", 1], + ["Float Test", 1.0], + ["Bool Test", True], + ] + for key, value in searches: + response = self.client.get( + f"/rest/Localizations/{self.project.pk}?attribute={key}::{value}", format=json + ) + self.assertEqual(len(response.data), 4) + encoded_search = base64.b64encode( + json.dumps({"attribute": key, "operation": "eq", "value": value}).encode() + ) + response = self.client.get( + f"/rest/Medias/{self.project.pk}?encoded_related_search={encoded_search.decode()}&sort_by=-$incident", + format="json", + ) + from pprint import pprint - first_hit = response.data[0] - second_hit = response.data[1] - self.assertEqual(first_hit.get("incident", None), 3) - self.assertEqual(first_hit["id"], self.entities[0].pk) - self.assertEqual(second_hit.get("incident", None), 1) - self.assertEqual(second_hit["id"], self.entities[1].pk) + pprint(response.data) + first_hit = response.data[0] + second_hit = response.data[1] + self.assertEqual(first_hit.get("incident", None), 3) + self.assertEqual(first_hit["id"], self.entities[0].pk) + self.assertEqual(second_hit.get("incident", None), 1) + self.assertEqual(second_hit["id"], self.entities[1].pk) - # reverse it - response = self.client.get( - f"/rest/Medias/{self.project.pk}?encoded_related_search={encoded_search.decode()}&sort_by=$incident", - format="json", - ) - first_hit = response.data[0] - second_hit = response.data[1] - self.assertEqual(second_hit.get("incident", None), 3) - self.assertEqual(second_hit["id"], self.entities[0].pk) - self.assertEqual(first_hit.get("incident", None), 1) - self.assertEqual(first_hit["id"], self.entities[1].pk) + # reverse it + response = self.client.get( + f"/rest/Medias/{self.project.pk}?encoded_related_search={encoded_search.decode()}&sort_by=$incident", + format="json", + ) + first_hit = response.data[0] + second_hit = response.data[1] + self.assertEqual(second_hit.get("incident", None), 3) + self.assertEqual(second_hit["id"], self.entities[0].pk) + self.assertEqual(first_hit.get("incident", None), 1) + self.assertEqual(first_hit["id"], self.entities[1].pk) def test_author_change(self): test_video = create_test_video(self.user, f"asdf_0", self.entity_type, self.project) @@ -5177,3 +5287,146 @@ def test_create_case_insensitive_username(self): url = f"/rest/{self.list_uri}" response = self.client.post(url, user_spec, format="json") self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + +class SectionTestCase(TatorTransactionTest): + def setUp(self): + print(f"\n{self.__class__.__name__}=", end="", flush=True) + logging.disable(logging.CRITICAL) + self.user = create_test_user() + self.client.force_authenticate(self.user) + self.project = create_test_project(self.user) + self.membership = create_test_membership(self.user, self.project) + + def test_unique_section_name(self): + section_spec = { + "name": "Winterfell summer vacation photos", + "tator_user_sections": uuid.uuid4(), + } + url = f"/rest/Sections/{self.project.pk}" + response = self.client.post(url, section_spec, format="json") + assertResponse(self, response, status.HTTP_201_CREATED) + + # Verify section with the same name can't be created. + response = self.client.post(url, section_spec, format="json") + assertResponse(self, response, status.HTTP_400_BAD_REQUEST) + + section_spec = { + "name": "Twin Pines Mall", + "tator_user_sections": uuid.uuid4(), + "path": "California.Hill_Valley", + } + url = f"/rest/Sections/{self.project.pk}" + response = self.client.post(url, section_spec, format="json") + assertResponse(self, response, status.HTTP_201_CREATED) + + section_spec = { + "name": "Lone Pine Mall", + "tator_user_sections": uuid.uuid4(), + "path": "California.Hill_Valley", + } + # Verify section with the same path can't be created. + response = self.client.post(url, section_spec, format="json") + assertResponse(self, response, status.HTTP_400_BAD_REQUEST) + + def test_section_lookup(self): + section_spec = { + "name": "Honda Civic EX-L", + "tator_user_sections": uuid.uuid4(), + "path": "Honda.Civic.EX-L", + } + url = f"/rest/Sections/{self.project.pk}" + response = self.client.post(url, section_spec, format="json") + + assertResponse(self, response, status.HTTP_201_CREATED) + + ################################################################### + # Make a bunch of test data + ################################################################### + # Honda + # - Accord + # + Sport + # + EX-L + # - Civic + # + Sport + # + EX-L + # - CR-V + # + Sport-L + # Ford + # - Mustang + ################################################################### + + section_spec = { + "name": "Honda Accord EX-L", + "tator_user_sections": uuid.uuid4(), + "path": "Honda.Accord.EX-L", + } + response = self.client.post(url, section_spec, format="json") + assertResponse(self, response, status.HTTP_201_CREATED) + + section_spec = { + "name": "Honda Civic Sport", + "tator_user_sections": uuid.uuid4(), + "path": "Honda.Civic.Sport", + } + response = self.client.post(url, section_spec, format="json") + assertResponse(self, response, status.HTTP_201_CREATED) + + section_spec = { + "name": "Honda Accord Sport", + "tator_user_sections": uuid.uuid4(), + "path": "Honda.Accord.Sport", + } + response = self.client.post(url, section_spec, format="json") + assertResponse(self, response, status.HTTP_201_CREATED) + + section_spec = { + "name": "Honda CR-V Sport-L", + "tator_user_sections": uuid.uuid4(), + "path": "Honda.CR-V.Sport-L", + } + response = self.client.post(url, section_spec, format="json") + assertResponse(self, response, status.HTTP_201_CREATED) + + section_spec = { + "name": "Ford Mustang", + "tator_user_sections": uuid.uuid4(), + "path": "Ford.Mustang", + } + response = self.client.post(url, section_spec, format="json") + assertResponse(self, response, status.HTTP_201_CREATED) + + ##################################### + ## Query the test data + ###################################### + + section_spec = { + "name": "Ford Mustang", + "tator_user_sections": uuid.uuid4(), + "path": "Ford.Mustang", + } + response = self.client.get(f"{url}?match=Ford.Mustang", format="json") + assertResponse(self, response, status.HTTP_200_OK) + self.assertEqual(len(response.data), 1) + + response = self.client.get(f"{url}?match=Tesla.Model3", format="json") + assertResponse(self, response, status.HTTP_200_OK) + self.assertEqual(len(response.data), 0) + + # Match all Hondas + response = self.client.get(f"{url}?match=Honda.*", format="json") + assertResponse(self, response, status.HTTP_200_OK) + self.assertEqual(len(response.data), 5) + + # Match all Hondas with EX-L trims + response = self.client.get(f"{url}?match=Honda.*.EX_L", format="json") + assertResponse(self, response, status.HTTP_200_OK) + self.assertEqual(len(response.data), 2) + + response = self.client.get(f"{url}?ancestors=Honda.Accord", format="json") + assertResponse(self, response, status.HTTP_200_OK) + self.assertEqual(len(response.data), 0) + + response = self.client.get(f"{url}?descendants=Honda.Accord", format="json") + assertResponse(self, response, status.HTTP_200_OK) + self.assertEqual(len(response.data), 2) diff --git a/api/main/views.py b/api/main/views.py index 706163202..264eebb4c 100644 --- a/api/main/views.py +++ b/api/main/views.py @@ -7,23 +7,17 @@ from django.core.exceptions import PermissionDenied from django.http import HttpResponse from django.http import JsonResponse -from rest_framework.authtoken.models import Token from django.contrib.auth.models import AnonymousUser from django.conf import settings from django.template.response import TemplateResponse from rest_framework.authentication import TokenAuthentication -import yaml from .models import Project from .models import Membership -from .models import Affiliation -from .models import Invitation -from .models import User from .notify import Notify from .cache import TatorCache -import os import logging import sys @@ -34,10 +28,7 @@ def check_login(request): - if request.user.is_authenticated: - return JsonResponse({"is_authenticated": True}) - else: - return JsonResponse({"is_authenticated": False}) + return JsonResponse({"is_authenticated": bool(request.user.is_authenticated)}) class LoginRedirect(View): @@ -65,7 +56,7 @@ def get_context_data(self, **kwargs): # Check if user is part of project. if not project.has_user(self.request.user.pk): - raise PermissionDenied + raise PermissionDenied(f"User {self.request.user} does not have access to {project.id}") return context @@ -132,16 +123,14 @@ def dispatch(self, request, *args, **kwargs): user = request.user if isinstance(user, AnonymousUser): try: - (user, token) = TokenAuthentication().authenticate(request) - except Exception as e: + user, _ = TokenAuthentication().authenticate(request) + except Exception: msg = "*Security Alert:* " msg += f"Bad credentials presented for '{original_url}' ({user})" Notify.notify_admin_msg(msg) - logger.warn(msg) + logger.warn(msg, exc_info=True) return HttpResponse(status=403) - filename = os.path.basename(original_url) - project = None try: comps = original_url.split("/") @@ -150,21 +139,19 @@ def dispatch(self, request, *args, **kwargs): project_id = comps[3] project = Project.objects.get(pk=project_id) authorized = validate_project(user, project) - except Exception as e: - logger.info(f"ERROR: {e}") + except Exception: + logger.info("Could not validate project access", exc_info=True) authorized = False if authorized: return HttpResponse(status=200) - else: - # Files that aren't in the whitelist or database are forbidden - msg = f"({user}/{user.id}): " - msg += f"Attempted to access unauthorized file '{original_url}'" - msg += f". " - msg += f"Does not have access to '{project}'" - Notify.notify_admin_msg(msg) - return HttpResponse(status=403) + # Files that aren't in the whitelist or database are forbidden + msg = ( + f"({user}/{user.id}): Attempted to access unauthorized file '{original_url}'; does not " + f"have access to '{project}'" + ) + Notify.notify_admin_msg(msg) return HttpResponse(status=403) @@ -183,23 +170,18 @@ def dispatch(self, request, *args, **kwargs): user = request.user if isinstance(user, AnonymousUser): try: - (user, token) = TokenAuthentication().authenticate(request) - except Exception as e: - msg = "*Security Alert:* " - msg += f"Bad credentials presented for '{original_url}'" + user, _ = TokenAuthentication().authenticate(request) + except Exception: + msg = f"*Security Alert:* Bad credentials presented for '{original_url}'" Notify.notify_admin_msg(msg) return HttpResponse(status=403) if user.is_staff: return HttpResponse(status=200) - else: - # Files that aren't in the whitelist or database are forbidden - msg = f"({user}/{user.id}): " - msg += f"Attempted to access unauthorized URL '{original_url}'" - msg += f"." - Notify.notify_admin_msg(msg) - return HttpResponse(status=403) + # Files that aren't in the whitelist or database are forbidden + msg = f"({user}/{user.id}): Attempted to access unauthorized URL '{original_url}'." + Notify.notify_admin_msg(msg) return HttpResponse(status=403) @@ -214,11 +196,10 @@ def ErrorNotifierView(request, code, message, details=None): # Generate slack message if Notify.notification_enabled(): - msg = f"{request.get_host()}:" - msg += f" ({request.user}/{request.user.id})" - msg += f" caused {code} at {request.get_full_path()}" + user = request.user + msg = f"{request.get_host()}: ({user}/{user.id}) caused {code} at {request.get_full_path()}" if details: - Notify.notify_admin_file(msg, msg + "\n" + details) + Notify.notify_admin_file(msg, f"{msg}\n{details}") else: if code == 404 and isinstance(request.user, AnonymousUser): logger.warn(msg) diff --git a/api/tator_online/__init__.py b/api/tator_online/__init__.py index 77f3ac84f..26859a99e 100644 --- a/api/tator_online/__init__.py +++ b/api/tator_online/__init__.py @@ -1,3 +1,2 @@ from .middleware import StatsdMiddleware -from .middleware import AuditMiddleware from .middleware import KeycloakMiddleware diff --git a/api/tator_online/middleware.py b/api/tator_online/middleware.py index c1dc6a455..d83b955fb 100644 --- a/api/tator_online/middleware.py +++ b/api/tator_online/middleware.py @@ -43,51 +43,6 @@ def process_response(self, request, response): return response -HOST = "http://audit-svc" - - -class AuditMiddleware: - def __init__(self, get_response): - self.get_response = get_response - self.enabled = os.getenv("AUDIT_ENABLED") - - def __call__(self, request): - # Create an audit record - if self.enabled: - r = requests.post( - f"{HOST}/rest", - json={ - "method": request.method, - "uri": request.path, - "query": QueryDict(request.META["QUERY_STRING"]), - "headers": dict(request.headers), - "user": request.user.id, - }, - ) - if r.status_code != 200: - raise RuntimeError("Failed to create audit record!") - record = r.json() - - # Process the request - start_time = time.time() - response = self.get_response(request) - duration = int(1000 * (time.time() - start_time)) - - # Update the audit record - if self.enabled: - r = requests.patch( - f"{HOST}/rest/{record['id']}", - json={ - "status": response.status_code, - "duration": duration, - }, - ) - if r.status_code != 200: - raise RuntimeError("Failed to update audit record!") - - return response - - class KeycloakMiddleware(KeycloakAuthenticationMixin): def __init__(self, get_response): self.get_response = get_response diff --git a/api/tator_online/settings.py b/api/tator_online/settings.py index 1eb446dfc..0b7618607 100644 --- a/api/tator_online/settings.py +++ b/api/tator_online/settings.py @@ -10,9 +10,7 @@ https://docs.djangoproject.com/en/2.1/ref/settings/ """ -import json import os -import socket from django.contrib.messages import constants as messages import yaml @@ -36,6 +34,8 @@ # Whether keycloak is being used for authentication KEYCLOAK_ENABLED = os.getenv("KEYCLOAK_ENABLED") == "TRUE" +STATSD_ENABLED = os.getenv("STATSD_ENABLED", "TRUE") == "TRUE" + # Application definition INSTALLED_APPS = [ @@ -78,6 +78,7 @@ "django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.common.CommonMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", ] + ( [ @@ -86,12 +87,16 @@ if KEYCLOAK_ENABLED else [ "django.middleware.csrf.CsrfViewMiddleware", - "django.contrib.auth.middleware.AuthenticationMiddleware", ] ) + + ( + [ + "tator_online.StatsdMiddleware", + ] + if STATSD_ENABLED + else [] + ) + [ - "tator_online.StatsdMiddleware", - "tator_online.AuditMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", ] diff --git a/containers/tator/Dockerfile b/containers/tator/Dockerfile index e2e0df648..a71e15b40 100644 --- a/containers/tator/Dockerfile +++ b/containers/tator/Dockerfile @@ -1,4 +1,4 @@ -FROM ubuntu:20.04 +FROM ubuntu:22.04 MAINTAINER CVision AI ARG APT_REPO_HOST=http://archive.ubuntu.com/ubuntu/ @@ -7,9 +7,24 @@ RUN sed -i "s;http://archive.ubuntu.com/ubuntu/;${APT_REPO_HOST};" /etc/apt/sour # Install apt packages RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ - python3 python3-pip libgraphviz-dev xdot \ - python3-setuptools python3-dev gcc libgdal-dev git vim curl libffi-dev \ - ffmpeg wget xmlsec1 python3-magic cron && rm -rf /var/lib/apt/lists + cron \ + curl \ + ffmpeg \ + gcc \ + git \ + libffi-dev \ + libgdal-dev \ + libgraphviz-dev \ + python3 \ + python3-dev \ + python3-magic \ + python3-pip \ + python3-setuptools \ + vim \ + wget \ + xdot \ + xmlsec1 \ + && rm -rf /var/lib/apt/lists/* # Install fork of openapi-core that works in DRF views WORKDIR /working @@ -21,8 +36,6 @@ RUN rm -rf /working/openapi-core # Install pip packages RUN python3 -m pip --no-cache-dir --timeout=1000 install --upgrade pip -RUN pip3 --no-cache-dir --timeout=1000 install wheel==0.38.1 -RUN pip3 --no-cache-dir --timeout=1000 install pyyaml==5.3.1 COPY containers/tator/requirements.txt requirements.txt RUN pip3 --no-cache-dir --timeout=1000 install -r requirements.txt RUN rm requirements.txt diff --git a/containers/tator/requirements.txt b/containers/tator/requirements.txt index bd5091df2..14c510f82 100644 --- a/containers/tator/requirements.txt +++ b/containers/tator/requirements.txt @@ -1,4 +1,5 @@ -boto3==1.20.41 +boto3==1.28.32 +cryptography==41.0.4 datadog==0.43.0 django==3.2.20 django_admin_json_editor==0.2.3 @@ -8,11 +9,10 @@ django-extensions==3.1.5 django-ltree==0.5.3 djangorestframework==3.13.1 elasticsearch==7.10.1 -gevent==1.4.0 +gevent==23.9.1 google-auth==2.3.3 google-cloud-storage==2.1.0 grafana-django-saml2-auth==3.9.0 -greenlet==0.4.15 gunicorn==20.1.0 hiredis==2.0.0 imageio==2.22.2 @@ -20,10 +20,10 @@ jsonschema==4.9.1 kubernetes==21.7.0 markdown==3.3.6 minio==7.1.5 -oci==2.89.0 -okta-jwt-verifier==0.2.3 +oci==2.110.0 +okta-jwt-verifier==0.2.4 pgvector==0.1.8 -pillow==9.5.0 +pillow==10.0.1 pillow-avif-plugin==1.2.2 pillow-heif==0.11.1 progressbar2==4.0.0 @@ -38,6 +38,7 @@ pyparsing==3.0.7 pytest-django==4.5.2 pytest-xdist==3.1.0 python-dateutil==2.8.2 +pyyaml==6.0.1 rawpy==0.17.2 redis==4.4.4 requests==2.31.0 @@ -45,3 +46,4 @@ rq==1.11.1 slackclient==2.9.3 ujson==5.4.0 uritemplate==4.1.1 +wheel==0.38.1 diff --git a/containers/tator_client/Dockerfile b/containers/tator_client/Dockerfile index 17418979b..d2fce1e32 100644 --- a/containers/tator_client/Dockerfile +++ b/containers/tator_client/Dockerfile @@ -1,4 +1,4 @@ -FROM cvisionai/svt_encoder:v0.0.10 AS cvtranscoder +FROM cvisionai/svt_encoder:v0.0.11 AS cvtranscoder MAINTAINER CVision AI ARG APT_REPO_HOST=http://archive.ubuntu.com/ubuntu/ @@ -10,7 +10,7 @@ ENV DEBIAN_FRONTEND=noninteractive RUN apt-get update && apt-get install -y --no-install-recommends \ python3 python3-pip \ python3-setuptools git vim curl unzip wget \ - fastjar libsm6 libxext6 libxrender1 libx265-179 libx264-155 \ + fastjar libsm6 libxext6 libxrender1 libx265-199 libx264-163 \ libpng16-16 libfreetype6 python3-opencv \ && rm -rf /var/lib/apt/lists @@ -20,13 +20,15 @@ RUN ldconfig # Install pip packages RUN pip3 --no-cache-dir --timeout=1000 install wheel -RUN pip3 --no-cache-dir --timeout=1000 install pillow==9.5.0 imageio==2.14.0 progressbar2==4.0.0 boto3==1.20.41 pandas==1.4.0 rq==1.11.1 +COPY containers/tator_client/requirements.txt requirements.txt +RUN pip3 --no-cache-dir --timeout=1000 install -r requirements.txt +RUN rm requirements.txt # Copy over scripts COPY scripts/transcoder /scripts COPY scripts/packages/tator-py/dist/*.whl /tmp -# Build tator-py +# Install tator-py RUN pip3 install /tmp/*.whl WORKDIR /scripts diff --git a/containers/tator_client/requirements.txt b/containers/tator_client/requirements.txt new file mode 100644 index 000000000..197bbaeb5 --- /dev/null +++ b/containers/tator_client/requirements.txt @@ -0,0 +1,6 @@ +boto3==1.28.32 +imageio==2.14.0 +pandas==1.4.0 +pillow==10.0.1 +progressbar2==4.0.0 +rq==1.11.1 diff --git a/containers/tator_transcode b/containers/tator_transcode index ede1ad341..7d5a7a75a 160000 --- a/containers/tator_transcode +++ b/containers/tator_transcode @@ -1 +1 @@ -Subproject commit ede1ad3410db55a83afc4479d3d6187f520c1ef0 +Subproject commit 7d5a7a75a86dc5a5953042e341af1914271a0eb9 diff --git a/containers/tator_ui/Dockerfile b/containers/tator_ui/Dockerfile index d324fb3a6..fa3a5b0b6 100644 --- a/containers/tator_ui/Dockerfile +++ b/containers/tator_ui/Dockerfile @@ -1,4 +1,4 @@ -FROM node:20.3.1-alpine +FROM node:20.5.1-alpine COPY scripts/packages/tator-js/pkg/dist /tator_online/scripts/packages/tator-js/pkg/dist COPY ui /tator_online/ui diff --git a/example-env b/example-env index d618622cc..17adf03d6 100644 --- a/example-env +++ b/example-env @@ -66,3 +66,7 @@ USE_MIN_JS=true # Whether this is a compose deployment (always true for OSS) COMPOSE_DEPLOY=true + +STATSD_ENABLED=false + +AUDIT_ENABLED=false \ No newline at end of file diff --git a/scripts/packages/tator-js b/scripts/packages/tator-js index 41b9e3b61..8c0929a42 160000 --- a/scripts/packages/tator-js +++ b/scripts/packages/tator-js @@ -1 +1 @@ -Subproject commit 41b9e3b6125f5c9f891cec6019e741cf27b76a72 +Subproject commit 8c0929a42794606b97453343a07524c2c9e65816 diff --git a/scripts/packages/tator-py b/scripts/packages/tator-py index fd277af3f..204e689e9 160000 --- a/scripts/packages/tator-py +++ b/scripts/packages/tator-py @@ -1 +1 @@ -Subproject commit fd277af3f83383341266daf412dfdda87b0ff174 +Subproject commit 204e689e9a4e6b58639f8538307f42cbdeb210e6 diff --git a/test/test_organization_settings.py b/test/test_organization_settings.py index bfb4308fe..f41adcd47 100644 --- a/test/test_organization_settings.py +++ b/test/test_organization_settings.py @@ -29,6 +29,8 @@ def test_organization_settings(page_factory, project, launch_time, image_file, b page.wait_for_selector(f'text="Organization {organization_id} updated successfully!"') print(f'Organization {organization_id} updated successfully!') + + # Invitation Tests print("Testing invitation create...") url = base_url + "/rest/Invitations/" + str(organization_id) @@ -75,7 +77,7 @@ def test_organization_settings(page_factory, project, launch_time, image_file, b print("Confirming invitation status") page.click('#nav-for-Invitation') - link = page.locator(".SideNav-subItem ").filter(has_text=f"{user_email}") + link = page.locator(".SideNav-subItem ").filter(has_text=f" {user_email}") link.click() page.wait_for_selector(f'org-type-invitation-container[form="invitation-edit"] text-input[name="Status"] input') statusInputValue = page.eval_on_selector(f'org-type-invitation-container[form="invitation-edit"] text-input[name="Status"] input', "i => i.value") @@ -83,6 +85,33 @@ def test_organization_settings(page_factory, project, launch_time, image_file, b assert statusInputValue == "Accepted" print("Invitation status shown as accepted!") + # Multiple invitation Tests + print("Testing 3+ invitations, with 1 repeat create...") + url = base_url + "/rest/Invitations/" + str(organization_id) + page.click('#nav-for-Invitation #sub-nav--plus-link') + user_email1 = 'no-reply'+str(organization_id)+'1@cvisionai.com' # NEW + user_email2 = 'no-reply'+str(organization_id)+'2@cvisionai.com' # NEW + #user_email = 'no-reply'+str(organization_id)+'@cvisionai.com' # DUPE + user_email3 = 'no-reply'+str(organization_id)+'3@cvisionai.com' # NEW + user_email4 = 'no-reply'+str(organization_id)+'4@cvisionai.com' # NEW + user_email5 = 'no-reply'+str(organization_id)+'5@cvisionai.com' # NEW + page.wait_for_selector('org-type-invitation-container[form="invitation-edit"]') + page.wait_for_timeout(1000) + page.select_option(f'org-type-invitation-container[form="invitation-edit"] enum-input[name="Permission"] select', label="Member") + page.fill(f'#invitation-edit--form email-list-input input', user_email1+';'+user_email2+';'+user_email+';'+user_email3+';'+user_email4+';'+user_email5+';') + page.wait_for_load_state("networkidle") + page.wait_for_timeout(1000) + # with page.expect_response(lambda response: response.url==url and response.status==201) as response_info: + page.keyboard.press("Enter") + page.wait_for_timeout(1000) + for _ in range(3): + page.keyboard.press("Tab") + page.click('org-type-invitation-container[form="invitation-edit"] input[type="submit"]') + page.wait_for_timeout(1000) + page.wait_for_selector('text="Successfully added 5 Invitations."') + print(f'Multiple invitations sent successfully! (Successfully added 5 Invitations. And Error for 1 pending did not interupt flow)') + + print("Testing affiliation create...") url = base_url + "/rest/Affiliations/" + str(organization_id) page.click('#nav-for-Affiliation') @@ -96,7 +125,7 @@ def test_organization_settings(page_factory, project, launch_time, image_file, b for _ in range(3): page.keyboard.press("Tab") page.keyboard.press("Enter") - page.wait_for_selector(f'text="Successfully added 1 Affiliations."') + page.wait_for_selector(f'text="Successfully added 1 Affiliation."') response = response_info.value respObject = response.json() print(respObject) diff --git a/test/test_playback.py b/test/test_playback.py index aed081afb..51c7f9599 100644 --- a/test/test_playback.py +++ b/test/test_playback.py @@ -577,8 +577,11 @@ def test_concat(page_factory, project, concat_test): # Pause the video play_button.click() _wait_for_color(page, canvas, 0, timeout=30, name='seek (pause)') - - page.close() + + try: + page.close() + except Exception as err: + print(f"Error closing page during test_concat: {err}") """ This test would be good, but doesn't work because playback isn't performant enough in test runner diff --git a/test/test_settings.py b/test/test_settings.py index d38450e60..e2b60a6c8 100644 --- a/test/test_settings.py +++ b/test/test_settings.py @@ -1,9 +1,15 @@ import os +import re +import string +import random import inspect import pytest from ._common import print_page_error +def generate_random_string(length): + characters = string.ascii_letters + string.digits + return ''.join(random.choice(characters) for i in range(length)) ## Status: In progress # Goals: # - Edited: Project; Todo: Assert changes stick single edits @@ -89,6 +95,7 @@ def test_settings_localizationTypes(page_factory, project): page.wait_for_timeout(5000) page.close() +@pytest.mark.flaky(reruns=2) def test_settings_leafType(page_factory, project, base_url): print("Leaf Type Tests...") page = page_factory(f"{os.path.basename(__file__)}__{inspect.stack()[0][3]}") @@ -117,9 +124,9 @@ def test_settings_leafType(page_factory, project, base_url): page.click('modal-dialog input[type="submit"]') page.wait_for_selector(f'text="New attribute type \'String Type\' added"') print("Confirmed leaf type attribute was added!") - + page.wait_for_timeout(5000) - # Add leafs with attr value + # Add leafs A and B page.click('type-form-container[form="leaf-type-edit"] .edit-project__h1 a') page.wait_for_timeout(5000) page.click('text=" New Leaf"') @@ -134,6 +141,7 @@ def test_settings_leafType(page_factory, project, base_url): page.click('modal-dialog input[value="Save"]') # This element should have the draggable attribute value as true + page.reload() page.wait_for_timeout(5000) leaf_elems = page.query_selector_all('.leaves-edit span[draggable="true"]') src_elem = leaf_elems[1] @@ -243,7 +251,7 @@ def test_settings_stateTypes(page_factory, project): page.close() -def test_settings_projectMemberships(page_factory, project): +def test_settings_projectMemberships(page_factory, project, launch_time, base_url): print("Membership Tests...") page = page_factory(f"{os.path.basename(__file__)}__{inspect.stack()[0][3]}") page.goto(f"/{project}/project-settings", wait_until='networkidle') @@ -254,6 +262,10 @@ def test_settings_projectMemberships(page_factory, project): page.wait_for_selector('#nav-for-Membership .SubItems .SideNav-subItem >> nth=1') page.wait_for_timeout(5000) subItems = page.query_selector_all('#nav-for-Membership .SubItems .SideNav-subItem') + + # How many memberships are there? + membersBase = len(subItems) + username = subItems[0].inner_text() subItem = subItems[0] @@ -268,18 +280,74 @@ def test_settings_projectMemberships(page_factory, project): page.select_option(f'#membership-edit--form enum-input[name="Default version"] select', label='Baseline') page.click(f'type-form-container[form="membership-edit"] input[type="submit"]') page.wait_for_selector(f'text="Membership {memberId} successfully updated!"') - print(f"Membershipship id {memberId} updated successfully!") - - #todo... reference org settings, use a membership from those tests to actually add new - page.click('#nav-for-Membership #sub-nav--plus-link') + print(f"Membership id {memberId} updated successfully!") + + #test using a list of 3+ memberships at the same time + print("Going to organizations to get a member list...") + page.goto(f"/organizations", wait_until='networkidle') page.wait_for_timeout(5000) - print("Testing re-adding current membership...") - page.fill('#membership-edit--form user-input[name="Search users"] input', username+';') + links = page.query_selector_all('.projects__link') + last_index = len(links) - 1 + link = links[last_index] + href = link.get_attribute('href') + print(f"href {href}") + organization_id = int(href.split('/')[-2]) + link.click() + + # Invitation Tests + url = base_url + "/rest/Invitations/" + str(organization_id) + idList = ["1","2","3","4","5","6","7"] + emailList = [] + for count in idList: + page.goto(f"/{organization_id}/organization-settings#Invitation-New", wait_until='networkidle') + user_email = 'no-reply'+str(organization_id)+str(count)+generate_random_string(6)+'@cvisionai.com' + emailList.append(user_email) + page.wait_for_selector('org-type-invitation-container[form="invitation-edit"]') + page.wait_for_timeout(1000) + page.select_option(f'org-type-invitation-container[form="invitation-edit"] enum-input[name="Permission"] select', label="Member") + page.fill(f'#invitation-edit--form email-list-input input', user_email) + page.wait_for_load_state("networkidle") + page.wait_for_timeout(1000) + # with page.expect_response(lambda response: response.url==url and response.status==201) as response_info: + page.keyboard.press("Enter") + page.wait_for_timeout(1000) + for _ in range(3): + page.keyboard.press("Tab") + page.click('org-type-invitation-container[form="invitation-edit"] input[type="submit"]') + page.wait_for_timeout(1000) + page.wait_for_selector("#invitation-edit--reg-link") + registration_link = page.query_selector("#invitation-edit--reg-link").get_attribute("href") + registration_link = re.sub(r'https?://.*?(/.*)', r'{base_url}\1', registration_link).format(base_url=base_url) + new_user_id = page.query_selector('org-type-invitation-container[form="invitation-edit"] #type-form-id').inner_text() + print(f'Invitation count {count} sent successfully!') + + page.goto(registration_link, wait_until='networkidle') + page.wait_for_timeout(1000) + page.fill('text-input[name="First name"] input', 'First') + page.fill('text-input[name="Last name"] input', 'Last') + page.fill('text-input[name="Email address"] input', user_email) + page.fill('text-input[name="Password"] input', '123!@#abc123') + page.fill('text-input[name="Password (confirm)"] input', '123!@#abc123') + page.fill('text-input[name="First name"] input', 'Name') + page.fill('text-input[name="Username"] input', 'NoReply'+str(organization_id)+count+generate_random_string(6)) #username must be unique + page.click('input[type="submit"]') + page.wait_for_selector(f'text="Continue"') + + print(f"Testing... emailList: {';'.join(emailList)}") + page.goto(f"/{project}/project-settings#Membership-New", wait_until='networkidle') + page.wait_for_timeout(1000) + emailListString = ';'.join(emailList) + page.fill('#membership-edit--form user-input[name="Search users"] input', emailListString) page.select_option('#membership-edit--form enum-input[name="Default version"] select', label='Test Version') page.click('type-form-container[form="membership-edit"] input[type="submit"]') - page.wait_for_selector(f'text=" Error"') - print(f"Membership endpoint hit (error) re-adding current user successfully!") - + page.wait_for_timeout(1000) + # page.wait_for_selector(f'text=" Success"') + + + membersNow = len(page.query_selector_all('a[href^="#Membership"]')) + print(f'{membersNow} == ({membersBase} + 7)') + assert membersNow == (membersBase + 7) + print(f"7 Memberships added successfully!") page.close() def test_settings_versionTests(page_factory, project): diff --git a/ui/package-lock.json b/ui/package-lock.json index 97a77c043..f0957d9f1 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -2351,9 +2351,9 @@ } }, "node_modules/postcss": { - "version": "8.4.22", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.22.tgz", - "integrity": "sha512-XseknLAfRHzVWjCEtdviapiBtfLdgyzExD50Rg2ePaucEesyh8Wv4VPdW0nbyDa1ydbrAxV19jvMT4+LFmcNUA==", + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", "dev": true, "funding": [ { @@ -5345,9 +5345,9 @@ } }, "postcss": { - "version": "8.4.22", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.22.tgz", - "integrity": "sha512-XseknLAfRHzVWjCEtdviapiBtfLdgyzExD50Rg2ePaucEesyh8Wv4VPdW0nbyDa1ydbrAxV19jvMT4+LFmcNUA==", + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", "dev": true, "requires": { "nanoid": "^3.3.6", diff --git a/ui/server/server.js b/ui/server/server.js index f565de6ac..75f8e391b 100644 --- a/ui/server/server.js +++ b/ui/server/server.js @@ -16,8 +16,6 @@ const argv = yargs(process.argv.slice(2)) .alias('e', 'email_enabled') .alias('o', 'okta_enabled') .alias('k', 'keycloak_enabled') - .alias('m', 'prelogin_message') - .alias('r', 'prelogin_redirect') .boolean('e') .boolean('o') .boolean('k') @@ -27,14 +25,10 @@ const argv = yargs(process.argv.slice(2)) .describe('e', 'Include this argument if email is enabled in the backend.') .describe('o', 'Include this argument if Okta is enabled for authentication.') .describe('k', 'Include this argument if Keycloak is enabled for authentication.') - .describe('m', 'Message to display at /prelogin.') - .describe('r', 'Redirect path for the accept button at /prelogin.') .default('h', 'localhost') .default('p', 3000) .default('b', '') .default('k', false) - .default('m', '') - .default('r', '') .argv const params = { @@ -42,8 +36,6 @@ const params = { email_enabled: argv.email_enabled, okta_enabled: argv.okta_enabled, keycloak_enabled: argv.keycloak_enabled, - prelogin_message: argv.prelogin_message.replaceAll("COLON", ":").split("\\n"), - prelogin_redirect: argv.prelogin_redirect, }; nunjucks.configure('server/views', { @@ -155,10 +147,6 @@ app.get('/callback', (req, res) => { res.render('callback', params); }); -app.get('/prelogin', (req, res) => { - res.render('prelogin', params); -}); - app.post('/exchange', async (req, res) => { const body = new URLSearchParams(); body.append('grant_type', 'authorization_code'); @@ -194,6 +182,8 @@ app.post('/exchange', async (req, res) => { res.cookie("access_token", data.access_token, options); options.path = "/admin"; res.cookie("access_token", data.access_token, options); + options.path = "/bespoke"; + res.cookie("access_token", data.access_token, options); res.setHeader("Access-Control-Allow-Credentials", 'true'); res.status(200).json({ access_token: data.access_token, @@ -247,6 +237,8 @@ app.get('/refresh', async (req, res) => { res.cookie("access_token", data.access_token, options); options.path = "/admin"; res.cookie("access_token", data.access_token, options); + options.path = "/bespoke"; + res.cookie("access_token", data.access_token, options); res.status(200).json({ access_token: data.access_token, expires_in: data.expires_in, diff --git a/ui/server/views/prelogin.html b/ui/server/views/prelogin.html deleted file mode 100644 index e26d77738..000000000 --- a/ui/server/views/prelogin.html +++ /dev/null @@ -1,18 +0,0 @@ - - -Tator | Notice - - - -
-
-

- {% for item in prelogin_message %} - {{ item }}
- {% endfor %} -

- Accept -
-
- - diff --git a/ui/server/views/tator-base.html b/ui/server/views/tator-base.html index e1897dcca..f7bc99769 100644 --- a/ui/server/views/tator-base.html +++ b/ui/server/views/tator-base.html @@ -3,8 +3,8 @@ diff --git a/ui/src/css/components/_annotation.scss b/ui/src/css/components/_annotation.scss index 4a09f2723..aeb5d7199 100644 --- a/ui/src/css/components/_annotation.scss +++ b/ui/src/css/components/_annotation.scss @@ -2,6 +2,23 @@ flex-grow: 1; } +.annotation-subheader { + color: $color-white; + background-color: $color-charcoal--light; + box-sizing: border-box; + height: 32px; + width: 100%; +} + +.annotation-subheader-close { + background-color: transparent; + border: none; + &:hover, + &:focus { + color: $color-white; + } +} + .annotation__breadcrumbs { button { margin-left: $spacing-3; @@ -147,7 +164,7 @@ save-dialog { transform: scale(0.95); transition-duration: 0.25s; transition-property: transform, opacity; - z-index: 2; + z-index: 4; &.is-open { opacity: 1; pointer-events: initial; @@ -174,7 +191,7 @@ modify-track-dialog { transform: scale(0.95); transition-duration: 0.25s; transition-property: transform, opacity; - z-index: 2; + z-index: 4; &.is-open { opacity: 1; pointer-events: initial; @@ -338,6 +355,7 @@ favorite-button { } .annotation__video-player { + z-index: 2; display: flex; flex-direction: column; flex-grow: 1; @@ -543,10 +561,37 @@ annotation-player { background-color: #4a4eae; color: #ffffff; } +.dark-page-tab { + align-items: center; + background-color: $color-charcoal--dark; + color: #a2afcd; + cursor: pointer; + display: flex; + height: 30px; + justify-content: center; + transition-duration: 0.25s; + transition-property: background-color, color; + border-bottom: 1px solid #262e3d; +} +.dark-page-tab:focus { + outline: none; +} +.dark-page-tab.active, +.dark-page-tab:hover, +.dark-page-tab:focus { + color: $color-white; + background-color: $color-charcoal--dark; + border-bottom: 1px solid #ffffff; +} .box-border { border: 1px solid #262e3d; } + +.dark-box-border { + border: 1px solid #151b28; +} + .purple-box-border { border: 2px solid #4a4eae; } @@ -593,7 +638,7 @@ annotation-player { position: absolute; z-index: 10; border: 2px solid black; - background-color: rgba(0, 0, 0, 0.9); + background-color: $color-charcoal--dark; padding: 2px; } @@ -602,8 +647,7 @@ annotation-player { overflow: initial; position: absolute; z-index: 10; - border: 2px solid black; - background-color: rgba(0, 0, 0, 0.9); + background-color: $color-charcoal--dark; padding: 2px; } diff --git a/ui/src/css/components/_tooltip.scss b/ui/src/css/components/_tooltip.scss index 02ea0f13b..c4e2807da 100644 --- a/ui/src/css/components/_tooltip.scss +++ b/ui/src/css/components/_tooltip.scss @@ -15,7 +15,7 @@ top: calc(100% + 8px); width: auto; max-width: 400px; - z-index: 1; + z-index: 3; img { max-width: 200px; diff --git a/ui/src/js/analytics/dashboards/dashboard-portal.js b/ui/src/js/analytics/dashboards/dashboard-portal.js index abde56166..06848cbca 100644 --- a/ui/src/js/analytics/dashboards/dashboard-portal.js +++ b/ui/src/js/analytics/dashboards/dashboard-portal.js @@ -97,7 +97,8 @@ export class DashboardPortal extends TatorPage { if ( dashboard.categories == null || (!dashboard.categories.includes("annotator-menu") && - !dashboard.categories.includes("annotator-tools")) + !dashboard.categories.includes("annotator-tools") && + !dashboard.categories.includes("annotator-canvas")) ) { this._insertDashboardSummary(dashboard); } diff --git a/ui/src/js/analytics/files/files-page.js b/ui/src/js/analytics/files/files-page.js index 1e202a882..93ac69cae 100644 --- a/ui/src/js/analytics/files/files-page.js +++ b/ui/src/js/analytics/files/files-page.js @@ -376,7 +376,7 @@ export class FilesPage extends TatorPage { this._loading.style.display = "block"; const fileListPromise = fetchCredentials( - `/rest/Files/${this._projectId}?meta=${fileType.id}` + `/rest/Files/${this._projectId}?type=${fileType.id}` ); fileListPromise.then((response) => { const fileListData = response.json(); diff --git a/ui/src/js/annotation/annotation-browser.js b/ui/src/js/annotation/annotation-browser.js index fc7196380..9c0938a98 100644 --- a/ui/src/js/annotation/annotation-browser.js +++ b/ui/src/js/annotation/annotation-browser.js @@ -380,6 +380,16 @@ export class AnnotationBrowser extends TatorElement { } } } + + /** + * @param {Object} evt + * event emitted from annotation-data "freshData" + */ + updateData(evt) { + for (const dataTypeId in this._entityPanels) { + this._entityPanels[dataTypeId].updateData(evt); + } + } } customElements.define("annotation-browser", AnnotationBrowser); diff --git a/ui/src/js/annotation/annotation-data.js b/ui/src/js/annotation/annotation-data.js index e6714c9ca..7cdd14e6b 100644 --- a/ui/src/js/annotation/annotation-data.js +++ b/ui/src/js/annotation/annotation-data.js @@ -44,6 +44,7 @@ export class AnnotationData extends HTMLElement { this._version = version; this._projectId = projectId; + this._mediaId = mediaId; if (update) { for (const dataType of dataTypes) { diff --git a/ui/src/js/annotation/annotation-filter-dialog.js b/ui/src/js/annotation/annotation-filter-dialog.js index 257d2ac39..4381d918e 100644 --- a/ui/src/js/annotation/annotation-filter-dialog.js +++ b/ui/src/js/annotation/annotation-filter-dialog.js @@ -112,14 +112,14 @@ export class AnnotationFilterDialog extends ModalDialog { set dataType(val) { this._dataType = val; - this._isLocalization = "dtype" in val; + this._isLocalization = !val.dtype.includes("state"); } set data(data) { this._td = new ModelDataConverter(data.project, data); let excludeList = []; let excludeCategories = ["Medias"]; if (this._dataType) { - if (data.isLocalization) { + if (this._isLocalization) { excludeCategories.push( ...["MediaStates", "LocalizationStates", "FrameStates"] ); diff --git a/ui/src/js/annotation/annotation-header.js b/ui/src/js/annotation/annotation-header.js new file mode 100644 index 000000000..517890585 --- /dev/null +++ b/ui/src/js/annotation/annotation-header.js @@ -0,0 +1,54 @@ +import { TatorElement } from "../components/tator-element.js"; + +export class AnnotationHeader extends TatorElement { + constructor() { + super(); + + const header = document.createElement("header"); + header.setAttribute( + "class", + "annotation-subheader d-flex flex-items-center flex-justify-between mb-1" + ); + this._shadow.appendChild(header); + + this._titleText = document.createElement("div"); + this._titleText.setAttribute( + "class", + "d-flex flex-row flex-items-center h3 text-white py-2 px-2" + ); + this._titleText.style.margin = "auto"; + header.appendChild(this._titleText); + + this._close = document.createElement("button"); + this._close.setAttribute( + "class", + "px-3 py-1 d-flex flex-items-center f4 text-uppercase text-gray annotation-subheader-close" + ); + this._close.innerHTML = ` +
Exit
+ + + + `; + header.appendChild(this._close); + + this._close.addEventListener("click", () => { + this._close.blur(); + this.dispatchEvent(new Event("close")); + }); + } + + static get observedAttributes() { + return ["title"]; + } + + attributeChangedCallback(name, oldValue, newValue) { + switch (name) { + case "title": + this._titleText.textContent = newValue; + break; + } + } +} + +customElements.define("annotation-header", AnnotationHeader); diff --git a/ui/src/js/annotation/annotation-image.js b/ui/src/js/annotation/annotation-image.js index 593878293..75c79ef9d 100644 --- a/ui/src/js/annotation/annotation-image.js +++ b/ui/src/js/annotation/annotation-image.js @@ -162,6 +162,13 @@ export class AnnotationImage extends TatorElement { updateAllLocalizations() { this._image.updateAllLocalizations(); } + + /** + * Place holder for annotation-page.js. The current frame is generally assumed to be 0. + */ + goToFrame(frame) { + return; + } } if (!customElements.get("annotation-image")) { diff --git a/ui/src/js/annotation/annotation-multi.js b/ui/src/js/annotation/annotation-multi.js index 17ea10207..83f83056c 100644 --- a/ui/src/js/annotation/annotation-multi.js +++ b/ui/src/js/annotation/annotation-multi.js @@ -1469,6 +1469,7 @@ export class AnnotationMulti extends TatorElement { this._primaryVideoIndex = this._longest_idx; for (let idx = 0; idx < video_info.length; idx++) { setup_video(idx, info[idx]); + this._videos[idx].style.zIndex = "unset"; if (this._frameOffsets[idx] != 0) { const searchParams = new URLSearchParams(window.location.search); let frameInit = 0; diff --git a/ui/src/js/annotation/annotation-page.js b/ui/src/js/annotation/annotation-page.js index c53ae4591..bc56e7250 100644 --- a/ui/src/js/annotation/annotation-page.js +++ b/ui/src/js/annotation/annotation-page.js @@ -26,6 +26,7 @@ export class AnnotationPage extends TatorPage { ); const user = this._header._shadow.querySelector("header-user"); user.parentNode.insertBefore(header, user); + this._headerDiv.style.zIndex = 3; const div = document.createElement("div"); div.setAttribute("class", "d-flex flex-items-center"); @@ -55,9 +56,11 @@ export class AnnotationPage extends TatorPage { this._success = document.createElement("success-light"); this._lightSpacer.appendChild(this._success); + this._success.style.zIndex = 3; this._warning = document.createElement("warning-light"); this._lightSpacer.appendChild(this._warning); + this._warning.style.zIndex = 3; this._versionButton = document.createElement("version-button"); settingsDiv.appendChild(this._versionButton); @@ -65,9 +68,49 @@ export class AnnotationPage extends TatorPage { this._settings = document.createElement("annotation-settings"); settingsDiv.appendChild(this._settings); + this._canvasAppletHeader = document.createElement("annotation-header"); + this._canvasAppletHeader.setAttribute( + "class", + "d-flex flex-items-center flex-justify-between f3" + ); + this._canvasAppletHeader.style.display = "none"; + this._shadow.appendChild(this._canvasAppletHeader); + + this._canvasAppletPageWrapper = document.createElement("div"); + this._shadow.appendChild(this._canvasAppletPageWrapper); + + this._outerMain = document.createElement("main"); + this._outerMain.setAttribute("class", "d-flex"); + this._shadow.appendChild(this._outerMain); + this._main = document.createElement("main"); this._main.setAttribute("class", "d-flex"); - this._shadow.appendChild(this._main); + this._outerMain.appendChild(this._main); + + this._canvasAppletHeader.addEventListener("close", () => { + this.exitCanvasApplet(); + }); + + this._canvasAppletMenu = document.createElement("div"); + this._canvasAppletMenu.setAttribute( + "class", + "annotation-canvas-overlay-menu d-flex flex-row flex-items-center flex-justify-between rounded-2 box-border" + ); + this._canvasAppletMenu.style.display = "none"; + this._main.appendChild(this._canvasAppletMenu); + + var menuDiv = document.createElement("div"); + menuDiv.setAttribute("class", "h3 px-2 py-3 mb-2"); + menuDiv.textContent = "Applets"; + this._canvasAppletMenu.appendChild(menuDiv); + + this._canvasAppletMenuLoading = document.createElement("div"); + this._canvasAppletMenuLoading.setAttribute( + "class", + "text-gray f3 pb-3 pl-2 pr-6" + ); + this._canvasAppletMenuLoading.textContent = "Initializing applets..."; + this._canvasAppletMenu.appendChild(this._canvasAppletMenuLoading); this._versionDialog = document.createElement("version-dialog"); this._main.appendChild(this._versionDialog); @@ -237,6 +280,7 @@ export class AnnotationPage extends TatorPage { this._breadcrumbs.setAttribute("media-name", data.name); this._browser.mediaInfo = data; this._undo.mediaInfo = data; + this._currentFrame = 0; fetchCredentials("/rest/MediaType/" + data.type, {}, true) .then((response) => response.json()) @@ -1030,16 +1074,29 @@ export class AnnotationPage extends TatorPage { memberships ); this._data.addEventListener("freshData", (evt) => { + this._browser.updateData(evt); + if (this._newEntityId) { for (const elem of evt.detail.data) { if (elem.id == this._newEntityId) { + this._newEntity = elem; this._browser.selectEntity(elem); if (this._player.selectTimelineData) { this._player.selectTimelineData(elem); } + // If the page is in canvas applet mode, let the applet know + // there is a fresh batch of data, it might've invoked it. + if (this._currentCanvasApplet != null) { + this._currentCanvasApplet.newData( + this._newEntity, + evt.detail.typeObj + ); + } + this._newEntityId = null; + this._newEntity = null; break; } } @@ -1103,6 +1160,14 @@ export class AnnotationPage extends TatorPage { this._sidebar.localizationTypes = byType; this._sidebar.trackTypes = trackTypes; + + this._sidebar.addEventListener("canvasApplet", (evt) => { + if (this._canvasAppletMenu.style.display == "none") { + this.showCanvasAppletMenu(); + } else { + this.hideCanvasAppletMenu(); + } + }); this._sidebar.addEventListener("default", (evt) => { this.clearMetaCaches(); canvas.defaultMode(); @@ -1129,6 +1194,7 @@ export class AnnotationPage extends TatorPage { canvas.addEventListener("frameChange", (evt) => { this._browser.frameChange(evt.detail.frame); this._settings.setAttribute("frame", evt.detail.frame); + this._currentFrame = evt.detail.frame; // TODO: tempting to call '_updateURL' here but may be a performance bottleneck }); @@ -1161,8 +1227,10 @@ export class AnnotationPage extends TatorPage { // when the data is retrieved (ie freshData event) if (evt.detail.method == "POST") { this._newEntityId = evt.detail.id; + this._newEntity = null; // Updates in "freshData" } else { this._newEntityId = null; + this._newEntity = null; } this._data.updateTypeLocal( @@ -1244,6 +1312,7 @@ export class AnnotationPage extends TatorPage { canvas.handleSliderChange(evt); }); this._browser.addEventListener("frameChange", (evt) => { + this._currentFrame = evt.detail.frame; if ("track" in evt.detail) { canvas.selectTrack(evt.detail.track, evt.detail.frame); } else { @@ -1309,7 +1378,7 @@ export class AnnotationPage extends TatorPage { favorites ); this._settings.setAttribute("version", this._version.id); - this._main.appendChild(save); + this._outerMain.appendChild(save); this._saves[dataType] = save; save.addEventListener("cancel", () => { @@ -1335,7 +1404,7 @@ export class AnnotationPage extends TatorPage { favorites ); this._settings.setAttribute("version", this._version.id); - this._main.appendChild(save); + this._outerMain.appendChild(save); this._saves[dataType.id] = save; // For states specifically, if we are using the multi-view, we will @@ -1381,7 +1450,6 @@ export class AnnotationPage extends TatorPage { requestObj, metaMode ); - this._makePreview(objDescription, dragInfo, canvasPosition); } }); @@ -1390,7 +1458,12 @@ export class AnnotationPage extends TatorPage { this._sidebar.modeChange(evt.detail.newMode, evt.detail.metaMode); }); - this._setupContextMenuDialogs(canvas, canvasElement, stateTypes); + this._setupContextMenuDialogs( + canvas, + canvasElement, + stateTypes, + favorites + ); canvas.addEventListener("maximize", () => { document.documentElement.requestFullscreen(); @@ -1405,7 +1478,17 @@ export class AnnotationPage extends TatorPage { ); } - _setupAnnotatorApplets(canvas, canvasElement) { + /** + * + * @param {AnnotationCanvas} canvas + * Annotation canvas object class to add the context menu and toolbar applets to + * @param {HTMLElement} canvasElement + * element containing the frame image(s) + * Used for size information + * @param {array} favorites + * List of Tator.Favorites associated with the user + */ + _setupAnnotatorApplets(canvas, canvasElement, favorites) { // Setup the menu applet dialog that will be loaded whenever the user right click menu selects // a registered applet this._menuAppletDialog = document.createElement("menu-applet-dialog"); @@ -1434,6 +1517,8 @@ export class AnnotationPage extends TatorPage { .then((response) => response.json()) .then((applets) => { this._appletMap = {}; + this._canvasApplets = {}; + var canvasAppletObjects = []; for (let applet of applets) { if (applet.categories == null) { @@ -1466,6 +1551,14 @@ export class AnnotationPage extends TatorPage { // Add the applet to the dialog this._menuAppletDialog.saveApplet(applet); canvas.addAppletToMenu(applet.name, applet.categories); + } else if (applet.categories.includes("annotator-canvas")) { + // #TODO Future work. Enable canvas applets for multiview. + if (this._player.mediaType.dtype == "multi") { + continue; + } + + // Add canvas applet + canvasAppletObjects.push(applet); } // Init for annotator tools applets if (applet.categories.includes("annotator-tools")) { @@ -1498,11 +1591,86 @@ export class AnnotationPage extends TatorPage { this._appletMap[applet.name] = applet; } + + // + // Setup the canvas applets + // + this._numCanvasApplets = canvasAppletObjects.length; + if (this._numCanvasApplets == 0) { + this._sidebar.disableCanvasApplet(); + } + + this._mediaCanvas = null; + if ("_video" in this._canvas) { + this._mediaCanvas = this._canvas._video; + } + if ("_image" in this._canvas) { + this._mediaCanvas = this._canvas._image; + } + if (this._mediaCanvas == null) { + this._sidebar.disableCanvasApplet(); + return; + } + this._annotationCanvas = canvas; + this._canvasElement = canvasElement; + + var canvasAppletInitPromises = []; + for (const applet of canvasAppletObjects) { + // Create the canvas applet + const appletInterface = document.createElement( + "canvas-applet-wrapper" + ); + appletInterface.style.display = "none"; + appletInterface.style.height = "100vh"; + canvasAppletInitPromises.push( + appletInterface.init(applet, this._data, favorites, this._undo) + ); + this._canvasAppletPageWrapper.appendChild(appletInterface); + this._canvasApplets[applet.id] = appletInterface; + } + + Promise.all(canvasAppletInitPromises).then(() => { + this._canvasAppletMenuLoading.style.display = "none"; + + // #TODO Add alphabetical ordering + for (const appletId in this._canvasApplets) { + const appletInterface = this._canvasApplets[appletId]; + + // Preload the canvas applets with the current image to speed things up + if (this._mediaType.dtype == "image") { + this._mediaCanvas.getPNGdata(false).then((blob) => { + appletInterface.updateFrame(0, blob); + }); + } + + // Add the applet to the toolbar menu option + const div = document.createElement("div"); + div.style.width = "400px"; + div.setAttribute( + "class", + "annotation-canvas-overlay-menu-option text-gray d-flex flex-grow px-2 py-2 flex-items-center text-left" + ); + div.innerHTML = ` +
+ ${appletInterface.getIcon()} +
+
+
${appletInterface.getTitle()}
+
${appletInterface.getDescription()}
+
+ `; + this._canvasAppletMenu.appendChild(div); + + div.addEventListener("click", () => { + this.showCanvasApplet(appletId); + }); + } + }); }); } - _setupContextMenuDialogs(canvas, canvasElement, stateTypes) { - this._setupAnnotatorApplets(canvas, canvasElement); + _setupContextMenuDialogs(canvas, canvasElement, stateTypes, favorites) { + this._setupAnnotatorApplets(canvas, canvasElement, favorites); // This is a bit of a hack, but the modals will share the same // methods used by the save localization dialogs since the @@ -1955,7 +2123,6 @@ export class AnnotationPage extends TatorPage { requestObj, metaMode ); - this._makePreview(objDescription, dragInfo, canvasPosition); }); if (typeof canvas.addCreateTrackType !== "undefined") { @@ -1970,7 +2137,12 @@ export class AnnotationPage extends TatorPage { save.classList.remove("is-open"); this.removeAttribute("has-open-modal"); document.body.classList.remove("shortcuts-disabled"); - this._main.removeChild(this._preview); + + if (this._mediaType.dtype == "multi") { + for (const video of this._player._videos) { + video.style.zIndex = "unset"; + } + } } } @@ -1983,6 +2155,12 @@ export class AnnotationPage extends TatorPage { save.classList.add("is-open"); this.setAttribute("has-open-modal", ""); document.body.classList.add("shortcuts-disabled"); + + if (this._mediaType.dtype == "multi") { + for (const video of this._player._videos) { + video.style.zIndex = 2; + } + } } _updateURL() { @@ -2022,34 +2200,6 @@ export class AnnotationPage extends TatorPage { }); } - _makePreview(objDescription, dragInfo, canvasPosition) { - this._preview = document.createElement("div"); - this._preview.style.overflow = "hidden"; - this._preview.style.position = "absolute"; - const prevTop = Math.min(dragInfo.start.y, dragInfo.end.y); - const prevLeft = Math.min(dragInfo.start.x, dragInfo.end.x); - this._preview.style.top = canvasPosition.top + prevTop + "px"; - this._preview.style.left = canvasPosition.left + prevLeft + "px"; - this._preview.style.width = - Math.abs(dragInfo.start.x - dragInfo.end.x) - 6 + "px"; - this._preview.style.height = - Math.abs(dragInfo.start.y - dragInfo.end.y) - 6 + "px"; - this._preview.style.borderStyle = "solid"; - this._preview.style.borderWidth = "3px"; - this._preview.style.borderColor = "white"; - this._preview.style.zIndex = 2; - this._main.appendChild(this._preview); - - const img = new Image(); - img.src = dragInfo.url; - img.style.position = "absolute"; - img.style.top = -prevTop - 3 + "px"; - img.style.left = -prevLeft - 3 + "px"; - img.style.width = canvasPosition.width + "px"; - img.style.height = canvasPosition.height + "px"; - this._preview.appendChild(img); - } - /// Turn on or off ability to edit annotations async enableEditing(mask) { let enable; @@ -2129,6 +2279,97 @@ export class AnnotationPage extends TatorPage { } return; } + + /** + * Display the canvas applet menu toolbar next to the toolbar button + */ + showCanvasAppletMenu() { + this._sidebar._canvasApplet._button.classList.add("purple-box-border"); + + let pos = this._sidebar._canvasApplet.getBoundingClientRect(); + let padding = 20 + this._numCanvasApplets * 60; + this._canvasAppletMenu.style.top = `${pos.top - padding}px`; + this._canvasAppletMenu.style.left = `${pos.right + 18}px`; + this._canvasAppletMenu.style.display = "block"; + } + + /** + * Hide the canvas applet menu + */ + hideCanvasAppletMenu() { + this._canvasAppletMenu.style.display = "none"; + this._sidebar._canvasApplet._button.classList.remove("purple-box-border"); + } + + /** + * Bring up the canvas applet to the forefront and hide the main annotation parts. + * Pass along information about the current state to the applet. + * @param {int} appletId + * Applet to display + */ + showCanvasApplet(appletId) { + this.hideCanvasAppletMenu(); + + var appletData = { + frame: this._currentFrame, + selectedTrack: this._canvas._activeTrack, + selectedLocalization: this._canvas.activeLocalization, + media: this._canvas._mediaInfo, + }; + + this._currentCanvasApplet = this._canvasApplets[appletId]; + + if ( + this._mediaType.dtype != "image" && + this._currentCanvasApplet._lastFrameUpdate != this._currentFrame + ) { + this._mediaCanvas.getPNGdata(false).then((blob) => { + this._currentCanvasApplet.updateFrame(this._currentFrame, blob); + }); + } + + this._currentCanvasApplet.style.display = "flex"; + this._currentCanvasApplet.show(appletData); + + this._canvasAppletHeader.style.display = "flex"; + this._canvasAppletHeader.setAttribute( + "title", + this._currentCanvasApplet.getTitle() + ); + + // + // HIDE MAIN PAGE PARTS + // + this._versionButton.style.display = "none"; + this._settings.style.display = "none"; + this._main.style.display = "none"; + } + + /** + * Request to close up the visible canvas applet and show the main annotator + * If the applet isn't allowed to close yet, then do nothing. + * If it is allowed, set the applet display to none and bring the annotation page back to usual. + */ + exitCanvasApplet() { + if (!this._currentCanvasApplet.allowedToClose()) { + return; + } + + this._currentCanvasApplet.style.display = "none"; + this._currentCanvasApplet.close(); + this._canvasAppletHeader.style.display = "none"; + this._currentCanvasApplet = null; + + // + // SHOW MAIN PAGE PARTS + // + this._versionButton.style.display = "block"; + this._settings.style.display = "block"; + this._main.style.display = "flex"; + + // Required resize to reset the elements correctly + window.dispatchEvent(new Event("resize")); + } } customElements.define("annotation-page", AnnotationPage); diff --git a/ui/src/js/annotation/annotation-sidebar.js b/ui/src/js/annotation/annotation-sidebar.js index bff04bd77..0a6d7b812 100644 --- a/ui/src/js/annotation/annotation-sidebar.js +++ b/ui/src/js/annotation/annotation-sidebar.js @@ -45,8 +45,11 @@ export class AnnotationSidebar extends TatorElement { const zoomOut = document.createElement("zoom-out-button"); this._div.appendChild(zoomOut); - const pan = document.createElement("pan-button"); - this._div.appendChild(pan); + this._pan = document.createElement("pan-button"); + this._div.appendChild(this._pan); + + this._canvasApplet = document.createElement("canvas-applet-button"); + this._div.appendChild(this._canvasApplet); this._indicator = document.createElement("span"); this._indicator.setAttribute("class", "annotation__shape-indicator"); @@ -61,7 +64,8 @@ export class AnnotationSidebar extends TatorElement { this._track, zoomIn, zoomOut, - pan, + this._pan, + this._canvasApplet, ]; this._edit.addEventListener("click", () => { @@ -79,21 +83,34 @@ export class AnnotationSidebar extends TatorElement { zoomOut.blur(); }); - pan.addEventListener("click", () => { - this._selectButton(pan); + this._pan.addEventListener("click", () => { + this._selectButton(this._pan); this.dispatchEvent(new Event("pan")); }); + this._canvasApplet.addEventListener("click", () => { + this._canvasApplet.blur(); + this.dispatchEvent(new Event("canvasApplet")); + }); + document.addEventListener("keydown", (evt) => { if (document.body.classList.contains("shortcuts-disabled")) { return; } - if (evt.keyCode == 27) { - this._edit.click(); - } else if (evt.keyCode == 187 || evt.keyCode == 107) { - zoomIn.click(); - } else if (evt.keyCode == 189 || evt.keyCode == 109) { - zoomOut.click(); + if (evt.ctrlKey) { + if (evt.key == "p") { + evt.preventDefault(); + evt.stopPropagation(); + pan.click(); + } + } else { + if (evt.key == "Escape") { + this._edit.click(); + } else if (evt.key == "+") { + zoomIn.click(); + } else if (evt.key == "-") { + zoomOut.click(); + } } }); } @@ -107,11 +124,15 @@ export class AnnotationSidebar extends TatorElement { if (!this._poly.permanentDisable) this._poly.removeAttribute("disabled"); if (!this._track.permanentDisable) this._track.removeAttribute("disabled"); + if (!this._canvasApplet.permanentDisable) + this._canvasApplet.removeAttribute("disabled"); } else { this._box.setAttribute("disabled", ""); this._line.setAttribute("disabled", ""); this._point.setAttribute("disabled", ""); + this._poly.setAttribute("disabled", ""); this._track.setAttribute("disabled", ""); + this._canvasApplet.setAttribute("disabled", ""); } } @@ -240,6 +261,14 @@ export class AnnotationSidebar extends TatorElement { modeChange(newMode, metaMode) { if (newMode == "new_poly") { this._selectButton(this._poly, metaMode); + } + if (newMode == "pan") { + this._selectButton(this._pan, metaMode); + this.dispatchEvent(new Event("pan")); + } + if (newMode == "query") { + this._selectButton(this._edit, metaMode); + this.dispatchEvent(new Event("default")); } else { console.info(`Mode change to ${newMode} ignored.`); } @@ -266,6 +295,11 @@ export class AnnotationSidebar extends TatorElement { this._edit.click(); } + disableCanvasApplet() { + this._canvasApplet.setAttribute("disabled", ""); + this._canvasApplet.permanentDisable = true; + } + addAppletPanel(panel, trigger) { this._hiddenDiv.appendChild(panel); this._div.appendChild(trigger); diff --git a/ui/src/js/annotation/attribute-panel.js b/ui/src/js/annotation/attribute-panel.js index e712bcd01..3d65849be 100644 --- a/ui/src/js/annotation/attribute-panel.js +++ b/ui/src/js/annotation/attribute-panel.js @@ -51,6 +51,13 @@ export class AttributePanel extends TatorElement { this._standardWidgetsDiv.appendChild(this._frameWidget); this._widgets.push(this._frameWidget); + this._typeWidget = document.createElement("text-input"); + this._typeWidget.permission = "View Only"; + this._typeWidget.setAttribute("name", "Type"); + this._standardWidgetsDiv.appendChild(this._typeWidget); + this._widgets.push(this._typeWidget); + this._typeWidget.style.display = "none"; + this._versionWidget = document.createElement("text-input"); this._versionWidget.setAttribute("name", "Version"); this._versionWidget.permission = "View Only"; @@ -159,6 +166,7 @@ export class AttributePanel extends TatorElement { this._timeStore = null; this._browserSettings = null; this._disableWidgets = new Set(); + this._disableWidgets.add("Type"); } static get observedAttributes() { @@ -235,6 +243,7 @@ export class AttributePanel extends TatorElement { if ( widget.getAttribute("name") != "ID" && widget.getAttribute("name") != "Frame" && + widget.getAttribute("name") != "Type" && widget.getAttribute("name") != "Version" ) { // Widget may have been marked as disabled, and its permission have already been @@ -1097,6 +1106,9 @@ export class AttributePanel extends TatorElement { if (val == "Frame") { this._frameWidget.style.display = "none"; } + if (val == "Type") { + this._typeWidget.style.display = "none"; + } if (val == "Version") { this._versionWidget.style.display = "none"; } @@ -1117,6 +1129,9 @@ export class AttributePanel extends TatorElement { if (!this._disableWidgets.has("ID")) { this._idWidget.style.display = "block"; } + if (!this._disableWidgets.has("Type")) { + this._typeWidget.style.display = "block"; + } if (!this._disableWidgets.has("Frame")) { this._frameWidget.style.display = "block"; } @@ -1143,6 +1158,7 @@ export class AttributePanel extends TatorElement { var standardWidgets = [ { attrName: "ID", widget: this._idWidget }, { attrName: "Frame", widget: this._frameWidget }, + { attrName: "Type", widget: this._typeWidget }, { attrName: "Version", widget: this._versionWidget }, ]; for (const info of standardWidgets) { @@ -1192,6 +1208,9 @@ export class AttributePanel extends TatorElement { // Set the ID widget this._idWidget.setValue(values.id); this._frameWidget.setValue(values.frame); + this._typeWidget.setValue( + `${this._dataType.name} (ID: ${this._dataType.id})` + ); let version = null; let foundVersion = false; diff --git a/ui/src/js/annotation/canvas-applet-element.js b/ui/src/js/annotation/canvas-applet-element.js new file mode 100644 index 000000000..556c5bbd7 --- /dev/null +++ b/ui/src/js/annotation/canvas-applet-element.js @@ -0,0 +1,901 @@ +/** + * Canvas applet parent class + * + * Used in the annotator to display a canvas visualizing the current frame + * and customized toolbar button(s) and information panel(s). + * + * Methods that are expected to be overridden by subclasses are marked as abstract. + */ +import { TatorElement } from "../components/tator-element.js"; + +export class CanvasAppletElement extends TatorElement { + /** + * Constructor + */ + constructor() { + super(); + + this._applet = null; + this._data = null; + this._favorites = null; + this._undo = null; + this._active = false; + + this._selectButtonEnabled = true; + this._zoomInButtonEnabled = true; + this._zoomOutButtonEnabled = true; + this._panButtonEnabled = true; + } + + /** + * @param {Tator.Applet} applet + * Applet to initialize the element with + * @param {annotation-data} data + * Annotation page data buffer + * Used by the applets to query state/localization types necessary at initialization + * @param {array of Tator.Applet} favorites + * List of user-associated favorites + * @param {undo-buffer} undo + * Undo buffer for patching/posting required by elements like the save dialog + * @param {undo-buffer} undo + * Undo buffer for patching/posting required by elements like the save dialog + */ + init(applet, data, favorites, undo) { + this._applet = applet; + this._data = data; + this._undo = undo; + this._favorites = favorites; + + this._main = document.createElement("main"); + this._main.setAttribute("class", "d-flex flex-justify-center"); + this._shadow.appendChild(this._main); + + this._toolbarWrapper = document.createElement("div"); + this._main.appendChild(this._toolbarWrapper); + this._canvasWrapper = document.createElement("div"); + this._main.appendChild(this._canvasWrapper); + this._infoWrapper = document.createElement("div"); + this._main.appendChild(this._infoWrapper); + + this.applyAppletInit(); + + this.createToolbar(); + this.createCanvas(); + this.createInfoPanel(); + + window.addEventListener("resize", () => { + if (this._active) { + this.redrawCanvas(); + } + }); + } + + /** + * Utility function to create a button that conforms to the toolbar + * @param {string} buttonText + * @param {string} svgHTML + * @return HTMLElement + * Button to add to the toolbar + */ + static createButton(buttonText, svgHTML) { + var button = document.createElement("button"); + button.style.width = "100%"; + button.setAttribute( + "class", + "mb-2 btn-clear d-flex flex-items-center flex-column flex-justify-center px-2 py-2 rounded-2 f2 text-gray entity__button sidebar-button box-border" + ); + var innerHTML = svgHTML; + if (buttonText != null) { + innerHTML += `
${buttonText}
`; + } + button.innerHTML = innerHTML; + + return button; + } + + /** + * Creates the toolbar to the left of the canvas + * + * Note: These buttons have the option of being visually disabled. + * They are still constructed because other functions in this class + * rely on them being present. + */ + createToolbar() { + this._sidebar = document.createElement("div"); + this._sidebar.setAttribute( + "class", + "d-flex flex-column flex-items-center py-1 px-3" + ); + this._sidebar.style.width = "65px"; + this._toolbarWrapper.appendChild(this._sidebar); + + // + // SELECT BUTTON + // + this._selectButton = CanvasAppletElement.createButton( + "Select", + ` + + + + + ` + ); + this._sidebar.appendChild(this._selectButton); + + this._selectButton.addEventListener("click", () => { + this.setCanvasMode("select"); + this._selectButton.blur(); + }); + + if (!this._selectButtonEnabled) { + this._selectButton.style.display = "none"; + } + + // + // APPLET-SPECIFIC BUTTONS + // + this.addAppletToolbarButtons(); + + // + // ZOOM-IN BUTTON + // + this._zoomInButton = CanvasAppletElement.createButton( + "Zoom In", + ` + + + + + + + + ` + ); + this._sidebar.appendChild(this._zoomInButton); + + this._zoomInButton.addEventListener("click", () => { + this.setCanvasMode("zoom-in"); + this._zoomInButton.blur(); + }); + + if (!this._zoomInButtonEnabled) { + this._zoomInButton.style.display = "none"; + } + + // + // ZOOM-OUT BUTTON + // + this._zoomOutButton = CanvasAppletElement.createButton( + "Zoom Out", + ` + + + + + + + ` + ); + this._sidebar.appendChild(this._zoomOutButton); + + this._zoomOutButton.addEventListener("click", () => { + this.setCanvasMode("zoom-out"); + this._zoomOutButton.blur(); + }); + + if (!this._zoomOutButtonEnabled) { + this._zoomOutButton.style.display = "none"; + } + + // + // PAN BUTTON + // + this._panButton = CanvasAppletElement.createButton( + "Pan", + ` + + + + + + + + + ` + ); + this._sidebar.appendChild(this._panButton); + + this._panButton.addEventListener("click", () => { + this.setCanvasMode("pan"); + this._panButton.blur(); + }); + + if (!this._panButtonEnabled) { + this._panButton.style.display = "none"; + } + } + + /** + * Note: If additional buttons have been added, follow the format of this method + * and implement a derived version of this method. + */ + setCanvasMode(mode) { + this._canvasMode = mode; + + this.deselectAllToolbarButtons(); + + var appletCanvasModes = this.getAppletCanvasModes(); + + if (appletCanvasModes.includes(mode)) { + this.selectAppletCanvasMode(mode); + } else if (this._canvasMode == "select") { + this._frameCanvas.style.cursor = "pointer"; + this._selectButton.classList.add("btn-purple50"); + } else if (this._canvasMode == "zoom-in") { + this._frameCanvas.style.cursor = "zoom-in"; + this._zoomInButton.classList.add("btn-purple50"); + } else if (this._canvasMode == "zoom-out") { + this._frameCanvas.style.cursor = "zoom-out"; + this._zoomOutButton.classList.add("btn-purple50"); + } else if (this._canvasMode == "pan") { + this._frameCanvas.style.cursor = "move"; + this._panButton.classList.add("btn-purple50"); + } else { + console.error(`setCanvasMode: Invalid mode (${mode})`); + } + } + + /** + * Note: If additional buttons have been added, follow the format of this method + * and implement a derived version of this method. + */ + deselectAllToolbarButtons() { + this._selectButton.classList.remove("btn-purple50"); + this._zoomInButton.classList.remove("btn-purple50"); + this._zoomOutButton.classList.remove("btn-purple50"); + this._panButton.classList.remove("btn-purple50"); + + this.deselectAppletToolbarButtons(); + } + + /** + * Convert the provided visible canvas coordinates (normalized) into the equivalent offscreen version + * + * Helper drawing function. Not expected to be reimplemented by derived class. + * + * @param {float} x + * -1.0 .. 1.0 in visible canvas coordinates relative to offscreen (width) + * @param {float} y + * -1.0 .. 1.0 in visible canvas coordinates relative to offscreen (height) + * @param {array} offscreenRoi + * [0] = x (0.0 .. 1.0) + * [1] = y (0.0 .. 1.0) + * [2] = width (0.0 .. 1.0) + * [3] = height (0.0 .. 1.0) + * + * If this is null, then this._offscreenRoi is used + * + * @return {array} + * [0] = x in offscreen canvas coordinates (normalized) + * [1] = y in offscreen canvas coordinates (normalized) + */ + convertVisibleToOffscreen(x, y, offscreenRoi) { + if (offscreenRoi == null) { + offscreenRoi = this._offscreenRoi; + } + + var offscreenX = offscreenRoi[0] + offscreenRoi[2] * x; + var offscreenY = offscreenRoi[1] + offscreenRoi[3] * y; + + if (offscreenX < 0) { + offscreenX = 0; + } else if (offscreenX > 1) { + offscreenX = 1; + } + + if (offscreenY < 0) { + offscreenY = 0; + } else if (offscreenY > 1) { + offscreenY = 1; + } + + return [offscreenX, offscreenY]; + } + + /** + * Utility function used to create normalized coordinates for the visible and + * offscreen canvases based on the provided mouse event + * @param {Event} event + * Event provided when a mouse event occurs + * @return + * Object with the following fields: + * - visible: Normalized coordinates of mouse in the visible canvas + * (0,0 is top left, 1,1 is bottom right) + * - offscreen: Normalized coordinates of mouse in the offscreen canvas + * (0,0 is top left, 1,1 is bottom right) + */ + createNormalizedCoordinates(event) { + var visibleCoordinates = [ + event.offsetX / this._frameCanvas.offsetWidth, + event.offsetY / this._frameCanvas.offsetHeight, + ]; + + for (let idx = 0; idx < 2; idx++) { + if (visibleCoordinates[idx] > 1.0) { + visibleCoordinates[idx] = 1; + } + if (visibleCoordinates[idx] < 0.0) { + visibleCoordinates[idx] = 0; + } + } + + var offscreenCoordinates = this.convertVisibleToOffscreen( + visibleCoordinates[0], + visibleCoordinates[1] + ); + + return { + visible: visibleCoordinates, + offscreen: offscreenCoordinates, + }; + } + + /** + * Helper drawing function. Not expected to be reimplemented by derived class. + */ + createCanvas() { + this._frameCanvas = document.createElement("canvas"); + this._canvasWrapper.appendChild(this._frameCanvas); + this._frameCanvasContext = this._frameCanvas.getContext("2d"); + + this._frameCanvas.offscreenCanvas = document.createElement("canvas"); + this._offscreenCanvasContext = + this._frameCanvas.offscreenCanvas.getContext("2d"); + + this._frameImage = new Image(); + + this._dragging = false; + + this._frameCanvas.addEventListener("click", (event) => { + var coords = this.createNormalizedCoordinates(event); + + // + // APPLET-SPECIFIC MODE CALLBACK + // + var appletCanvasModes = this.getAppletCanvasModes(); + if (appletCanvasModes.includes(this._canvasMode)) { + this.applyAppletMouseClick(coords.visible, coords.offscreen); + return; + } + + // + // ZOOM MODES + // + if (this._canvasMode == "zoom-in") { + this._canvasZoom *= 2; + + if (this._canvasZoom > 8) { + this._canvasZoom = 8; + } + + // The 0.25 is related with zoom. + this._canvasCenterPoint = [ + coords.visible[0] + 0.25, + coords.visible[1] + 0.25, + ]; + this.redrawCanvas(); + } else if (this._canvasMode == "zoom-out") { + this._canvasZoom *= 0.5; + if (this._canvasZoom < 1) { + this._canvasZoom = 1; + } + this._canvasCenterPoint = [ + coords.visible[0] - 0.5, + coords.visible[1] - 0.5, + ]; + this.redrawCanvas(); + } + }); + + this._frameCanvas.addEventListener("mouseout", (event) => { + this.applyAppletMouseOut(); + }); + + this._frameCanvas.addEventListener("mousedown", (event) => { + this._event = { start: {}, current: {} }; + this._event.start.time = Date.now(); + + var coords = this.createNormalizedCoordinates(event); + this._event.start.point = coords.visible; + this._dragging = true; + + // + // APPLET-SPECIFIC MODE CALLBACK + // + var appletCanvasModes = this.getAppletCanvasModes(); + if (appletCanvasModes.includes(this._canvasMode)) { + this.applyAppletMouseDown(coords.visible, coords.offscreen); + return; + } + }); + + this._frameCanvas.addEventListener("mouseup", (event) => { + this._dragging = false; + this._event = null; + var coords = this.createNormalizedCoordinates(event); + + // + // APPLET-SPECIFIC MODE CALLBACK + // + var appletCanvasModes = this.getAppletCanvasModes(); + if (appletCanvasModes.includes(this._canvasMode)) { + this.applyAppletMouseUp(coords.visible, coords.offscreen); + return; + } + }); + + this._frameCanvas.addEventListener("mousemove", (event) => { + var coords = this.createNormalizedCoordinates(event); + + // + // APPLET-SPECIFIC MODE CALLBACK + // + var appletCanvasModes = this.getAppletCanvasModes(); + if (appletCanvasModes.includes(this._canvasMode)) { + this.applyAppletMouseMove(coords.visible, coords.offscreen); + return; + } + + // + // PAN TOOL + // + if (!this._dragging) { + return; + } + + var now = Date.now(); + var duration = now - this._event.start.time; + + if (this._canvasMode == "pan" && duration > 1000.0 / 60) { + this._deltaX = coords.visible[0] - this._event.start.point[0]; + this._deltaY = coords.visible[1] - this._event.start.point[1]; + this._canvasCenterPoint[0] = 0.5 - this._deltaX; + this._canvasCenterPoint[1] = 0.5 - this._deltaY; + + this._event.start.time = now; + this._event.start.point = coords.visible; + + this.redrawCanvas(); + } + }); + } + + /** + * Helper function not expected to be re-implemented in a derived class. + */ + redrawCanvas() { + if (this._canvasCenterPoint == null) { + return; // Not initialized yet. + } + + // + // Set the visible canvas size based on available document space + // while maintaining frame image ratio. + // + var canvasMaxHeight = window.innerHeight - 110; + var canvasWrapperWidth = Math.round( + window.innerWidth - + this._toolbarWrapper.offsetWidth - + this._infoWrapper.offsetWidth + ); + var canvasWrapperHeight = Math.round( + (this._frameImage.height / this._frameImage.width) * canvasWrapperWidth + ); + if (canvasWrapperHeight > canvasMaxHeight) { + canvasWrapperHeight = canvasMaxHeight; + canvasWrapperWidth = Math.round( + (this._frameImage.width / this._frameImage.height) * canvasMaxHeight + ); + } + + this._frameCanvas.width = canvasWrapperWidth; + this._frameCanvas.height = canvasWrapperHeight; + + if (this._frameCanvas.offsetWidth == 0) { + return; // Canvas isn't visible, don't bother drawing + } + + // + // Create the offscreen canvas size based on the frame image ratio and requested zoom + // + var visibleCanvasRatio = + this._frameCanvas.offsetWidth / this._frameImage.width; + var offscreenCanvasSize = [0, 0]; + offscreenCanvasSize[0] = Math.round( + this._frameImage.width * this._canvasZoom * visibleCanvasRatio + ); + offscreenCanvasSize[1] = Math.round( + this._frameImage.height * this._canvasZoom * visibleCanvasRatio + ); + this._frameCanvas.offscreenCanvas.width = offscreenCanvasSize[0]; + this._frameCanvas.offscreenCanvas.height = offscreenCanvasSize[1]; + + // + // Draw the stuff on the offscreen canvas + // + this._offscreenCanvasContext.clearRect( + 0, + 0, + offscreenCanvasSize[0], + offscreenCanvasSize[1] + ); + this._offscreenCanvasContext.drawImage( + this._frameImage, + 0, + 0, + offscreenCanvasSize[0], + offscreenCanvasSize[1] + ); + + // + // 1. Convert the selected canvas point into the corresponding offscreen point + // 2. Using the known visible canvas size, figure out the relative offscreen roi + // + var rOffscreenTopLeft = this.convertVisibleToOffscreen( + this._canvasCenterPoint[0] - 0.5, + this._canvasCenterPoint[1] - 0.5, + this._offscreenRoi + ); + + var rWidth = this._frameCanvas.offsetWidth / offscreenCanvasSize[0]; + if (rWidth + rOffscreenTopLeft[0] > 1.0) { + rOffscreenTopLeft[0] = 1 - rWidth; + } + if (rOffscreenTopLeft[0] < 0) { + rOffscreenTopLeft[0] = 0; + } + + var rHeight = this._frameCanvas.offsetHeight / offscreenCanvasSize[1]; + if (rHeight + rOffscreenTopLeft[1] > 1.0) { + rOffscreenTopLeft[1] = 1 - rHeight; + } + if (rOffscreenTopLeft[1] < 0) { + rOffscreenTopLeft[1] = 0; + } + + this._offscreenRoi = [ + rOffscreenTopLeft[0], + rOffscreenTopLeft[1], + rWidth, + rHeight, + ]; + + // + // Draw applet specific stuff before taking a ROI thumbnail of the offscreen canvas + // and placing it onto the visible canvas + // + this.drawAppletData(); + + // + // Draw + // + this._frameCanvasContext.clearRect( + 0, + 0, + this._frameCanvas.offsetWidth, + this._frameCanvas.offsetHeight + ); + this._frameCanvasContext.drawImage( + this._frameCanvas.offscreenCanvas, + this._offscreenRoi[0] * offscreenCanvasSize[0], // sx + this._offscreenRoi[1] * offscreenCanvasSize[1], // sy + this._offscreenRoi[2] * offscreenCanvasSize[0], // swidth + this._offscreenRoi[3] * offscreenCanvasSize[1], // sheight + 0, // dx + 0, // dy + this._frameCanvas.offsetWidth, // dwidth + this._frameCanvas.offsetHeight // dheight + ); + + this._canvasCenterPoint = [0.5, 0.5]; + } + + /** + * Reinitialize the canvas with the frame image to update + * @param {integer} frame + * Frame number associated with the image + * @param {blob} frameBlob + * Blob of media frame image to display in the canvas + * @postcondition + * Resets the canvas zoom scaling back to 1 + * @return {Promise} + * Resolves when the image is loaded with the provided frame blob + */ + updateFrame(frame, frameBlob) { + this._frame = frame; + this._frameBlob = frameBlob; + return new Promise((resolve) => { + this._canvasZoom = 1; + this._canvasCenterPoint = [0.5, 0.5]; + this._offscreenRoi = [0, 0, 1.0, 1.0]; // Initialize with 1-to-1 mapping + + var that = this; + this._frameImage.onload = function () { + that.appletFrameUpdateCallback().then(() => { + that.redrawCanvas(); + resolve(); + }); + }; + this._frameImage.src = URL.createObjectURL(frameBlob); + }); + } + + /** + * @return {string} + * Text to display in the applet menu and annotator header + */ + getTitle() { + return this._applet.name; + } + + /** + * @return {string} + * Description to display in the applet menu + */ + getDescription() { + return this._applet.description; + } + + /** + * Note: This should be overridden if the select button is not used. + * + * @return {string} + * Mode that the applet will start off with when shown + */ + getDefaultMode() { + return "select"; + } + + // + // ABSTRACT METHODS + // The methods in this block should be updated by the derived class + // + + /** + * @abstract + * Derived implementation should return a unique applet icon. This icon is used in the + * annotator applet menu list. The width/height and class should match the example below + * @return {string} + * HTML of the icon associated with this applet + */ + getIcon() { + return ` + + + + + + + `; + } + + /** + * @abstract + * Derived implementation should override this to perform tasks whenever the frame + * has been updated. + */ + async appletFrameUpdateCallback() { + return; + } + + /** + * Creates the information panel to the right of the canvas + * + * @abstract + * Add applet specific information panels + */ + createInfoPanel() { + return; + } + + /** + * Called when the sidebar is initialized + * + * @abstract + * Add applet specific buttons to this._sidebar + */ + addAppletToolbarButtons() { + return; + } + + /** + * Deselect applet specific toolbar buttons + * @abstract + * Clear out specific classes from applet toolbar buttons. + * Refer to deselectAllToolbarButtons + */ + deselectAppletToolbarButtons() { + return; + } + + /** + * @abstract + * Derived implementation should return an array of canvas mode strings specific + * to the applet's functions. Generally there should be a mode associated with a + * toolbar button. + * @return {array} + * Array of strings - applet specific canvas modes (e.g. ["mode_a", "mode_b"]) + */ + getAppletCanvasModes() { + return []; + } + + /** + * @abstract + * Derived implementation should act on the requested applet specific canvas mode + * @param {string} mode + * Requested canvas mode + */ + selectAppletCanvasMode(mode) { + return; + } + + /** + * Method called when the mousemove event is caught and an applet specific canvas mode is active + * + * @abstract + * Override if applet needs to respond to mousemove events in the visible canvas + * @param {array} visibleCoordinates + * Normalized mouse location relative to the visible canvas + * (e.g. top left is 0,0 and the bottom right is 1,1) + * @param {array} offscreenCoordinates + * Normalized mouse location relative to the offscreen canvas + * (e.g. top left is 0,0 and the bottom right is 1,1) + * If zoom level is 1, this is the same as the visible coordinates. + */ + applyAppletMouseMove(visibleCoordinates, offscreenCoordinates) { + return; + } + + /** + * Method called when the mousedown event is caught and an applet specific canvas mode is active + * + * @abstract + * Override if applet needs to respond to mousedown events in the visible canvas + * @param {array} visibleCoordinates + * Normalized mouse location relative to the visible canvas + * (e.g. top left is 0,0 and the bottom right is 1,1) + * @param {array} offscreenCoordinates + * Normalized mouse location relative to the offscreen canvas + * (e.g. top left is 0,0 and the bottom right is 1,1) + * If zoom level is 1, this is the same as the visible coordinates. + */ + applyAppletMouseDown(visibleCoordinates, offscreenCoordinates) { + return; + } + + /** + * Method called when the mouseup event is caught and an applet specific canvas mode is active + * + * @abstract + * Override if applet needs to respond to mouseup events in the visible canvas + * @param {array} visibleCoordinates + * Normalized mouse location relative to the visible canvas + * (e.g. top left is 0,0 and the bottom right is 1,1) + * @param {array} offscreenCoordinates + * Normalized mouse location relative to the offscreen canvas + * (e.g. top left is 0,0 and the bottom right is 1,1) + * If zoom level is 1, this is the same as the visible coordinates. + */ + applyAppletMouseUp(visibleCoordinates, offscreenCoordinates) { + return; + } + + /** + * Method called when the mouseout event is caught and an applet specific canvas mode is active + * + * @abstract + * Override if applet needs to respond to click events in the visible canvas + * @param {array} visibleCoordinates + * Normalized mouse location relative to the visible canvas + * (e.g. top left is 0,0 and the bottom right is 1,1) + * @param {array} offscreenCoordinates + * Normalized mouse location relative to the offscreen canvas + * (e.g. top left is 0,0 and the bottom right is 1,1) + * If zoom level is 1, this is the same as the visible coordinates. + */ + applyAppletMouseClick(visibleCoordinates, offscreenCoordinates) { + return; + } + + /** + * Method called when the mouseout event is caught and an applet specific canvas mode is active + * + * @abstract + * Override if applet needs to respond to mouseout events in the visible canvas + */ + applyAppletMouseOut() { + return; + } + + /** + * Method called when the redrawCanvas function is executed + * + * @abstract + * Override if applet needs to draw stuff on top of the frame image + */ + drawAppletData() { + return; + } + + /** + * Method called after saving the provided data interfaces but before the UI creation. + * + * @abstract + * Override if applet needs to perform tasks at initialization + */ + applyAppletInit() { + return; + } + + /** + * Called when the applet is active/shown in the annotator + * + * @postcondition + * this._active is set to true, which allows it to redraw on window resize events + * Canvas mode is set to the default mode + * @abstract + * Called after updateFrame() has been called. + * The canvas applet is shown on the annotation-page (display != none) and this function + * is called. This may not be necssary to re-implement. + * @param data {Object} + * Refer to annotation-page.js for the latest information + */ + show(data) { + this._active = true; + this.setCanvasMode(this.getDefaultMode()); + } + + /** + * Called when "freshData" event is emitted by annotation-data + * + * @abstract + * Derived implementation will respond to newly created Tator element (if needed) + * This is likely necessary to implement if this applet creates the element. + * @param {Tator.Entity} newElement + * Localization or state element that was recently created + * @param {Tator.EntityType} associatedType + * LocalizationType or StateType associated with newElement + */ + newData(newElement, associatedType) { + return; + } + + /** + * Called when the user requests to close the applet + * + * @abstract + * Derived implementation should decide if it is ready to close (e.g. all data has been saved) + * and also inform the user in the UI appropriately. + * @return {bool} + * True if the applet is ready to close. False otherwise. + */ + allowedToClose() { + return true; + } + + /** + * Called when the applet is not active in the annotator + * + * allowedToClose() should have returned true prior to calling this + * + * @abstract + * Derived implementation should do the tasks needed to close up shop. + */ + close() { + this._active = false; + } +} + +customElements.define("canvas-applet-element", CanvasAppletElement); diff --git a/ui/src/js/annotation/canvas-applet-wrapper.js b/ui/src/js/annotation/canvas-applet-wrapper.js new file mode 100644 index 000000000..fd172351d --- /dev/null +++ b/ui/src/js/annotation/canvas-applet-wrapper.js @@ -0,0 +1,116 @@ +/** + * Wrapper that instantiates an iframe that will contain the registered canvas applet + * This will act as the interface between the annotation page and the canvas applet. + * Expected to have one of these per canvas applet. + */ +import { TatorElement } from "../components/tator-element.js"; + +export class CanvasAppletWrapper extends TatorElement { + /** + * Constructor + */ + constructor() { + super(); + + this._applet = null; // Must call init() + this._lastFrameUpdate = -1; // Used to help determine if re-init is required + } + + /** + * @precondition init() must have been called + * @returns boolean + * True if applet can close gracefully + */ + allowedToClose() { + return this._appletElement.allowedToClose(); + } + + /** + * @precondition init() must have been called + */ + close() { + this._appletElement.close(); + } + + /** + * @precondition init() must have been called + * @returns string + * Applet's description + */ + getDescription() { + return this._appletElement.getDescription(); + } + + /** + * @precondition init() must have been called + * @returns string + * Applet's description + */ + getTitle() { + return this._appletElement.getTitle(); + } + + /** + * @precondition init() must have been called + * @returns string + * Applet's menu icon + */ + getIcon() { + return this._appletElement.getIcon(); + } + + /** + * Call this at construction + * @param {Tator.Applet} applet + * Applet to initialize the element with + * @param {annotation-data} data + * Annotation page data buffer + * Used by the applets to query state/localization types necessary at initialization + * @param {array of Tator.Applet} favorites + * List of user-associated favorites + * @param {undo-buffer} undo + * Undo buffer for patching/posting required by elements like the save dialog + * @return Promise + * Resolves when the applet element has been initialized + */ + init(applet, data, favorites, undo) { + return new Promise((resolve) => { + var appletView = document.createElement("iframe"); + appletView.setAttribute("class", "d-flex col-12"); + + var that = this; + appletView.onload = function () { + that._appletElement = + appletView.contentWindow.document.getElementById("mainApplet"); + that._appletElement.init(applet, data, favorites, undo); + resolve(); + }; + + appletView.src = applet.html_file; + this._shadow.appendChild(appletView); + }); + } + + /** + * @precondition init() must have been called + */ + show(data) { + this._appletElement.show(data); + } + + /** + * Update applet with current frame information + * @precondition init() must have been called + */ + async updateFrame(frame, blob) { + this._lastFrameUpdate = frame; + await this._appletElement.updateFrame(frame, blob); + } + + // #TODO + newData(newElement, associatedType) { + this._appletElement.newData(newElement, associatedType); + } +} + +customElements.define("canvas-applet-wrapper", CanvasAppletWrapper); diff --git a/ui/src/js/annotation/entity-browser.js b/ui/src/js/annotation/entity-browser.js index eb9c37906..fd1645966 100644 --- a/ui/src/js/annotation/entity-browser.js +++ b/ui/src/js/annotation/entity-browser.js @@ -152,28 +152,30 @@ export class EntityBrowser extends TatorElement { this._undo = val; } + /** + * @param {Object} evt + * event emitted from annotation-data "freshData" + */ + updateData(evt) { + if (evt.detail.typeObj.id === this._dataType.id) { + if (!this._initialized) { + this._initialized = true; + } + this._evt = evt; + this._drawControls(); + + if (this._selectEntityId != null) { + for (let group in this._selectors) { + this._selectors[group].selectEntityWithId(this._selectEntityId, true); + } + this._selectEntityId = null; + } + } + } + set annotationData(val) { this._data = val; this._jumpFrame.setValue(false); - this._data.addEventListener("freshData", (evt) => { - if (evt.detail.typeObj.id === this._dataType.id) { - if (!this._initialized) { - this._initialized = true; - } - this._evt = evt; - this._drawControls(); - - if (this._selectEntityId != null) { - for (let group in this._selectors) { - this._selectors[group].selectEntityWithId( - this._selectEntityId, - true - ); - } - this._selectEntityId = null; - } - } - }); this._filterModal.data = this._data; this._search.addEventListener("click", (evt) => { @@ -431,6 +433,10 @@ export class EntityBrowser extends TatorElement { if (selector) { // Selector may not exist if element was deleted. selector.selectEntity(obj); + } else { + // It's possible the group has not been created yet with the update, so stage the update + // This may need to be revisited if there's an asynchronous problem + this.selectEntityOnUpdate(obj.id); } } diff --git a/ui/src/js/annotation/entity-selector.js b/ui/src/js/annotation/entity-selector.js index 7d2c7a0ce..77908b608 100644 --- a/ui/src/js/annotation/entity-selector.js +++ b/ui/src/js/annotation/entity-selector.js @@ -368,8 +368,8 @@ export class EntitySelector extends TatorElement { this._slider.value = data.length - 1; } - if (this._selectAttempt) { - this.selectEntity(this._selectAttempt, true); + if (this._selectedObject) { + this.selectEntity(this._selectedObject); } this._emitSelection(false, true, false); @@ -396,32 +396,24 @@ export class EntitySelector extends TatorElement { } } - selectEntity(obj, attemptPrevSelect) { - var selectedObject = false; + selectEntity(obj) { + var foundObject = false; for (const [index, data] of this._data.entries()) { if (data.id == obj.id) { this._div.classList.add("is-open"); this.dispatchEvent(new Event("open")); this._current.textContent = String(index + 1); this._slider.value = index; - selectedObject = true; + foundObject = true; break; } } - if (attemptPrevSelect) { - this._selectAttempt = null; + if (!foundObject) { return; } - // If the object was attempted to be selected, it might not have been - // a part of the data entries yet. Save it and when the data buffer is updated, - // attempt to select it. - if (!selectedObject) { - this._selectAttempt = obj; - } - - this._emitSelection(false, false, false); + this._emitSelection(true, true, false); } _emitSelection(byUser, composed, goToEntityFrame) { @@ -493,6 +485,8 @@ export class EntitySelector extends TatorElement { } } + this._selectedObject = this._data[index]; + this.dispatchEvent( new CustomEvent("select", { detail: { diff --git a/ui/src/js/annotation/save-dialog.js b/ui/src/js/annotation/save-dialog.js index b21eacb18..b59a54028 100644 --- a/ui/src/js/annotation/save-dialog.js +++ b/ui/src/js/annotation/save-dialog.js @@ -30,6 +30,7 @@ export class SaveDialog extends TatorElement { const close = document.createElement("modal-close"); header.appendChild(close); + this._modalClose = close; this._hookButtonDiv = document.createElement("div"); this._hookButtonDiv.setAttribute("class", "hooks-button-div"); diff --git a/ui/src/js/annotation/simple-entity-selector.js b/ui/src/js/annotation/simple-entity-selector.js new file mode 100644 index 000000000..37beab536 --- /dev/null +++ b/ui/src/js/annotation/simple-entity-selector.js @@ -0,0 +1,170 @@ +import { TatorElement } from "../components/tator-element.js"; + +export class SimpleEntitySelector extends TatorElement { + constructor() { + super(); + + this._div = document.createElement("div"); + this._div.setAttribute( + "class", + "d-flex flex-items-center flex-justify-between position-relative entity__selector is-open" + ); + this._shadow.appendChild(this._div); + + this._expand = document.createElement("button"); + this._expand.setAttribute( + "class", + "annotation__entity btn-clear px-4 col-12 css-truncate text-white" + ); + this._div.appendChild(this._expand); + + this._name = document.createElement("span"); + this._name.setAttribute("class", "text-semibold"); + this._expand.appendChild(this._name); + + this._count = document.createElement("span"); + this._count.setAttribute("class", "px-1 text-gray"); + this._expand.appendChild(this._count); + + const controls = document.createElement("div"); + controls.setAttribute( + "class", + "annotation__entity-count d-flex flex-items-center px-4" + ); + this._div.appendChild(controls); + + this._prev = document.createElement("entity-prev-button"); + controls.appendChild(this._prev); + + const details = document.createElement("details"); + details.setAttribute("class", "position-relative"); + details.setAttribute("id", "current-index"); + controls.appendChild(details); + + const summary = document.createElement("summary"); + summary.setAttribute("class", "d-flex flex-items-center px-1"); + summary.style.cursor = "pointer"; + details.appendChild(summary); + + this._current = document.createElement("span"); + this._current.setAttribute("class", "px-1 text-gray"); + this._current.textContent = "1"; + summary.appendChild(this._current); + + const styleDiv = document.createElement("div"); + styleDiv.setAttribute("class", "files__main files-wrap"); + details.appendChild(styleDiv); + + const div = document.createElement("div"); + div.setAttribute("class", "more d-flex flex-column f2 py-3 px-2"); + styleDiv.appendChild(div); + + this._slider = document.createElement("input"); + this._slider.setAttribute("class", "range flex-grow"); + this._slider.setAttribute("type", "range"); + this._slider.setAttribute("step", "1"); + this._slider.setAttribute("min", "0"); + this._slider.setAttribute("value", "0"); + div.appendChild(this._slider); + + this._next = document.createElement("entity-next-button"); + controls.appendChild(this._next); + + this._data = []; + this._listIndex = -1; + + this._prev.addEventListener("click", () => { + this._prev.blur(); + + if (this._data.length > 0) { + this.selectEntity(this._listIndex - 1); + } + }); + this._next.addEventListener("click", () => { + this._next.blur(); + + if (this._data.length > 0) { + this.selectEntity(this._listIndex + 1); + } + }); + this._slider.addEventListener("input", () => { + this._slider.blur(); + if (this._data.length > 0) { + this.selectEntity(Number(this._slider.value)); + } + }); + } + + set name(val) { + this._name.textContent = val; + } + + /** + * Select entity based on the provided list index + * @precondition this._data must have content + */ + selectEntity(listIndex) { + if (listIndex < 0) { + this._listIndex = 0; + } else if (listIndex >= this._data.length) { + this._listIndex = this._data.length - 1; + } else { + this._listIndex = listIndex; + } + + this._current.textContent = `${this._listIndex + 1}`; + this._slider.value = this._listIndex; + + this.dispatchEvent( + new CustomEvent("select", { + detail: { + data: this._data[this._listIndex], + }, + }) + ); + } + + /** + * Update the internal data list to cycle through + * @param {array of Tator.Entity} data + * Array of tator entities (e.g. list of localizations). + * Used to determine the nav UI interactions. + */ + update(data) { + this._data = data; + + if (data.length == 0 || data == null) { + this._count.textContent = "0"; + this._current.textContent = "N/A"; + this._slider.value = 0; + this._listIndex = -1; + } else { + this._count.textContent = String(data.length); + if (this._current.textContent == "N/A") { + this._current.textContent = "1"; + this._listIndex = 0; + } + this._slider.max = data.length - 1; + const haveData = data.length > 0; + if (haveData && this._listIndex <= 0) { + this._current.textContent = "1"; + this._slider.value = 0; + this._listIndex = 0; + } + if (haveData && this._listIndex >= data.length) { + this._current.textContent = String(data.length); + this._slider.value = data.length - 1; + this._listIndex = data.length - 1; + } + + this.dispatchEvent( + new CustomEvent("select", { + detail: { + data: this._data[this._listIndex], + }, + }) + ); + } + } +} +customElements.define("simple-entity-selector", SimpleEntitySelector); diff --git a/ui/src/js/annotation/toolbar-button.js b/ui/src/js/annotation/toolbar-button.js index 60ddf09d2..11a15c9b8 100644 --- a/ui/src/js/annotation/toolbar-button.js +++ b/ui/src/js/annotation/toolbar-button.js @@ -165,6 +165,17 @@ class PanButton extends ToolbarButton { } } +class CanvasAppletButton extends ToolbarButton { + constructor() { + super(); + this.init( + "Canvas Applet", + "M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z", + "0, 0, 24, 24" + ); + } +} + customElements.define("toolbar-button", ToolbarButton); customElements.define("edit-button", EditButton); customElements.define("box-button", BoxButton); @@ -175,3 +186,4 @@ customElements.define("track-button", TrackButton); customElements.define("zoom-in-button", ZoomInButton); customElements.define("zoom-out-button", ZoomOutButton); customElements.define("pan-button", PanButton); +customElements.define("canvas-applet-button", CanvasAppletButton); diff --git a/ui/src/js/components/canvas-ctxmenu.js b/ui/src/js/components/canvas-ctxmenu.js index 1e2dcf715..185ce7a39 100644 --- a/ui/src/js/components/canvas-ctxmenu.js +++ b/ui/src/js/components/canvas-ctxmenu.js @@ -70,7 +70,7 @@ export class CanvasContextMenu extends TatorElement { } displayMenu(x, y) { - this._div.style.zIndex = 1; + this._div.style.zIndex = 3; // Needs to be above video for menu items to be selectable this._div.style.left = x + "px"; this._div.style.top = y + "px"; this._div.style.display = "block"; diff --git a/ui/src/js/components/entity-panel/entity-panel-localization.js b/ui/src/js/components/entity-panel/entity-panel-localization.js index d09561292..4d837bd0f 100644 --- a/ui/src/js/components/entity-panel/entity-panel-localization.js +++ b/ui/src/js/components/entity-panel/entity-panel-localization.js @@ -114,7 +114,16 @@ export class GalleryPanelLocalization extends TatorElement { ) { // get the frame const resp = await fetchCredentials( - `/rest/GetFrame/${mediaData.id}?frames=${localizationData.frame}` + `/rest/GetFrame/${mediaData.id}?frames=${localizationData.frame}`, + { + mode: "cors", + headers: { + "Content-Type": "image/*", + Accept: "image/*", + }, + }, + false, + true ); const sourceBlob = await resp.blob(); imageSource = URL.createObjectURL(sourceBlob); diff --git a/ui/src/js/components/filter-data.js b/ui/src/js/components/filter-data.js index 9355f2553..6cd4cbd63 100644 --- a/ui/src/js/components/filter-data.js +++ b/ui/src/js/components/filter-data.js @@ -232,6 +232,13 @@ export class FilterData { }; entityType.attribute_types.push(dtypeAttribute); + var elemental_id = { + name: "$elemental_id", + label: "Elemental ID", + dtype: "string", + }; + entityType.attribute_types.push(elemental_id); + var archiveStateAttribute = { choices: ["live", "to_archive", "archived", "to_live"], name: "$archive_state", @@ -287,6 +294,13 @@ export class FilterData { entityType.attribute_types.push(geo_height); } + var elemental_id = { + name: "$elemental_id", + label: "Elemental ID", + dtype: "string", + }; + entityType.attribute_types.push(elemental_id); + var frameAttribute = { name: "$frame", label: "Frame", diff --git a/ui/src/js/components/filter-interface.js b/ui/src/js/components/filter-interface.js index 75803fa47..d76231e05 100644 --- a/ui/src/js/components/filter-interface.js +++ b/ui/src/js/components/filter-interface.js @@ -207,7 +207,10 @@ export class FilterInterface extends TatorElement { date_range: "Within", distance_lte: "Within", }; - const humanReadable = operator_convert[filter.operation]; + let humanReadable = operator_convert[filter.operation]; + if (filter.inverse == true) { + humanReadable = `NOT ${humanReadable}`; + } const display = humanReadable ? humanReadable : filter.operation; return `${filter.attribute} ${display} ${filter.value}`; } diff --git a/ui/src/js/components/inputs/email-list-input.js b/ui/src/js/components/inputs/email-list-input.js index df839306b..0420063d3 100644 --- a/ui/src/js/components/inputs/email-list-input.js +++ b/ui/src/js/components/inputs/email-list-input.js @@ -151,11 +151,8 @@ export class EmailListInput extends TatorElement { } clear() { - if (this._pills.length) { - for (const pill of this._pills.children) { - this._pills.removeChild(pill); - } - } + if (this._input && this._input.setValue) this._input.setValue(""); + this._pills.innerHTML = ""; } } diff --git a/ui/src/js/components/inputs/user-input.js b/ui/src/js/components/inputs/user-input.js index 4300bb5cd..b26901a45 100644 --- a/ui/src/js/components/inputs/user-input.js +++ b/ui/src/js/components/inputs/user-input.js @@ -131,8 +131,8 @@ export class UserInput extends TatorElement { reset() { // Go back to default value if (this._data?._users) this._data._users = new Map(); - if (this._pills.length) { - this.clear(); + if (this._pills.children.length > 0) { + this._pills.innerHTML = ""; } } diff --git a/ui/src/js/components/nav-main.js b/ui/src/js/components/nav-main.js index a8c8c12fb..86b860d39 100644 --- a/ui/src/js/components/nav-main.js +++ b/ui/src/js/components/nav-main.js @@ -60,7 +60,7 @@ export class NavMain extends TatorElement { const idToken = localStorage.getItem("id_token"); logout.setAttribute( "href", - `/accounts/logout?id_token_hint=${idToken}&post_logout_redirect_uri=${window.location.origin}/accounts/login` + `/accounts/logout?id_token_hint=${idToken}&post_logout_redirect_uri=${window.location.origin}` ); logout.addEventListener("click", (evt) => { localStorage.removeItem("access_token"); diff --git a/ui/src/js/components/upload-element.js b/ui/src/js/components/upload-element.js index bc2036617..21afc2697 100644 --- a/ui/src/js/components/upload-element.js +++ b/ui/src/js/components/upload-element.js @@ -39,7 +39,7 @@ export class UploadElement extends TatorElement { /(tiff|tif|bmp|jpe|jpg|jpeg|png|gif|avif|heic|heif)$/i ); const isVideo = ext.match( - /(mp4|avi|3gp|ogg|wmv|webm|flv|mkv|mov|mts|m4v|mpg|mp2|mpeg|mpe|mpv|m4p|qt|swf|avchd)$/i + /(mp4|avi|3gp|ogg|wmv|webm|flv|mkv|mov|mts|m4v|mpg|mp2|mpeg|mpe|mpv|m4p|qt|swf|avchd|ts)$/i ); const isArchive = ext.match(/^(zip|tar)/i); const mediaTypes = this._store.getState().mediaTypes; diff --git a/ui/src/js/organization-settings/affiliation-edit.js b/ui/src/js/organization-settings/affiliation-edit.js index 9fe73c78c..57228ecb2 100644 --- a/ui/src/js/organization-settings/affiliation-edit.js +++ b/ui/src/js/organization-settings/affiliation-edit.js @@ -29,7 +29,7 @@ export class AffiliationEdit extends OrgTypeFormTemplate { async _setupFormUnique() { // - this._userInput.hidden = this._data.id !== "New"; + this._userInput.hidden = this._data?.id !== "New"; // permission if (!this._permissionSelect._choices) { @@ -40,14 +40,20 @@ export class AffiliationEdit extends OrgTypeFormTemplate { this._permissionSelect.choices = permissionOptions; } - this._permissionSelect._select.required = this._data.id === "New"; - this._permissionSelect.setValue(this._data.permission); - this._permissionSelect.default = this._data.permission; + this._permissionSelect._select.required = this._data?.id === "New"; + + if (this._data?.id === "New" || !this._data) { + this._permissionSelect.setValue("Member"); + this._permissionSelect.default = "Member"; + } else { + this._permissionSelect.setValue(this._data.permission); + this._permissionSelect.default = this._data.permission; + } } _getFormData() { let formData; - if (this._data.id == "New") { + if (this._data?.id == "New" || !this._data) { formData = []; const users = this._userData.getUsers(); for (const user of users.values()) { diff --git a/ui/src/js/organization-settings/bucket-edit.js b/ui/src/js/organization-settings/bucket-edit.js index 686ce329b..1408e7f2a 100644 --- a/ui/src/js/organization-settings/bucket-edit.js +++ b/ui/src/js/organization-settings/bucket-edit.js @@ -87,7 +87,7 @@ export class BucketEdit extends OrgTypeFormTemplate { async _setupFormUnique() { // name - if (this._data.id === "New") { + if (this._data?.id === "New") { this._editName.setValue(""); this._editName.default = ""; } else { @@ -96,7 +96,7 @@ export class BucketEdit extends OrgTypeFormTemplate { } // type - if (this._data.id == "New") { + if (this._data?.id == "New") { this._editStoreType.setValue("MINIO"); this._editStoreType.default = "MINIO"; this._editStoreType.hidden = false; @@ -112,7 +112,7 @@ export class BucketEdit extends OrgTypeFormTemplate { ); // external host - if (this._data.id == "New") { + if (this._data?.id == "New") { this._editExternalHost.setValue(""); this._editExternalHost.default = ""; } else { @@ -122,7 +122,7 @@ export class BucketEdit extends OrgTypeFormTemplate { // archive storage class this._showArchiveScField(); - if (this._data.id == "New") { + if (this._data?.id == "New") { this._editArchiveSc.setValue("STANDARD"); this._editArchiveSc.default = "STANDARD"; } else { @@ -131,7 +131,7 @@ export class BucketEdit extends OrgTypeFormTemplate { } // live storage class - if (this._data.id == "New") { + if (this._data?.id == "New") { this._editLiveSc.setValue("STANDARD"); this._editLiveSc.default = "STANDARD"; } else { @@ -146,7 +146,7 @@ export class BucketEdit extends OrgTypeFormTemplate { _getFormData() { let formData = {}; - const isNew = this._data.id == "New"; + const isNew = this._data?.id == "New"; // Cannot edit bucket type after creation, so only consider if isNew if (isNew) { diff --git a/ui/src/js/organization-settings/components/new-membership-dialog.js b/ui/src/js/organization-settings/components/new-membership-dialog.js index 5c51a734a..13347ae3a 100644 --- a/ui/src/js/organization-settings/components/new-membership-dialog.js +++ b/ui/src/js/organization-settings/components/new-membership-dialog.js @@ -97,7 +97,7 @@ export class AffiliationMembershipDialog extends ModalDialog { showHiddenNewFields() { const versionChosen = this._versions.getValue(); - console.log(`Version was chosen! ${versionChosen}`, this._user); + if ( versionChosen === "__add_custom_version_name" || versionChosen === "__add_user_first_last" @@ -128,8 +128,10 @@ export class AffiliationMembershipDialog extends ModalDialog { } set username(val) { - this._username = val; - this.setUserId(this._username); + if (val) { + this._username = val; + this.setUserId(this._username); + } } set pageModal(val) { @@ -150,12 +152,6 @@ export class AffiliationMembershipDialog extends ModalDialog { // todo skip if user doesn't have those project permissions Or if is already in that project // should only return projects in which user has Full Control // should skip any that the current user is already a member of.... - console.log( - "DO WE HAVE A USER ID? " + - this._userId + - "DO WE HAVE A USER NAME? " + - this._username - ); let skipList = []; if ( this._username && @@ -164,15 +160,13 @@ export class AffiliationMembershipDialog extends ModalDialog { skipList = store .getState() .Membership.usernameProjectIdMap.get(this._username); - console.log(skipList); } - console.log(skipList); const projectChoices = await getCompiledList({ type: "Project", skip: Array.from(skipList), }); - console.log("projectChoices.length = " + projectChoices.length); + if (projectChoices.length === 1) { this._messageList.innerHTML = `
  • ${ diff --git a/ui/src/js/organization-settings/components/org-type-affiliate-container.js b/ui/src/js/organization-settings/components/org-type-affiliate-container.js index b1de0178a..f3c606b87 100644 --- a/ui/src/js/organization-settings/components/org-type-affiliate-container.js +++ b/ui/src/js/organization-settings/components/org-type-affiliate-container.js @@ -62,8 +62,9 @@ export class OrgTypeAffiliateContainer extends OrgTypeFormContainer { this._form.data = data; // Setup object info - this.objectName = data.username; - this.updateAffiliateSidebar(data.username); + const userName = data?.username ? data.username : ""; + this.objectName = userName; + this.updateAffiliateSidebar(userName); } async updatedProjectData(newProjectData) { diff --git a/ui/src/js/organization-settings/components/org-type-form-container.js b/ui/src/js/organization-settings/components/org-type-form-container.js index 2b744620c..eb97f2a07 100644 --- a/ui/src/js/organization-settings/components/org-type-form-container.js +++ b/ui/src/js/organization-settings/components/org-type-form-container.js @@ -136,7 +136,9 @@ export class OrgTypeFormContainer extends TatorElement { */ set objectName(val) { this._objectName = val; - if (this._typeId === "New") { + // console.log("Org type container ... this._objectName = " + this._objectName); + + if (this._typeId === "New" || val === "") { this.editH1.hidden = true; this.newH1.hidden = false; this.sideCol.hidden = true; @@ -154,7 +156,7 @@ export class OrgTypeFormContainer extends TatorElement { let objectName = ""; // Setup object info - this.objectName = data.name; + this.objectName = data?.name ? data.name : ""; } /** @@ -213,7 +215,6 @@ export class OrgTypeFormContainer extends TatorElement { .getState() .getData(this._typeName, this._typeId); // console.log(`DEBUG: selection found newData for ${this._typeName}, id: ${this._typeId}`, data); - if (data) { this.setUpData(data); this._form.data = data; @@ -230,6 +231,7 @@ export class OrgTypeFormContainer extends TatorElement { // resetToNew() { this._form.data = null; + this.setUpData(null); this.objectName = ""; } } diff --git a/ui/src/js/organization-settings/components/org-type-form-template.js b/ui/src/js/organization-settings/components/org-type-form-template.js index 9b2dedcfc..74fc99431 100644 --- a/ui/src/js/organization-settings/components/org-type-form-template.js +++ b/ui/src/js/organization-settings/components/org-type-form-template.js @@ -52,8 +52,8 @@ export class OrgTypeFormTemplate extends TatorElement { this._data = this._getEmptyData(); } - this.typeId = this._data.id; - this.objectName = this._data.name; + this.typeId = this._data?.id ? this._data.id : "New"; + this.objectName = this._data?.name ? this._data.name : ""; this._setupFormUnique(); } @@ -68,7 +68,7 @@ export class OrgTypeFormTemplate extends TatorElement { async saveDataFunction() { const formData = this._getFormData(); - console.log("formData", formData); + if (Object.entries(formData).length !== 0 && !Array.isArray(formData)) { try { const respData = await this.doSaveAction(formData); @@ -77,16 +77,17 @@ export class OrgTypeFormTemplate extends TatorElement { this.modal._error(err); } } else if (Array.isArray(formData) && formData.length !== 0) { - const responses = []; - for (let d of formData) { - const respData = await this.doSaveAction(d); - responses.push(respData); + try { + const respData = await this.doSaveAction(formData, true); + this.handleResponseList(respData); + } catch (err) { + this.modal._error(err); } - console.log("formData do save action responses", responses); - this.handleResponseList(responses); } else { this.modal._success("Nothing new to save!"); } + + this.data = null; } /** @@ -132,12 +133,19 @@ export class OrgTypeFormTemplate extends TatorElement { return this._warningDeleteMessage; } - doSaveAction(formData) { - const info = { type: this.typeName, id: this.typeId, data: formData }; - if (this.typeId == "New") { - return store.getState().addType(info); + async doSaveAction(formData, isArray = false) { + const info = { + type: this.typeName, + id: this.typeId, + data: formData, + }; + + if (this.typeId == "New" && isArray) { + return await store.getState().addTypeArray(info); + } else if (this.typeId == "New" && !isArray) { + return await store.getState().addTypeSingle(info); } else { - return store.getState().updateType(info); + return await store.getState().updateType(info); } } @@ -163,39 +171,47 @@ export class OrgTypeFormTemplate extends TatorElement { } handleResponseList(responses) { - let sCount = 0; - let eCount = 0; - let errors = ""; + if (responses && Array.isArray(responses)) { + let sCount = 0; + let eCount = 0; + let errors = ""; + + for (let object of responses) { + if (object.response.ok) { + sCount++; + } else { + eCount++; + const message = JSON.parse(object.response.text).message; + errors += `

    ${message}`; //${r.data.message} + } + } - for (let object of responses) { - if (object.response.ok) { - sCount++; + if (sCount > 0 && eCount === 0) { + return this.modal._success( + `Successfully added ${sCount} ${this.typeName}${ + sCount == 1 ? "" : "s" + }.` + ); + } else if (sCount > 0 && eCount > 0) { + return this.modal._complete( + `Successfully added ${sCount} ${ + this.typeName + }s.

    Error adding ${eCount} ${this.typeName}${ + eCount == 1 ? "" : "s" + }.

    Error message${ + eCount == 1 ? "" : "s" + }:

    ${errors}` + ); } else { - eCount++; - const message = JSON.parse(object.response.text).message; - errors += `Error: ${message} \n`; //${r.data.message} + return this.modal._error( + `Error adding ${eCount} ${this.typeName}${ + eCount == 1 ? "" : "s" + }.

    Error message${ + eCount == 1 ? "" : "s" + }:

    ${errors}` + ); } } - - if (sCount > 0 && eCount === 0) { - return this.modal._success( - `Successfully added ${sCount} ${this.typeName}s.` - ); - } else if (sCount > 0 && eCount > 0) { - return this.modal._complete( - `Successfully added ${sCount} ${ - this.typeName - }s.\n Error adding ${eCount} ${this.typeName}s.\n Error message${ - eCount == 1 ? "" : "s" - }: ${errors}` - ); - } else { - return this.modal._error( - `Error adding ${eCount} ${this.typeName}s.\n Error message${ - eCount == 1 ? "" : "s" - }: ${errors}` - ); - } } // Use the most recently set data to update the values of form diff --git a/ui/src/js/organization-settings/components/org-type-invitation-container.js b/ui/src/js/organization-settings/components/org-type-invitation-container.js index c18f9cc5e..addba8a31 100644 --- a/ui/src/js/organization-settings/components/org-type-invitation-container.js +++ b/ui/src/js/organization-settings/components/org-type-invitation-container.js @@ -63,8 +63,10 @@ export class OrgTypeInvitationContainer extends OrgTypeFormContainer { let objectName = ""; // Setup object info - this.objectName = data.email; - this._setupButtonsInvite(this._form._data.status); + + this.objectName = data && data.email ? data.email : ""; + const status = this._form?._data?.status ? this._form._data.status : null; + this._setupButtonsInvite(status); } async _resetInvitation() { @@ -84,7 +86,6 @@ export class OrgTypeInvitationContainer extends OrgTypeFormContainer { async _setupButtonsInvite(status) { const showReset = ["Expired", "Pending"]; const showCustomButton = showReset.includes(status) || status == "Accepted"; - const inviteEmail = this._data.email; if (showCustomButton) { if (showReset.includes(status)) { @@ -92,6 +93,7 @@ export class OrgTypeInvitationContainer extends OrgTypeFormContainer { this._customButtonSectionPrimary.hidden = true; } else if (status == "Accepted") { const result = await store.getState().initType("Affiliation"); + const inviteEmail = this._data?.email ? this._data.email : ""; const affiliation = store .getState() .Affiliation.emailMap.has(inviteEmail) diff --git a/ui/src/js/organization-settings/components/org-type-project-container.js b/ui/src/js/organization-settings/components/org-type-project-container.js index b827b0ac2..2140f66cc 100644 --- a/ui/src/js/organization-settings/components/org-type-project-container.js +++ b/ui/src/js/organization-settings/components/org-type-project-container.js @@ -67,9 +67,7 @@ export class OrgTypeProjectContainer extends OrgTypeFormContainer { this._data = data; this._form.data = data; - console.log(`setUpData for ${this._typeName}..`, data); - this.objectName = data.name; - + this.objectName = data?.name ? data.name : ""; this._customButtonSectionPrimary.hidden = false; this.sideCol.hidden = false; @@ -123,12 +121,6 @@ export class OrgTypeProjectContainer extends OrgTypeFormContainer { } updatedMembershipData(newMembershipData) { - console.log( - "State updated for MEMBERSHIP while we are selected for ProjectId" + - this._typeId, - newMembershipData - ); - // Setting data, should be a list of memberships projects const projectId = Number(this._typeId); const data = newMembershipData.projectIdMembersMap.get(projectId); @@ -139,13 +131,11 @@ export class OrgTypeProjectContainer extends OrgTypeFormContainer { if (this._typeId !== "New") { this._saveEditSection.classList.add("hidden"); const projectId = Number(this._typeId); - console.log("Update sidebar called with data", data); if (data == null) { data = await store.getState().getProjMembershipData(projectId); } this.projectMembershipSidebar.projectId = projectId; - console.log("Setting sidebar data to....", { projectId, data }); this.projectMembershipSidebar.data = { projectId, data }; // Projects diff --git a/ui/src/js/organization-settings/components/project-memberships-sidebar.js b/ui/src/js/organization-settings/components/project-memberships-sidebar.js index 08cbaadb7..b67fe6141 100644 --- a/ui/src/js/organization-settings/components/project-memberships-sidebar.js +++ b/ui/src/js/organization-settings/components/project-memberships-sidebar.js @@ -96,9 +96,7 @@ export class ProjectMembershipSidebar extends TatorElement { .currentUser.membershipsByProject.has(projectId) ? true : false; - console.log( - `Does user have control of this projectId ${projectId}? hasControl=${hasControl} (if false, add new should be hidden)` - ); + await store.getState().initType("Affiliation"); if (!hasControl) { this._addNew.classList.add("hidden"); diff --git a/ui/src/js/organization-settings/invitation-edit.js b/ui/src/js/organization-settings/invitation-edit.js index 640d4957e..3f9266c8a 100644 --- a/ui/src/js/organization-settings/invitation-edit.js +++ b/ui/src/js/organization-settings/invitation-edit.js @@ -34,7 +34,7 @@ export class InvitationEdit extends OrgTypeFormTemplate { async _setupFormUnique() { // this._emailInput.clear(); - this._emailInput.hidden = this._data.id !== "New"; + this._emailInput.hidden = this._data?.id !== "New"; // permission if (!this._permissionSelect._choices) { @@ -45,8 +45,14 @@ export class InvitationEdit extends OrgTypeFormTemplate { this._permissionSelect.choices = permissionOptions; } this._permissionSelect._select.required = this._data.id === "New"; - this._permissionSelect.setValue(this._data.permission); - this._permissionSelect.default = this._data.permission; + + if (this._data.permission) { + this._permissionSelect.setValue(this._data.permission); + this._permissionSelect.default = this._data.permission; + } else { + this._permissionSelect.setValue("Member"); + this._permissionSelect.default = "Member"; + } this._permissionSelect.permission = "Can Edit"; // status diff --git a/ui/src/js/organization-settings/project-display-list.js b/ui/src/js/organization-settings/project-display-list.js index 04804b81e..41777b2d3 100644 --- a/ui/src/js/organization-settings/project-display-list.js +++ b/ui/src/js/organization-settings/project-display-list.js @@ -67,8 +67,8 @@ export class ProjectDisplayList extends OrgTypeFormTemplate { // _getFormData() { // } + //THIS IS THE OVERRIDE FOR SAVE DATA.... async _saveData() { - console.log("THIS IS THE OVERRIDE FOR SAVE DATA...."); const projectSpec = this._newProjectDialog.getProjectSpec(); const preset = this._newProjectDialog.getProjectPreset(); @@ -76,7 +76,6 @@ export class ProjectDisplayList extends OrgTypeFormTemplate { const projectInfo = await store .getState() .addProject(projectSpec, preset); - console.log("Project response info ", projectInfo); if (projectInfo.response.ok) { this.data = null; diff --git a/ui/src/js/organization-settings/store.js b/ui/src/js/organization-settings/store.js index 4f2a2a170..d3ac98aa9 100644 --- a/ui/src/js/organization-settings/store.js +++ b/ui/src/js/organization-settings/store.js @@ -512,12 +512,7 @@ const store = create( projectList = [...new Set(projectList)]; usernameProjectIdMap.set(item.username, projectList); - // - console.log( - `${ - item.user - } ${typeof item.user} === ${currentUserId} ${typeof currentUserId} ` - ); + /* */ if ( item.user === currentUserId && item.permission === "Full Control" @@ -530,11 +525,6 @@ const store = create( } } - console.log( - "membershipsByProject is set to currentUserMap", - currentUserMap - ); - set({ Membership: { ...get().Membership, @@ -581,8 +571,7 @@ const store = create( } } }, - - addType: async ({ type, data }) => { + addTypeSingle: async ({ type, data }) => { set({ status: { ...get().status, @@ -590,23 +579,57 @@ const store = create( msg: `Adding ${type}...`, }, }); - try { - const fn = postMap.get(type); - const organizationId = get().organizationId; - const responseInfo = await fn(organizationId, data); + const responseInfo = await get().addType({ type, data }); - // Refresh the page data before setting the selection - await get().fetchTypeByOrg(type); + // Refresh all the data + await get().fetchTypeByOrg(type); + + // Select the new type + let newID = responseInfo.data.id ? responseInfo.data.id : "New"; + window.location = `${window.location.origin}${window.location.pathname}#${type}-${newID}`; + + set({ status: { ...get().status, name: "idle", msg: "" } }); + + // Refresh the page data before setting the selection + return responseInfo; + }, + addTypeArray: async ({ type, data }) => { + set({ + status: { + ...get().status, + name: "pending", + msg: `Adding multiple ${type}...`, + }, + }); + + let responseInfo = null; + const responses = []; + for await (let d of data) { + responseInfo = await get().addType({ type, data: d }); + responses.push(responseInfo); + } + + // Refresh all the data + await get().fetchTypeByOrg(type); - // Select the new type - //Response should have the newly added ID - let newID = responseInfo.data.id ? responseInfo.data.id : "New"; + // Select the LAST new type + //Response should have the newly added ID + + if (responseInfo?.data?.id) { + const newID = responseInfo?.data?.id ? responseInfo.data.id : "New"; window.location = `${window.location.origin}${window.location.pathname}#${type}-${newID}`; + } - set({ status: { ...get().status, name: "idle", msg: "" } }); + set({ status: { ...get().status, name: "idle", msg: "" } }); - // This includes the reponse so error handling can happen in ui - return responseInfo; + // Refresh the page data before setting the selection + return responses; + }, + addType: async ({ type, data }) => { + try { + const fn = postMap.get(type); + const organizationId = get().organizationId; + return await fn(organizationId, data); } catch (err) { set({ status: { ...get().status, name: "idle", msg: "" } }); return err; @@ -663,14 +686,12 @@ const store = create( const type = "Invitation"; const fn = deleteMap.get(type); const object = await fn(inviteSpec.id); - console.log("Result deleting" + inviteSpec.id, object); const createFn = postMap.get(type); const objectCreate = await createFn(get().organizationId, { email: inviteSpec.email, permission: inviteSpec.permission, }); - console.log("Result adding a new with the same spec", objectCreate); get().setSelection({ typeName: "Invitation", @@ -688,7 +709,6 @@ const store = create( }, getProjectByUsername: async (username) => { const info = await get().initType("Project"); - console.log("Project info....", info); const userProjects = info.userMap.has(username) ? info.userMap.has(username) : []; @@ -700,7 +720,6 @@ const store = create( newVersion = false, newVersionName = "", }) => { - console.log("addMembership to projectId " + projectId); set({ status: { ...get().status, @@ -709,19 +728,15 @@ const store = create( }, }); try { - console.log( - `newVersion ${newVersion} newVersionName ${newVersionName}` - ); if (newVersion) { const info = await api.createVersionWithHttpInfo(projectId, { name: newVersionName, }); if (info.response.ok) { - const newVersionId = info.data.id; + const newVersionId = info.data?.id ? info.data.id : null; formData.default_version = newVersionId; } } - console.log("Creating membership with formdata", formData); const info = await api.createMembershipWithHttpInfo( projectId, @@ -729,7 +744,6 @@ const store = create( ); await get().fetchMemberships(); - console.log("Returning ", info); return info; } catch (err) { set({ status: { ...get().status, name: "idle", msg: "" } }); @@ -737,7 +751,6 @@ const store = create( } }, updateMembership: async ({ membershipId, formData }) => { - console.log("updateMembership, membershipId: " + membershipId); set({ status: { ...get().status, @@ -832,7 +845,6 @@ const loopMap = ({ map, skip, check, type }) => { ]; for (let [id, item] of map) { - console.log(`Is this id in skip? id ${id} ${!skip.includes(id)}`, skip); if (typeof item !== "undefined" && id !== skip && !skip.includes(id)) { newList.push({ id: id, diff --git a/ui/src/js/project-settings/components/type-form-template.js b/ui/src/js/project-settings/components/type-form-template.js index ce1c8fbd1..43ab84003 100644 --- a/ui/src/js/project-settings/components/type-form-template.js +++ b/ui/src/js/project-settings/components/type-form-template.js @@ -77,15 +77,14 @@ export class TypeFormTemplate extends TatorElement { this.modal._error(err); } } else if (Array.isArray(formData) && formData.length !== 0) { - const responses = []; - for (let d of formData) { - const respData = await this.doSaveAction(d); - responses.push(respData); - } + const responses = await this.doSaveAction(formData, true); + this.handleResponseList(responses); } else { this.modal._success("Nothing new to save!"); } + + this.data = null; } /** @@ -131,12 +130,21 @@ export class TypeFormTemplate extends TatorElement { return this._warningDeleteMessage; } - doSaveAction(formData) { - const info = { type: this.typeName, id: this.typeId, data: formData }; - if (this.typeId == "New") { - return store.getState().addType(info); + async doSaveAction(formData, isArray = false) { + const info = { + type: this.typeName, + id: this.typeId, + data: formData, + }; + + console.log("Form data", formData); + + if (this.typeId == "New" && isArray) { + return await store.getState().addTypeArray(info); + } else if (this.typeId == "New" && !isArray) { + return await store.getState().addTypeSingle(info); } else { - return store.getState().updateType(info); + return await store.getState().updateType(info); } } @@ -162,45 +170,52 @@ export class TypeFormTemplate extends TatorElement { } handleResponseList(responses) { - let sCount = 0; - let eCount = 0; - let errors = ""; + if (responses && Array.isArray(responses)) { + let sCount = 0; + let eCount = 0; + let errors = ""; + + for (let object of responses) { + if (object?.response?.ok) { + sCount++; + } else { + eCount++; + const message = JSON.parse(object.response.text).message; + errors += `

    ${message}`; //${r.data.message} + } + } - for (let object of responses) { - if (object.response.ok) { - sCount++; + if (sCount > 0 && eCount === 0) { + return this.modal._success( + `Successfully added ${sCount} ${this.typeName}${ + sCount == 1 ? "" : "s" + }.` + ); + } else if (sCount > 0 && eCount > 0) { + return this.modal._complete( + `Successfully added ${sCount} ${ + this.typeName + }s.

    Error adding ${eCount} ${this.typeName}${ + eCount == 1 ? "" : "s" + }.

    Error message${ + eCount == 1 ? "" : "s" + }:

    ${errors}` + ); } else { - eCount++; - const message = JSON.parse(object.response.text).message; - errors += `Error: ${message} \n`; //${r.data.message} + return this.modal._error( + `Error adding ${eCount} ${this.typeName}${ + eCount == 1 ? "" : "s" + }.

    Error message${ + eCount == 1 ? "" : "s" + }:

    ${errors}` + ); } } - - if (sCount > 0 && eCount === 0) { - return this.modal._success( - `Successfully added ${sCount} ${this.typeName}s.` - ); - } else if (sCount > 0 && eCount > 0) { - return this.modal._complete( - `Successfully added ${sCount} ${ - this.typeName - }s.\n Error adding ${eCount} ${this.typeName}s.\n Error message${ - eCount == 1 ? "" : "s" - }: ${errors}` - ); - } else { - return this.modal._error( - `Error adding ${eCount} ${this.typeName}s.\n Error message${ - eCount == 1 ? "" : "s" - }: ${errors}` - ); - } } - // Use the most recently set data to update the values of form _resetForm(evt) { evt.preventDefault(); - this.setupForm(this._data); + this.data = null; } async _deleteType() { diff --git a/ui/src/js/project-settings/store.js b/ui/src/js/project-settings/store.js index 995953d85..47fc825d1 100644 --- a/ui/src/js/project-settings/store.js +++ b/ui/src/js/project-settings/store.js @@ -222,7 +222,6 @@ const store = create( initHeader: async () => { Promise.all([api.whoami(), api.getAnnouncementList()]).then((values) => { - console.log(values[0]); set({ user: values[0], announcements: values[1], @@ -385,29 +384,11 @@ const store = create( } }, addType: async ({ type, data }) => { - set({ - status: { ...get().status, name: "pending", msg: `Adding ${type}...` }, - }); try { const fn = postMap.get(type); const projectId = get().projectId; const responseInfo = await fn(projectId, data); - // Refresh the page data before setting the selection - await get().fetchType(type); - - // Select the new type (non-Leaf) forms - if (type === "Leaf") { - // Try to reset selection to refresh the same page - get().setSelection({ typeId: get().selection.typeId }); - } else { - //Response should have the newly added ID - let newID = responseInfo.data.id ? responseInfo.data.id : "New"; - window.location = `${window.location.origin}${window.location.pathname}#${type}-${newID}`; - } - - set({ status: { ...get().status, name: "idle", msg: "" } }); - // This includes the reponse so error handling can happen in ui return responseInfo; } catch (err) { @@ -415,6 +396,65 @@ const store = create( return err; } }, + addTypeSingle: async ({ type, data }) => { + set({ + status: { + ...get().status, + name: "pending", + msg: `Adding single ${type}...`, + }, + }); + + const responseInfo = await get().addType({ type, data }); + + // Select the new type (non-Leaf) forms + if (type === "Leaf") { + // Try to reset selection to refresh the same page + get().setSelection({ typeId: get().selection.typeId }); + } else { + //Response should have the newly added ID + let newID = responseInfo.data.id ? responseInfo.data.id : "New"; + window.location = `${window.location.origin}${window.location.pathname}#${type}-${newID}`; + } + + // Refresh the page data before setting the selection + await get().fetchType(type); + + set({ status: { ...get().status, name: "idle", msg: "" } }); + + return responseInfo; + }, + addTypeArray: async ({ type, data }) => { + set({ + status: { + ...get().status, + name: "pending", + msg: `Adding array ${type}...`, + }, + }); + + const responses = []; + let lastInfo = null; + for (let d of data) { + // console.log("Adding data "+type, d); + const responseInfo = await get().addType({ type, data: d }); + responses.push(responseInfo); + lastInfo = responseInfo; + } + + // Refresh the page data before setting the selection + await get().fetchType(type); + + // Select the new type (non-Leaf) forms + if (type === "Leaf") { + // Try to reset selection to refresh the same page + get().setSelection({ typeId: get().selection.typeId }); + } + + set({ status: { ...get().status, name: "idle", msg: "" } }); + + return responses; + }, updateType: async ({ type, id, data }) => { set({ status: { @@ -538,10 +578,6 @@ export const getAttributeDataByType = async () => { const data = await store.getState().initType(type); const list = Array.isArray(data) ? data : data.map.values(); for (let entity of list) { - console.log( - ` attributeDataByType[${type}][${entity.name}] = entity.attribute_types`, - entity.attribute_types - ); attributeDataByType[type][entity.name] = entity.attribute_types; } } diff --git a/ui/src/js/project-settings/type-forms/algorithm-edit.js b/ui/src/js/project-settings/type-forms/algorithm-edit.js index 1569ce7a5..55463ef7a 100644 --- a/ui/src/js/project-settings/type-forms/algorithm-edit.js +++ b/ui/src/js/project-settings/type-forms/algorithm-edit.js @@ -175,8 +175,8 @@ export class AlgorithmEdit extends TypeFormTemplate { // Path to manifest this._manifestPath.permission = !this.cantSave ? "Can Edit" : "View Only"; if (this._data.manifest) { - this._manifestPath.setValue(`/media/${this._data.manifest}`); - this._manifestPath.default = `/media/${this._data.manifest}`; + this._manifestPath.setValue(`${this._data.manifest}`); + this._manifestPath.default = `${this._data.manifest}`; } else { this._manifestPath.setValue(null); this._manifestPath.default = null; @@ -193,7 +193,7 @@ export class AlgorithmEdit extends TypeFormTemplate { ); if (resp.ok) { const manifestData = await resp.json(); - const viewLink = `/media/${manifestData.url}`; + const viewLink = `${manifestData.url}`; this._manifestPath.setValue(viewLink); Utilities.showSuccessIcon(`Manifest file uploaded to: ${viewLink}`); } else { diff --git a/ui/src/js/project-settings/type-forms/membership-edit.js b/ui/src/js/project-settings/type-forms/membership-edit.js index 0d83645a6..73d75caf7 100644 --- a/ui/src/js/project-settings/type-forms/membership-edit.js +++ b/ui/src/js/project-settings/type-forms/membership-edit.js @@ -44,15 +44,15 @@ export class MembershipEdit extends TypeFormTemplate { } async _setupFormUnique() { - console.log("_setupFormUnique"); + console.log("Membership form ... _setupFormUnique"); + this._userInput.reset(); + this._userInput.init(this._userData); + if (store.getState().Version.init === false) { await store.getState().initType("Version"); - await this.setVersionChoices(); } - this._userInput.reset(); if (this._data.id == "New") { - this._userInput.init(this._userData); this._userInput.hidden = false; } else { this._userInput.hidden = true; @@ -94,7 +94,6 @@ export class MembershipEdit extends TypeFormTemplate { async setVersionChoices() { this._versionSelect.clear(); const versionOptions = await getCompiledList({ type: "Version" }); - console.log("versionOptions", versionOptions); this._versionSelect.choices = versionOptions; } @@ -107,11 +106,8 @@ export class MembershipEdit extends TypeFormTemplate { for (const [userId, user] of users.entries()) { formData.push({ user: userId, - username: user.username, // ignored by BE, used by FE only - project: this.projectId, permission: this._permissionSelect.getValue(), default_version: Number(this._versionSelect.getValue()), - default_version_id: Number(this._versionSelect.getValue()), // ignored by BE, used by FE only }); } } else { diff --git a/ui/src/js/util/tator-data.js b/ui/src/js/util/tator-data.js index cbd1fccce..932f4cc07 100644 --- a/ui/src/js/util/tator-data.js +++ b/ui/src/js/util/tator-data.js @@ -267,10 +267,18 @@ export class TatorData { * Returns data for getFrame with project ID */ async getFrame(frameId) { - const response = await fetchCredentials(`/rest/GetFrame/${frameId}`, { - mode: "cors", - credentials: "include", - }); + const response = await fetchCredentials( + `/rest/GetFrame/${frameId}`, + { + mode: "cors", + headers: { + "Content-Type": "image/*", + Accept: "image/*", + }, + }, + false, + true + ); const data = await response.json(); return data;