diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index 4d3e18c03..000000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,70 +0,0 @@ -version: 2.1 -# workflows: -# build_and_test: -# jobs: -# - test: -# matrix: -# parameters: -# python-version: ["3.6", "3.7"] -jobs: - build: - docker: - - image: cimg/python:3.10 - environment: - DATABASE_URL: postgres://root@127.0.0.1:5432/readux - PGUSER: root - AWS_ACCESS_KEY_ID: dummy-access-key - AWS_SECRET_ACCESS_KEY: dummy-access-key-secret - AWS_DEFAULT_REGION: us-east-1 - DJANGO_ENV: test - ELASTICSEARCH_URL: 127.0.0.1:9200 - - image: cimg/postgres:10.19 - environment: - POSTGRES_USER: root - POSTGRES_DB: readux - - image: cimg/ruby:2.5-node - - image: cimg/rust:1.59.0 - - image: docker.elastic.co/elasticsearch/elasticsearch:7.17.1 - environment: - - STACK_VERSION: 7.17.1 - - xpack.security.enabled: false - - cluster.name: readux-elasticsearch - - http.port: 9200 - - discovery.type: single-node - - steps: - - checkout - - run: - name: Install System packages - command: | - sudo apt update - sudo apt install -y libjpeg-dev libopenjp2-7-dev libopenjp2-tools libssl-dev postgresql-client ruby-full openssh-server - mkdir logs && touch logs/debug.log - sudo gem install bundler -v "$(grep -A 1 "BUNDLED WITH" Gemfile.lock | tail -n 1)" - sudo /etc/init.d/ssh start - - run: - name: Install Ruby Stuff - command: | - bundle install - sudo gem install sass - - run: - name: Install Python Stuff and Setup App - command: | - pip install --upgrade pip - pip install pyld==1.0.5 - pip install -r requirements/local.txt - cp config/settings/local.dst config/settings/local.py - - run: - name: Wait for Elasticsearch startup - command: | - dockerize -wait tcp://localhost:9200 - - run: - name: Running tests - command: | - DJANGO_ENV=test pytest apps/ --cov=./apps - - run: - name: Sending Coverage Report - when: on_success - command: | - coveralls - diff --git a/.djlintrc b/.djlintrc new file mode 100644 index 000000000..6ee67e5dd --- /dev/null +++ b/.djlintrc @@ -0,0 +1,3 @@ +{ + "ignore": "H006" +} \ No newline at end of file diff --git a/apps/ingest/templates/.gitkeep b/.github/.gitkeep similarity index 100% rename from apps/ingest/templates/.gitkeep rename to .github/.gitkeep diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index ad793c912..000000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,32 +0,0 @@ -version: 2 -updates: -- package-ecosystem: pip - directory: "/" - schedule: - interval: daily - time: "10:00" - open-pull-requests-limit: 10 - target-branch: develop - ignore: - - dependency-name: ipdb - versions: - - 0.13.4 - - 0.13.5 - - 0.13.6 - - dependency-name: django-crispy-forms - versions: - - 1.10.0 - - 1.11.0 - - 1.11.1 - - dependency-name: django - versions: - - 2.2.13 - - dependency-name: coverage - versions: - - "5.4" - - dependency-name: pytest-django - versions: - - 4.1.0 - - dependency-name: wagtail - versions: - - 2.9.3 diff --git a/.github/workflows/depoly.yml b/.github/workflows/depoly.yml new file mode 100644 index 000000000..9bb200cc1 --- /dev/null +++ b/.github/workflows/depoly.yml @@ -0,0 +1,60 @@ +name: Deploy + +on: + workflow_run: + workflows: [Test] + types: [completed] + +permissions: + id-token: write # This is required for requesting the JWT + contents: read # This is required for actions/checkout + +jobs: + deploy: + runs-on: ubuntu-latest + if: ${{ github.event.workflow_run.conclusion == 'success' }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + ref: ${{ github.event.workflow_run.head_branch }} + + - name: Checkout Docker configs + run: | + eval `ssh-agent -s` + ssh-add - <<< '${{ secrets.DEPLOY_KEY_FOR_CONFIG_REPO }}' + git clone -b "${{ github.event.workflow_run.head_branch }}" "${{ secrets.DEPLOY_CONFIG_REPO}}" + mv "${{ secrets.CONFIG_REPO_NAME }}"/* . + chmod +x config.sh + chmod +x deploy.sh + + - name: Run Config + run: ./config.sh + env: + AWS_KEY: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + AWS_FSX_VOLUME: ${{ secrets.AWS_FSX_VOLUME }} + DATABASE_URL: ${{ secrets.DATABASE_URL_DEV }} + EMAIL_USER: ${{ secrets.EMAIL_USER }} + EMAIL_PASSWORD: ${{ secrets.EMAIL_PASSWORD }} + BRANCH: ${{ github.event.workflow_run.head_branch }} + + - name: Configure AWS credentials from AWS account + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ secrets.AWS_ROLE_DEV }} + aws-region: us-east-1 + role-session-name: GitHub-OIDC-frontend + + - name: Run Deploy + env: + BRANCH: ${{ github.event.workflow_run.head_branch }} + AWS_KEY: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + AWS_FSX_VOLUME: ${{ secrets.AWS_FSX_VOLUME }} + DATABASE_URL_DEV: ${{ secrets.DATABASE_URL_DEV}} + DATABASE_URL_PROD: ${{ secrets.DATABASE_URL_PROD}} + EMAIL_USER: ${{ secrets.EMAIL_USER }} + EMAIL_PASSWORD: ${{ secrets.EMAIL_PASSWORD }} + DEPLOY_KEY: ${{ secrets.DEPLOY_KEY}} + run: ./deploy.sh diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 000000000..d8f8d92c8 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,102 @@ +name: Test + +on: + push: + branches: + - develop + - main + workflow_dispatch: + +env: + DATABASE_URL: postgres://user:password@localhost:5432/readux + DJANGO_ENV: test + +jobs: + pytest: + runs-on: ubuntu-22.04 + + services: + elasticsearch: + image: docker.elastic.co/elasticsearch/elasticsearch:7.17.5 + env: + STACK_VERSION: 7.17.5 + xpack.security.enabled: false + cluster.name: readux-elasticsearch + http.port: 9200 + discovery.type: single-node + options: >- + --health-cmd "curl http://localhost:9200/_cluster/health" + --health-interval 10s + --health-timeout 5s + --health-retries 10 + ports: + - 9200:9200 + + postgres: + image: postgres:12 + env: + POSTGRES_PASSWORD: password + POSTGRES_USER: user + POSTGRES_DB: readux_test + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Setup Python + uses: actions/setup-python@v4 + with: + python-version: "3.10" + + - name: Setup Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: "2.7.2" + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install system requirements + run: | + sudo apt update + sudo apt install -y libjpeg-dev libopenjp2-7-dev libopenjp2-tools libssl-dev postgresql-client ruby-full openssh-server + + - name: Install Ruby dependencies + run: | + sudo gem install bundler -v "$(grep -A 1 "BUNDLED WITH" Gemfile.lock | tail -n 1)" + bundle install + sudo gem install sass + + - name: Install Python dependencies + run: | + mkdir logs && touch logs/debug.log + cp config/settings/local.dst config/settings/local.py + pip install --upgrade pip + pip install pyld==1.0.5 + pip install -r requirements/local.txt + + # - name: Test Export + # run: pytest apps/export/ + + # - name: Test IIIF + # run: pytest apps/iiif/ + + # - name: Test Readux + # run: pytest apps/readux/ + + # - name: Test Users + # run: pytest apps/users/ + + # - name: Test Webpack build + # run: | + # npm install + # npx webpack diff --git a/.gitignore b/.gitignore index 48a309237..162eb3525 100644 --- a/.gitignore +++ b/.gitignore @@ -17,7 +17,6 @@ customizations/**/*.js ### python gitignores auto-generated by github - # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] @@ -128,7 +127,7 @@ static/plugins/annotator/scss/node_modules # Local Junk .DS_Store local.py -venv +*venv cert* *.bk .ruby-version @@ -146,7 +145,6 @@ assets/upload/* !assets/upload/index.html *_dev -snippets # profiler *.profile @@ -157,3 +155,13 @@ dev_*.json presentation-validator/ nohup.out webpack-stats.json +*.key +*.crt +*.pem + +# compiled scss and JS +apps/static/css/project.css +apps/static/js/main.js +apps/static/js/main.js.map + +snippets/* diff --git a/.pylintrc b/.pylintrc index 505260c8f..92423c0ee 100644 --- a/.pylintrc +++ b/.pylintrc @@ -47,99 +47,6 @@ unsafe-load-any-extension=no # all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED confidence= -# Disable the message, report, category or checker with the given id(s). You -# can either give multiple identifiers separated by comma (,) or put this -# option multiple times (only on the command line, not in the configuration -# file where it should appear only once).You can also use "--disable=all" to -# disable everything first and then reenable specific checks. For example, if -# you want to run only the similarities checker, you can use "--disable=all -# --enable=similarities". If you want to run only the classes checker, but have -# no Warning level messages displayed, use"--disable=all --enable=classes -# --disable=W" -disable=print-statement, - parameter-unpacking, - unpacking-in-except, - old-raise-syntax, - backtick, - long-suffix, - old-ne-operator, - old-octal-literal, - import-star-module-level, - non-ascii-bytes-literal, - invalid-unicode-literal, - raw-checker-failed, - bad-inline-option, - locally-disabled, - locally-enabled, - file-ignored, - suppressed-message, - useless-suppression, - deprecated-pragma, - apply-builtin, - basestring-builtin, - buffer-builtin, - cmp-builtin, - coerce-builtin, - execfile-builtin, - file-builtin, - long-builtin, - raw_input-builtin, - reduce-builtin, - standarderror-builtin, - unicode-builtin, - xrange-builtin, - coerce-method, - delslice-method, - getslice-method, - setslice-method, - no-absolute-import, - old-division, - dict-iter-method, - dict-view-method, - next-method-called, - metaclass-assignment, - indexing-exception, - raising-string, - reload-builtin, - oct-method, - hex-method, - nonzero-method, - cmp-method, - input-builtin, - round-builtin, - intern-builtin, - unichr-builtin, - map-builtin-not-iterating, - zip-builtin-not-iterating, - range-builtin-not-iterating, - filter-builtin-not-iterating, - using-cmp-argument, - eq-without-hash, - div-method, - idiv-method, - rdiv-method, - exception-message-attribute, - invalid-str-codec, - sys-max-int, - bad-python3-import, - deprecated-string-function, - deprecated-str-translate-call, - deprecated-itertools-function, - deprecated-types-field, - next-method-defined, - dict-items-not-iterating, - dict-keys-not-iterating, - dict-values-not-iterating, - deprecated-operator-function, - deprecated-urllib-function, - xreadlines-attribute, - deprecated-sys-function, - exception-escape, - comprehension-escape, - too-many-ancestors, - logging-format-interpolation, - logging-fstring-interpolation - # Enable the message, report, category or checker with the given id(s). You can # either give multiple identifier separated by comma (,) or put this option # multiple time (only on the command line, not in the configuration file where @@ -333,13 +240,6 @@ max-line-length=100 # Maximum number of lines in a module max-module-lines=1000 -# List of optional constructs for which whitespace checking is disabled. `dict- -# separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. -# `trailing-comma` allows a space between comma and closing bracket: (a, ). -# `empty-line` allows space-only lines. -no-space-check=trailing-comma, - dict-separator - # Allow the body of a class to be on the same line as the declaration if body # contains single statement. single-line-class-stmt=no diff --git a/Gemfile.lock b/Gemfile.lock index 6b4aaea9f..cb0a41d84 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -9,14 +9,14 @@ GIT GEM remote: https://rubygems.org/ specs: - activesupport (6.1.4.6) + activesupport (7.0.7.2) concurrent-ruby (~> 1.0, >= 1.0.2) i18n (>= 1.6, < 2) minitest (>= 5.1) tzinfo (~> 2.0) zeitwerk (~> 2.3) - concurrent-ruby (1.1.9) - faraday (1.10.0) + concurrent-ruby (1.1.10) + faraday (1.10.2) faraday-em_http (~> 1.0) faraday-em_synchrony (~> 1.0) faraday-excon (~> 1.1) @@ -32,29 +32,29 @@ GEM faraday-em_synchrony (1.0.0) faraday-excon (1.1.0) faraday-httpclient (1.0.1) - faraday-multipart (1.0.3) - multipart-post (>= 1.2, < 3) + faraday-multipart (1.0.4) + multipart-post (~> 2) faraday-net_http (1.0.1) faraday-net_http_persistent (1.2.0) faraday-patron (1.0.0) faraday-rack (1.0.0) faraday-retry (1.0.3) - i18n (1.10.0) + i18n (1.12.0) concurrent-ruby (~> 1.0) iiif-presentation (0.2.0) activesupport (>= 3.2.18) faraday (>= 0.9) json - ipaddr (1.2.4) - json (2.6.1) + ipaddr (1.2.5) + json (2.6.3) minitest (5.15.0) - multipart-post (2.1.1) - openssl (2.2.1) + multipart-post (2.2.3) + openssl (3.1.0) ipaddr ruby2_keywords (0.0.5) - tzinfo (2.0.4) + tzinfo (2.0.5) concurrent-ruby (~> 1.0) - zeitwerk (2.5.4) + zeitwerk (2.6.6) PLATFORMS ruby diff --git a/INGEST.md b/INGEST.md new file mode 100644 index 000000000..20e0bf0e2 --- /dev/null +++ b/INGEST.md @@ -0,0 +1,51 @@ +# Ingest Steps + +## Local Ingest + +A zip file containing a single volume and metadata file are uploaded. + +### Options + +- Image server +- Zip file + +### Steps + +1. Admin form is saved + 1. `Manifest` is created by `Apps.Ingest.services.create_manifest()`. + 2. The `Local` object is passed to teh `create_canvas_from_local_task` task. +2. `create_canvas_from_local_task()` + 1. Creates `Manifest` is missing. + 2. `create_canvases()` is called on the `Local` object. + 1. The zip file is streamed from S3 in chunks. + 2. Files that are not an image or OCR file are ignored. + 3. Image and OCR files are uploaded to the target image server's storage service. + 4. Lists of the uploaded image and OCR files are iterated through + 1. `Canvas` objects are created from the image files. + 2. If the image as an associated OCR file, the `Canvas` object is passed to teh `add_ocr_task` task. + 3. Each `Canvas` object is passed to an `ensure_dimensions` task. + +## Bulk Ingest + +Zip files of individual volumes are uploaded. Each zip file can include a metadata file or a single metadata file can be uploaded for all volumes. + +### Options + +- Image server +- Files + - Zip files of individual volumes + - Metadata file (optional) +- Collection (optional) + +### Steps + +1. Admin form is saved + 1. Metadata file is processed if present. + 2. A `Local` ingest object is created for each zip file. + 3. The zip file is saved as the `bundle_from_bulk` attribute on the `Local` object. This saves the zip file to the local file system. This is done so the person uploading this files does not have to wait for each file to be uploaded to S3. + 4. The `Local` object is passed to the `upload_to_s3_task` task +2. `upload_to_s3_task()` + 1. `Local.bundle_to_s2()` + 1. The `bundle_from_bulk` file object, which is on the local disk is assigned to the `Local` object's `bundle`. This uploads the file to S3 and deletes the local copy. + 2. The `Local` object is passed to the `create_canvas_from_local_task` task. +3. At this point, the bulk ingest process continues exactly as local ingest at step 2. diff --git a/LICENSE.md b/LICENSE.md index 310f5fc5f..a148280a3 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -186,6 +186,7 @@ third-party archives. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, diff --git a/NOTES.md b/NOTES.md new file mode 100644 index 000000000..0bf7797a4 --- /dev/null +++ b/NOTES.md @@ -0,0 +1,94 @@ +# Notes + +## IIP Server Notes + +The IIP server is running on a second Apache install `/root/apache` and a proxy is set up via the DreamHost panel. + +All the image files are in `/home/images` and owned by dh_bb3cmt. That is the user for the fcgi process defined in `/root/apache/conf/httpd.conf`. + +### Expired Certificates + +Certbot should auto-renew the certificates. The most current certificates are located in + +~~~sh +/etc/letsencrypt/live/iip.readux.io/ +~~~ + +If cert has not been renewed, follow these steps as the root user. + +~~~sh +certbot certonly -d iip.readux.io +~~~ + +Select option 2 "Place files in webroot directory (webroot)". Supply the following webroot: + +~~~sh +/home/dh_bb3cmt/iip +~~~ + +New certs can be be added via the DreamHost panel Websites -> Secure certificates -> Settings -> Add New Cert -> Import + +For the "Certificate", paste the contents of + +~~~sh +cat /etc/letsencrypt/live/iip.readux.io/cert.pem +~~~ + +For the "RSA Private Key", paste the contents of + +~~~sh +cat /etc/letsencrypt/live/iip.readux.io/privkey.pem +~~~ + +The other optional fields can be left blank. + +## Elasticsearch Notes + +### Expired Certs + +The certs are managed by certbot but they have to be readable by the elasticsearch group. So we have to 1) add the elasticsearch group to the ownership and 2) copy the certs to a directory readable by the elasticsearch group. Certbot really locks down access to the certs it create. + +Certbot should automatically create new certs, but if you need to make one, run: + +~~~sh +sudo certbot renew --force-renewal +~~~ + +To copy the certs, we have to go full root: + +~~~sh +sudo su - +cp /etc/letsencrypt/archive/search.readux.io/privkey{biggest number}.pem /etc/elasticsearch/certs/privkey.pem +cp /etc/letsencrypt/archive/search.readux.io/fullchain{biggest number}.pem /etc/elasticsearch/certs/fullchain.pem +exit +~~~ + +Add the elasticsearch group as an owner: + +~~~sh +sudo chown root:elasticsearch /etc/elasticsearch/certs/* +~~~ + +Restart Elasticsearch + +~~~sh +sudo service elasticsearch restart +~~~ + +## Convert existing SVG annotations + +~~~python +import re +from bs4 import BeautifulSoup +from svgpathtools import Path +import re + +for ua in j.userannotation_set.all(): + if ua.svg: + soup = BeautifulSoup(ua.svg, 'xml')` + d_path = soup.find('path')['d'] + path = Path(d_path) + new_path = re.sub('[A-Za-z]\s', '', path.d()) + ua.oa_annotation['on'][0]['selector']['item']['value'] = f'' + ua.save() +~~~ diff --git a/apps/cms/blocks.py b/apps/cms/blocks.py index bccc4c1a6..b698ec446 100644 --- a/apps/cms/blocks.py +++ b/apps/cms/blocks.py @@ -1,7 +1,7 @@ """Custom `StructBlock` classes.""" from wagtail.images.blocks import ImageChooserBlock from wagtail.embeds.blocks import EmbedBlock -from wagtail.core.blocks import ( +from wagtail.blocks import ( CharBlock, ChoiceBlock, RichTextBlock, StreamBlock, StructBlock, TextBlock, ) diff --git a/apps/cms/migrations/0001_squashed_0004_auto_20190409_1905.py b/apps/cms/migrations/0001_squashed_0004_auto_20190409_1905.py index 24ff7aadb..711e1fdc3 100644 --- a/apps/cms/migrations/0001_squashed_0004_auto_20190409_1905.py +++ b/apps/cms/migrations/0001_squashed_0004_auto_20190409_1905.py @@ -3,8 +3,8 @@ from django.db import migrations, models import django.db.models.deletion import modelcluster.fields -import wagtail.core.blocks -import wagtail.core.fields +import wagtail.blocks +import wagtail.fields import wagtail.embeds.blocks import wagtail.images.blocks @@ -26,7 +26,7 @@ class Migration(migrations.Migration): name='ContentPage', fields=[ ('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.Page')), - ('body', wagtail.core.fields.StreamField([('heading_block', wagtail.core.blocks.StructBlock([('heading_text', wagtail.core.blocks.CharBlock(classname='title', required=True)), ('size', wagtail.core.blocks.ChoiceBlock(blank=True, choices=[('', 'Select a header size'), ('h2', 'H2'), ('h3', 'H3'), ('h4', 'H4')], required=False))])), ('paragraph_block', wagtail.core.blocks.RichTextBlock(icon='fa-paragraph', template='blocks/paragraph_block.html')), ('image_block', wagtail.core.blocks.StructBlock([('image', wagtail.images.blocks.ImageChooserBlock(required=True)), ('caption', wagtail.core.blocks.CharBlock(required=False)), ('attribution', wagtail.core.blocks.CharBlock(required=False))])), ('block_quote', wagtail.core.blocks.StructBlock([('text', wagtail.core.blocks.TextBlock()), ('attribute_name', wagtail.core.blocks.CharBlock(blank=True, label='e.g. Mary Berry', required=False))])), ('embed_block', wagtail.embeds.blocks.EmbedBlock(help_text='Insert an embed URL e.g https://www.youtube.com/embed/SGJFWirQ3ks', icon='fa-s15', template='blocks/embed_block.html'))], blank=True, verbose_name='Page body')), + ('body', wagtail.fields.StreamField([('heading_block', wagtail.blocks.StructBlock([('heading_text', wagtail.blocks.CharBlock(classname='title', required=True)), ('size', wagtail.blocks.ChoiceBlock(blank=True, choices=[('', 'Select a header size'), ('h2', 'H2'), ('h3', 'H3'), ('h4', 'H4')], required=False))])), ('paragraph_block', wagtail.blocks.RichTextBlock(icon='fa-paragraph', template='blocks/paragraph_block.html')), ('image_block', wagtail.blocks.StructBlock([('image', wagtail.images.blocks.ImageChooserBlock(required=True)), ('caption', wagtail.blocks.CharBlock(required=False)), ('attribution', wagtail.blocks.CharBlock(required=False))])), ('block_quote', wagtail.blocks.StructBlock([('text', wagtail.blocks.TextBlock()), ('attribute_name', wagtail.blocks.CharBlock(blank=True, label='e.g. Mary Berry', required=False))])), ('embed_block', wagtail.embeds.blocks.EmbedBlock(help_text='Insert an embed URL e.g https://www.youtube.com/embed/SGJFWirQ3ks', icon='fa-s15', template='blocks/embed_block.html'))], blank=True, verbose_name='Page body')), ], options={ 'abstract': False, diff --git a/apps/cms/migrations/0003_auto_20200128_1721.py b/apps/cms/migrations/0003_auto_20200128_1721.py index 2d2330291..959bcae75 100644 --- a/apps/cms/migrations/0003_auto_20200128_1721.py +++ b/apps/cms/migrations/0003_auto_20200128_1721.py @@ -1,7 +1,7 @@ # Generated by Django 2.1.2 on 2020-01-28 17:21 from django.db import migrations -import wagtail.core.fields +import wagtail.fields class Migration(migrations.Migration): @@ -14,6 +14,6 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='homepage', name='tagline', - field=wagtail.core.fields.RichTextField(blank=True), + field=wagtail.fields.RichTextField(blank=True), ), ] diff --git a/apps/cms/migrations/0005_homepage_featured_story.py b/apps/cms/migrations/0005_homepage_featured_story.py new file mode 100644 index 000000000..01118b9f2 --- /dev/null +++ b/apps/cms/migrations/0005_homepage_featured_story.py @@ -0,0 +1,44 @@ +# Generated by Django 3.2.25 on 2024-09-13 16:25 + +from django.db import migrations, models + +class Migration(migrations.Migration): + + dependencies = [ + ("cms", "0004_auto_20200224_2038"), + ("wagtailimages", "0025_alter_image_file_alter_rendition_file"), + ] + + operations = [ + migrations.AddField( + model_name="homepage", + name="featured_story_image", + field=models.ForeignKey( + blank=True, + help_text="Thumbnail image for the featured story", + null=True, + on_delete=models.deletion.SET_NULL, + related_name="+", + to="wagtailimages.image", + ), + ), + migrations.AddField( + model_name="homepage", + name="featured_story_summary", + field=models.TextField( + blank=True, help_text="A summary or excerpt of the featured story" + ), + ), + migrations.AddField( + model_name="homepage", + name="featured_story_title", + field=models.CharField( + blank=True, help_text="Title of the featured story", max_length=255 + ), + ), + migrations.AddField( + model_name="homepage", + name="featured_story_url", + field=models.URLField(blank=True, help_text="Link to the featured story"), + ), + ] diff --git a/apps/cms/migrations/0006_create_footermenu.py b/apps/cms/migrations/0006_create_footermenu.py new file mode 100644 index 000000000..4746f20bd --- /dev/null +++ b/apps/cms/migrations/0006_create_footermenu.py @@ -0,0 +1,34 @@ +# Generated by Django 3.2.25 on 2024-09-19 20:35 + +from django.db import migrations + + +def create_footer_menu(apps, schema_editor): + """ + Data migration to create a flat menu for the footer items. + """ + Site = apps.get_model("wagtailcore", "Site") + FlatMenu = apps.get_model("wagtailmenus", "FlatMenu") + try: + site = Site.objects.get(is_default_site=True) + except Site.DoesNotExist: + site = Site.objects.first() + try: + FlatMenu.objects.get(handle="footer-menu") + except FlatMenu.DoesNotExist: + FlatMenu.objects.create( + title="Footer menu", + handle="footer-menu", + max_levels=1, + site=site, + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ("cms", "0005_homepage_featured_story"), + ("wagtailmenus", "0023_remove_use_specific"), + ] + + operations = [migrations.RunPython(create_footer_menu, migrations.RunPython.noop)] diff --git a/apps/cms/migrations/0007_homepage_featured_video.py b/apps/cms/migrations/0007_homepage_featured_video.py new file mode 100644 index 000000000..231b18191 --- /dev/null +++ b/apps/cms/migrations/0007_homepage_featured_video.py @@ -0,0 +1,37 @@ +# Generated by Django 3.2.25 on 2024-09-23 19:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("cms", "0006_create_footermenu"), + ] + + operations = [ + migrations.AddField( + model_name="homepage", + name="featured_video_tagline", + field=models.CharField( + blank=True, + help_text="A short description or tagline for the featured video", + max_length=255, + ), + ), + migrations.AddField( + model_name="homepage", + name="featured_video_title", + field=models.CharField( + blank=True, help_text="Title of the featured video", max_length=255 + ), + ), + migrations.AddField( + model_name="homepage", + name="featured_video_url", + field=models.URLField( + blank=True, + help_text="Vimeo link to the featured video (e.g. https://vimeo.com/76979871, only Vimeo supported)", + ), + ), + ] diff --git a/apps/cms/migrations/0008_homepage_background_image.py b/apps/cms/migrations/0008_homepage_background_image.py new file mode 100644 index 000000000..5091b89b1 --- /dev/null +++ b/apps/cms/migrations/0008_homepage_background_image.py @@ -0,0 +1,27 @@ +# Generated by Django 3.2.25 on 2024-10-14 17:20 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("wagtailimages", "0025_alter_image_file_alter_rendition_file"), + ("cms", "0007_homepage_featured_video"), + ] + + operations = [ + migrations.AddField( + model_name="homepage", + name="background_image", + field=models.ForeignKey( + blank=True, + help_text="Optional background image for the site header", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="wagtailimages.image", + ), + ), + ] diff --git a/apps/cms/models.py b/apps/cms/models.py index 46ea92b84..a40719de2 100644 --- a/apps/cms/models.py +++ b/apps/cms/models.py @@ -1,27 +1,26 @@ """CMS Models.""" -from urllib.parse import urlencode from modelcluster.fields import ParentalManyToManyField -from wagtail.core.models import Page -from wagtail.core.fields import RichTextField, StreamField -from wagtail.admin.edit_handlers import FieldPanel, StreamFieldPanel +from wagtail.models import Page +from wagtail.fields import RichTextField, StreamField +from wagtail.admin.panels import FieldPanel, MultiFieldPanel from wagtailautocomplete.edit_handlers import AutocompletePanel from django.db import models from apps.cms.blocks import BaseStreamBlock +from apps.readux.forms import AllCollectionsForm, AllVolumesForm from apps.readux.models import UserAnnotation from apps.iiif.kollections.models import Collection from apps.iiif.manifests.models import Manifest -from apps.iiif.canvases.models import Canvas from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator class ContentPage(Page): """Content page""" body = StreamField( - BaseStreamBlock(), verbose_name="Page body", blank=True + BaseStreamBlock(), verbose_name="Page body", blank=True, use_json_field=False ) content_panels = Page.content_panels + [ - StreamFieldPanel('body'), + FieldPanel('body'), ] class CollectionsPage(Page): @@ -39,14 +38,95 @@ class CollectionsPage(Page): default="List", help_text="Select to show all volumes as a list or a grid of icons." ) - collections = Collection.objects.all - volumes = Manifest.objects.all + collections = Collection.objects.all() + volumes = Manifest.objects.all() content_panels = Page.content_panels + [ FieldPanel('page_title', classname="full"), FieldPanel('tagline', classname="full"), FieldPanel('paragraph', classname="full"), FieldPanel('layout', classname="full"), ] + initial = {"sort": "title", "order": "asc", "display": "grid", "per_page": "60"} + sort_fields = { + "title": "label", + "added": "created_at", + "volumes": "volumes_count", + } + + def get_form(self, request): + """Get the form for setting sort and order""" + # use GET instead of default POST/PUT for form data + form_data = request.GET.copy() + + # sort by chosen sort + if "sort" in form_data and bool(form_data.get("sort")): + form_data["sort"] = form_data.get("sort") + + # Otherwise set all form values to default + for key, val in self.initial.items(): + form_data.setdefault(key, val) + + return AllCollectionsForm(data=form_data) + + def get_queryset(self, form, queryset): + """Get the sorted set of objects to display""" + + # return empty queryset if not valid + if not form.is_valid(): + return queryset.none() + + # get sort and order selections from form + search_opts = form.cleaned_data + sort = search_opts.get("sort", "title") + if sort not in self.sort_fields: + sort = "title" + order = search_opts.get("order", "asc") + sign = "-" if order == "desc" else "" + + # include volume count in queryset if sort + if "volumes" in sort: + queryset = queryset.annotate(volumes_count=models.Count("manifests")) + + # build order_by query to sort results + queryset = queryset.order_by(f"{sign}{self.sort_fields[sort]}") + + return queryset + + def get_context(self, request): + """Context function.""" + context = super().get_context(request) + + form = self.get_form(request) + query_set = self.get_queryset(form, self.collections) + + per_page_default = int(self.initial.get("per_page", 20)) + per_page = per_page_default + if form.is_valid(): + try: + per_page = int(form.cleaned_data.get("per_page") or per_page_default) + except (TypeError, ValueError): + per_page = per_page_default + + paginator = Paginator(query_set, per_page) + + page = request.GET.get("page", 1) + try: + collections = paginator.page(page) + except PageNotAnInteger: + # If page is not an integer, deliver first page. + collections = paginator.page(1) + except EmptyPage: + # If page is out of range (e.g. 9999), deliver last page of results. + page = paginator.num_pages + collections = paginator.page(page) + + context.update({ + "form": form, + "collections": collections, + "paginator_range": paginator.get_elided_page_range(page, on_each_side=2), + }) + + return context class VolumesPage(Page): """Content page""" @@ -70,59 +150,72 @@ class VolumesPage(Page): FieldPanel('paragraph', classname="full"), FieldPanel('layout', classname="full"), ] + initial = {"sort": "title", "order": "asc", "display": "grid", "per_page": "60"} + sort_fields = { + "title": "label", + "author": "author", + "date": "date_sort_ascending", + "added": "created_at", + } + + def get_form(self, request): + """Get the form for setting sort and order""" + # use GET instead of default POST/PUT for form data + form_data = request.GET.copy() + + # sort by chosen sort + if "sort" in form_data and bool(form_data.get("sort")): + form_data["sort"] = form_data.get("sort") + + # Otherwise set all form values to default + for key, val in self.initial.items(): + form_data.setdefault(key, val) + + return AllVolumesForm(data=form_data) + + def get_queryset(self, form, queryset): + """Get the sorted set of objects to display""" + + # return empty queryset if not valid + if not form.is_valid(): + return queryset.none() + + # get sort and order selections from form + search_opts = form.cleaned_data + sort = search_opts.get("sort", "title") + if sort not in self.sort_fields: + sort = "title" + order = search_opts.get("order", "asc") + sign = "-" if order == "desc" else "" + + # build order_by query to sort results + if sort == "date" and order == "desc": + # special case for date, descending: need to use date_sort_descending field + # and sort nulls last + queryset = queryset.order_by(models.F("date_sort_descending").desc(nulls_last=True)) + else: + queryset = queryset.order_by(f"{sign}{self.sort_fields[sort]}") + + return queryset def get_context(self, request): """Context function.""" context = super().get_context(request) - sort = request.GET.get('sort', None) - order = request.GET.get('order', None) - query_set = self.volumes - - - sort_options = ['title', 'author', 'date published', 'date added'] - order_options = ['asc', 'desc'] - if sort not in sort_options and order not in order_options: - sort = 'title' - order = 'asc' - elif sort not in sort_options: - sort = 'title' - elif order not in order_options: - order = 'asc' - - if sort == 'title': - if order == 'asc': - query_set = query_set.order_by('label') - elif order == 'desc': - query_set = query_set.order_by('-label') - elif sort == 'author': - if order == 'asc': - query_set = query_set.order_by('author') - elif order == 'desc': - query_set = query_set.order_by('-author') - elif sort == 'date published': - if order == 'asc': - query_set = query_set.order_by('published_date') - elif order == 'desc': - query_set = query_set.order_by('-published_date') - elif sort == 'date added': - if order == 'asc': - query_set = query_set.order_by('created_at') - elif order == 'desc': - query_set = query_set.order_by('-created_at') - - sort_url_params = request.GET.copy() - order_url_params = request.GET.copy() - if 'sort' in sort_url_params and 'order' in order_url_params: - del sort_url_params['sort'] - del order_url_params['order'] - elif 'sort' in sort_url_params: - del sort_url_params['sort'] - elif 'order' in order_url_params: - del order_url_params['order'] - - paginator = Paginator(query_set, 10) # Show 10 volumes per page - - page = request.GET.get('page') + + form = self.get_form(request) + query_set = self.get_queryset(form, self.volumes) + + per_page_default = int(self.initial.get("per_page", 60)) + per_page = per_page_default + if form.is_valid(): + try: + per_page = int(form.cleaned_data.get("per_page") or per_page_default) + except (TypeError, ValueError): + per_page = per_page_default + + paginator = Paginator(query_set, per_page) # Show volumes per selected page size + + page = request.GET.get("page", 1) try: volumes = paginator.page(page) except PageNotAnInteger: @@ -130,31 +223,16 @@ def get_context(self, request): volumes = paginator.page(1) except EmptyPage: # If page is out of range (e.g. 9999), deliver last page of results. - volumes = paginator.page(paginator.num_pages) - - # make the variable 'volumes' available on the template - context['volumes'] = volumes -# context['volumespage'] = query_set.all - context['user_annotation'] = UserAnnotation.objects.filter(owner_id=request.user.id) - # annocount_list = [] - # canvaslist = [] - # for volume in query_set: - # user_annotation_count = UserAnnotation.objects.filter(owner_id=request.user.id).filter(canvas__manifest__id=volume.id).count() - # annocount_list.append({volume.pid: user_annotation_count}) - # context['user_annotation_count'] = annocount_list - # canvasquery = Canvas.objects.filter(is_starting_page=1).filter(manifest__id=volume.id) - # canvasquery2 = list(canvasquery) - # canvaslist.append({volume.pid: canvasquery2}) - # context['firstthumbnail'] = canvaslist - # value = 0 - # context['value'] = value + page = paginator.num_pages + volumes = paginator.page(page) context.update({ - 'sort_url_params': urlencode(sort_url_params), - 'order_url_params': urlencode(order_url_params), - 'sort': sort, 'sort_options': sort_options, - 'order': order, 'order_options': order_options, + "form": form, + "volumes": volumes, + "user_annotation": UserAnnotation.objects.filter(owner_id=request.user.id), + "paginator_range": paginator.get_elided_page_range(page, on_each_side=2), }) + return context @@ -195,13 +273,61 @@ class HomePage(Page): collections = Collection.objects.all()[:8] volumes = Manifest.objects.all()[:8] + background_image = models.ForeignKey( + 'wagtailimages.Image', + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name='+', + help_text="Optional background image for the site header", + ) + + featured_story_title = models.CharField( + help_text="Title of the featured story", blank=True, max_length=255 + ) + featured_story_summary = models.TextField( + help_text="A summary or excerpt of the featured story", blank=True + ) + featured_story_image = models.ForeignKey( + 'wagtailimages.Image', + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name='+', + help_text="Thumbnail image for the featured story", + ) + featured_story_url = models.URLField( + help_text="Link to the featured story", blank=True + ) + featured_video_url = models.URLField( + help_text="Vimeo link to the featured video (e.g. https://vimeo.com/76979871, only Vimeo supported)", blank=True + ) + featured_video_title = models.CharField( + help_text="Title of the featured video", blank=True, max_length=255 + ) + featured_video_tagline = models.CharField( + help_text="A short description or tagline for the featured video", blank=True, max_length=255 + ) + content_panels = Page.content_panels + [ FieldPanel('tagline', classname="full"), + FieldPanel('background_image', classname="full"), FieldPanel('content_display', classname="full"), AutocompletePanel('featured_collections', target_model="kollections.Collection"), FieldPanel('featured_collections_sort_order', classname="full"), AutocompletePanel('featured_volumes', target_model="manifests.Manifest"), FieldPanel('featured_volumes_sort_order', classname="full"), + MultiFieldPanel(children=[ + FieldPanel('featured_story_title'), + FieldPanel('featured_story_summary'), + FieldPanel('featured_story_image'), + FieldPanel('featured_story_url'), + ], heading='Featured story (optional)'), + MultiFieldPanel(children=[ + FieldPanel('featured_video_url'), + FieldPanel('featured_video_title'), + FieldPanel('featured_video_tagline'), + ], heading='Featured video (optional)'), ] def featured_volume_count(self): @@ -213,26 +339,6 @@ def has_featured_volume(self): def get_context(self, request): """Function that returns context""" context = super().get_context(request) - query_set = self.volumes - - # context['volumespage'] = query_set.all - # context['user_annotation'] = UserAnnotation.objects.filter(owner_id=request.user.id) context['volumesurl'] = Page.objects.type(VolumesPage).first() context['collectionsurl'] = Page.objects.type(CollectionsPage).first() - # annocount_list = [] - # canvaslist = [] - # for volume in query_set: - # user_annotation_count = UserAnnotation.objects.filter( - # owner_id=request.user.id - # ).filter( - # canvas__manifest__id=volume.id - # ).count() - # annocount_list.append({volume.pid: user_annotation_count}) - # context['user_annotation_count'] = annocount_list - # canvasquery = Canvas.objects.filter(is_starting_page=1).filter(manifest__id=volume.id) - # canvasquery2 = list(canvasquery) - # canvaslist.append({volume.pid: canvasquery2}) - # context['firstthumbnail'] = canvaslist - # value = 0 - # context['value'] = value return context diff --git a/apps/cms/templatetags/readux_templatetags.py b/apps/cms/templatetags/readux_templatetags.py index e9b708e0b..ffa3a8878 100644 --- a/apps/cms/templatetags/readux_templatetags.py +++ b/apps/cms/templatetags/readux_templatetags.py @@ -2,7 +2,8 @@ register = Library() + @register.filter_function def order_by(queryset, args): - args = [x.strip() for x in args.split(',')] + args = [x.strip() for x in args.split(",")] return queryset.order_by(*args) diff --git a/apps/cms/wagtail_hooks.py b/apps/cms/wagtail_hooks.py index c227923c0..26be90f07 100644 --- a/apps/cms/wagtail_hooks.py +++ b/apps/cms/wagtail_hooks.py @@ -2,7 +2,7 @@ from django.templatetags.static import static from django.utils.html import format_html -from wagtail.core import hooks +from wagtail import hooks # Register a custom css file for the wagtail admin. diff --git a/apps/custom_styles/context_processors.py b/apps/custom_styles/context_processors.py index b26f3b53b..0a0d46c13 100644 --- a/apps/custom_styles/context_processors.py +++ b/apps/custom_styles/context_processors.py @@ -1,5 +1,6 @@ """Exposes the custom style to the base template.""" from .models import Style +from django.conf import settings def add_custom_style(request): """Return user defined CSS. @@ -16,3 +17,12 @@ def add_custom_style(request): except Style.DoesNotExist: pass return {'css': ''} + +def background_image_url(request): + """ + Add BACKGROUND_IMAGE_URL and a fallback gradient to the template context. + """ + return { + 'background_image_url': getattr(settings, 'BACKGROUND_IMAGE_URL', ''), + 'background_fallback': 'linear-gradient(135deg, #1D3557, #457B9D)', # Fallback gradient + } \ No newline at end of file diff --git a/apps/export/export.py b/apps/export/export.py index f62218129..f72a0c345 100644 --- a/apps/export/export.py +++ b/apps/export/export.py @@ -1,4 +1,5 @@ """Github export module""" + import httpretty import io import json @@ -13,16 +14,18 @@ from time import sleep from urllib.parse import urlparse from requests import get + # pylint: disable = unused-import, ungrouped-imports try: from yaml import CLoader as Loader, CDumper as Dumper -except ImportError: # pragma: no cover +except ImportError: # pragma: no cover from yaml import Loader, Dumper # pylint: enable = unused-import, ungrouped-imports # TODO: Can we be more efficient in how we import git? import git from git.cmd import Git from yaml import load, safe_dump +from django.conf import settings from django.core.mail import send_mail from django.core.serializers import serialize from django.template.loader import get_template @@ -38,16 +41,30 @@ # zip file of base jekyll site with digital edition templates JEKYLL_THEME_ZIP = digitaledition_jekylltheme.ZIPFILE_PATH +# FIXME: The GitHub action env does not seem to find the zip file. +if os.environ["DJANGO_ENV"] == "test" and not os.path.exists(JEKYLL_THEME_ZIP): + JEKYLL_THEME_ZIP = os.path.join( + settings.APPS_DIR, + "export", + "tests", + "fixtures", + "digitaledition-jekylltheme.zip", + ) + + class ExportException(Exception): """Custom exception""" + pass + class IiifManifestExport: """Manifest Export :return: Return bytes containing the entire contents of the buffer. :rtype: bytes """ + @classmethod def get_zip(self, manifest, version, owners=[]): """Generate zipfile of manifest. @@ -91,7 +108,7 @@ def get_zip(self, manifest, version, owners=[]): annotators = User.objects.filter( userannotation__canvas__manifest__id=manifest.id ).distinct() - annotators_string = ', '.join([i.name for i in annotators]) + annotators_string = ", ".join([i.name for i in annotators]) # pylint: enable = possibly-unused-variable # pylint: disable = line-too-long @@ -100,33 +117,45 @@ def get_zip(self, manifest, version, owners=[]): # dedup the list of owners (although -- how to order? alphabetical or # sby contribution count or ignore order) .distinct() # turn the list of owners into a comma separated string of formal names instead of user ids - readme = "Annotation export from Readux %(version)s at %(readux_url)s\nedition type: Readux IIIF Exported Edition\nexport date: %(now)s UTC\n\n" % locals() - volume_data = "volume title: %(title)s\nvolume author: %(author)s\nvolume date: %(date)s\nvolume publisher: %(publisher)s\npages: %(page_count)s \n" % locals() - annotators_attribution_string = "Annotated by: " + annotators_string +"\n\n" + readme = ( + "Annotation export from Readux %(version)s at %(readux_url)s\nedition type: Readux IIIF Exported Edition\nexport date: %(now)s UTC\n\n" + % locals() + ) + volume_data = ( + "volume title: %(title)s\nvolume author: %(author)s\nvolume date: %(date)s\nvolume publisher: %(publisher)s\npages: %(page_count)s \n" + % locals() + ) + annotators_attribution_string = "Annotated by: " + annotators_string + "\n\n" boilerplate = "Readux is a platform developed by Emory University’s Center for Digital Scholarship for browsing, annotating, and publishing with digitized books. This zip file includes an International Image Interoperability Framework (IIIF) manifest for the digitized book and an annotation list for each page that includes both the encoded text of the book and annotations created by the user who created this export. This bundle can be used to archive the recognized text and annotations for preservation and future access.\n\n" - explanation = "Each canvas (\"sc:Canvas\") in the manifest represents a page of the work. Each canvas includes an \"otherContent\" field-set with information identifying that page's annotation lists. This field-set includes an \"@id\" field and the label field (\"@type\") \"sc:AnnotationList\" for each annotation list. The \"@id\" field contains the URL link at which the annotation list was created and originally hosted from the Readux site. In order to host this IIIF manifest and its annotation lists again to browse the book and annotations outside of Readux, these @id fields would need to be updated to the appropriate URLs for the annotation lists on the new host. Exported annotation lists replace nonword characters (where words are made up of alphanumerics and underscores) with underscores in the filename." - readme = readme + volume_data + annotators_attribution_string + boilerplate + explanation - zip_file.writestr('README.txt', readme) + explanation = 'Each canvas ("sc:Canvas") in the manifest represents a page of the work. Each canvas includes an "otherContent" field-set with information identifying that page\'s annotation lists. This field-set includes an "@id" field and the label field ("@type") "sc:AnnotationList" for each annotation list. The "@id" field contains the URL link at which the annotation list was created and originally hosted from the Readux site. In order to host this IIIF manifest and its annotation lists again to browse the book and annotations outside of Readux, these @id fields would need to be updated to the appropriate URLs for the annotation lists on the new host. Exported annotation lists replace nonword characters (where words are made up of alphanumerics and underscores) with underscores in the filename.' + readme = ( + readme + + volume_data + + annotators_attribution_string + + boilerplate + + explanation + ) + zip_file.writestr("README.txt", readme) current_user = User.objects.get(id__in=owners) # pylint: enable = line-too-long # Next write the manifest zip_file.writestr( - 'manifest.json', + "manifest.json", json.dumps( json.loads( serialize( - 'manifest', + "manifest", [manifest], version=version, annotators=current_user.name, exportdate=now, - current_user=current_user + current_user=current_user, ) ), - indent=4 - ) + indent=4, + ), ) # Then write the OCR annotations @@ -134,23 +163,14 @@ def get_zip(self, manifest, version, owners=[]): if canvas.annotation_set.count() > 0: json_hash = json.loads( serialize( - 'annotation_list', - [canvas], - version=version, - owners=owners + "annotation_list", [canvas], version=version, owners=owners ) ) - anno_uri = json_hash['@id'] + anno_uri = json_hash["@id"] - annotation_file = re.sub(r'\W','_', anno_uri) + ".json" + annotation_file = re.sub(r"\W", "_", anno_uri) + ".json" - zip_file.writestr( - annotation_file, - json.dumps( - json_hash, - indent=4 - ) - ) + zip_file.writestr(annotation_file, json.dumps(json_hash, indent=4)) # Then write the user annotations for canvas in manifest.canvas_set.all(): user_annotations = current_user.userannotation_set.filter(canvas=canvas) @@ -159,33 +179,30 @@ def get_zip(self, manifest, version, owners=[]): # annotations = canvas.userannotation_set.filter(owner__in=owners).all() json_hash = json.loads( serialize( - 'user_annotation_list', + "user_annotation_list", [canvas], version=version, is_list=False, - owners=[current_user] - ) - ) - anno_uri = json_hash['@id'] - annotation_file = re.sub(r'\W', '_', anno_uri) + ".json" - - zip_file.writestr( - annotation_file, - json.dumps( - json_hash, - indent=4 + owners=[current_user], ) ) + print(json_hash) + anno_uri = json_hash["@id"] + annotation_file = re.sub(r"\W", "_", anno_uri) + ".json" - zip_file.close() # flush zipfile to byte stream + zip_file.writestr(annotation_file, json.dumps(json_hash, indent=4)) + + zip_file.close() # flush zipfile to byte stream return byte_stream.getvalue() class GithubExportException(Exception): """Custom exception.""" + pass + class JekyllSiteExport(object): """Export Jekyllsite @@ -196,9 +213,18 @@ class JekyllSiteExport(object): :return: [description] :rtype: [type] """ - def __init__(self, manifest, version, page_one=None, - include_images=False, deep_zoom='hosted', - github_repo=None, owners=None, user=None): + + def __init__( + self, + manifest, + version, + page_one=None, + include_images=False, + deep_zoom="hosted", + github_repo=None, + owners=None, + user=None, + ): """Init JekyllSiteExport :param manifest: Manifest to be exported @@ -223,9 +249,9 @@ def __init__(self, manifest, version, page_one=None, self.version = version # self.page_one = page_one # self.include_images = include_images - #self.deep_zoom = deep_zoom - self.include_deep_zoom = (deep_zoom == 'include') - self.no_deep_zoom = (deep_zoom == 'exclude') + # self.deep_zoom = deep_zoom + self.include_deep_zoom = deep_zoom == "include" + self.no_deep_zoom = deep_zoom == "exclude" # self.github_repo = github_repo # # initialize github connection values to None @@ -239,7 +265,6 @@ def __init__(self, manifest, version, page_one=None, # TODO: Why? self.is_testing = False - def log_status(self, msg): """Shortcut function to log status of export. @@ -274,7 +299,7 @@ def get_zip_path(filename): def get_zip_file(self, filename): """Generate zip file""" - file = open(JekyllSiteExport.get_zip_path(filename),"rb") + file = open(JekyllSiteExport.get_zip_path(filename), "rb") data = file.read() file.close() return data @@ -285,7 +310,7 @@ def iiif_dir(self): :return: System path for export file. :rtype: str """ - return os.path.join(self.jekyll_site_dir, 'iiif_export') + return os.path.join(self.jekyll_site_dir, "iiif_export") def import_iiif_jekyll(self, manifest, tmpdir): """Get a fresh import of IIIF as jekyll site content @@ -297,14 +322,14 @@ def import_iiif_jekyll(self, manifest, tmpdir): :raises ExportException: [description] """ # run the script to get a fresh import of IIIF as jekyll site content - self.log_status('Running jekyll import IIIF manifest script') + self.log_status("Running jekyll import IIIF manifest script") jekyllimport_manifest_script = settings.JEKYLLIMPORT_MANIFEST_SCRIPT import_command = [ jekyllimport_manifest_script, - '--local-directory', - '-q', + "--local-directory", + "-q", self.iiif_dir(), - tmpdir + tmpdir, ] # TODO # # if a page number is specified, pass it as a parameter to the script @@ -313,23 +338,22 @@ def import_iiif_jekyll(self, manifest, tmpdir): # # if no deep zoom is requested, pass through so the jekyll # # config can be updated appropriately if self.no_deep_zoom: - import_command.append('--no-deep-zoom') + import_command.append("--no-deep-zoom") try: - LOGGER.debug('Jekyll import command: %s', ' '.join(import_command)) + LOGGER.debug("Jekyll import command: %s", " ".join(import_command)) output = subprocess.check_output( - ' '.join(import_command), - shell=True, - stderr=subprocess.STDOUT + " ".join(import_command), shell=True, stderr=subprocess.STDOUT ) - LOGGER.debug('Jekyll import output:') - LOGGER.debug(output.decode('utf-8')) + LOGGER.debug("Jekyll import output:") + LOGGER.debug(output.decode("utf-8")) except subprocess.CalledProcessError as error: - LOGGER.debug('Jekyll import error:') + LOGGER.debug("Jekyll import error:") LOGGER.debug(error.output) - err_msg = "Error running jekyll import on IIIF manifest!\n{cmd}\n{err}".format( - cmd=' '.join(import_command), - err=error.output.decode('utf-8') + err_msg = ( + "Error running jekyll import on IIIF manifest!\n{cmd}\n{err}".format( + cmd=" ".join(import_command), err=error.output.decode("utf-8") + ) ) LOGGER.error(err_msg) raise ExportException(err_msg) @@ -343,20 +367,22 @@ def generate_website(self): :return: System path for export directory. :rtype: str """ - LOGGER.debug('Generating jekyll website for %s', self.manifest.id) - tmpdir = tempfile.mkdtemp(prefix='tmp-rdx-export') - LOGGER.debug('Building export for %s in %s', self.manifest.id, tmpdir) + LOGGER.debug("Generating jekyll website for %s", self.manifest.id) + tmpdir = tempfile.mkdtemp(prefix="tmp-rdx-export") + LOGGER.debug("Building export for %s in %s", self.manifest.id, tmpdir) # unzip jekyll template site - self.log_status('Extracting jekyll template site') - with zipfile.ZipFile(JEKYLL_THEME_ZIP, 'r') as jekyllzip: + self.log_status("Extracting jekyll template site") + with zipfile.ZipFile(JEKYLL_THEME_ZIP, "r") as jekyllzip: jekyllzip.extractall(tmpdir) - self.jekyll_site_dir = os.path.join(tmpdir, 'digitaledition-jekylltheme') - LOGGER.debug('Jekyll site dir:') + self.jekyll_site_dir = os.path.join(tmpdir, "digitaledition-jekylltheme") + LOGGER.debug("Jekyll site dir:") LOGGER.debug(self.jekyll_site_dir) - LOGGER.debug('Exporting IIIF bundle') - iiif_zip_stream = IiifManifestExport.get_zip(self.manifest, 'v2', owners=self.owners) + LOGGER.debug("Exporting IIIF bundle") + iiif_zip_stream = IiifManifestExport.get_zip( + self.manifest, "v2", owners=self.owners + ) iiif_zip = zipfile.ZipFile(io.BytesIO(iiif_zip_stream), "r") iiif_zip.extractall(self.iiif_dir()) @@ -369,22 +395,19 @@ def generate_website(self): # if self.include_deep_zoom: # self.generate_deep_zoom(jekyll_site_dir) - # run the script to import IIIF as jekyll site content self.import_iiif_jekyll(self.manifest, self.jekyll_site_dir) # NOTE: putting export content in a separate dir to make it easy to create # the zip file with the right contents and structure - export_dir = os.path.join(tmpdir, 'export') + export_dir = os.path.join(tmpdir, "export") os.mkdir(export_dir) # rename the jekyll dir and move it into the export dir - shutil.move(self.jekyll_site_dir, - self.edition_dir(export_dir)) + shutil.move(self.jekyll_site_dir, self.edition_dir(export_dir)) return export_dir - def edition_dir(self, export_dir): """Convenience function for system path to the edition directory @@ -394,8 +417,7 @@ def edition_dir(self, export_dir): :rtype: str """ return os.path.join( - export_dir, - '{m}_annotated_jekyll_site'.format(m=self.manifest.id) + export_dir, "{m}_annotated_jekyll_site".format(m=self.manifest.id) ) def website_zip(self): @@ -410,17 +432,19 @@ def website_zip(self): # create a tempfile to hold a zip file of the site # (using tempfile for automatic cleanup after use) webzipfile = tempfile.NamedTemporaryFile( - suffix='.zip', - prefix='%s_annotated_site_' % self.manifest.id, - delete=False) + suffix=".zip", prefix="%s_annotated_site_" % self.manifest.id, delete=False + ) shutil.make_archive( # name of the zipfile to create without .zip os.path.splitext(webzipfile.name)[0], - 'zip', # archive format; could also do tar - export_dir + "zip", # archive format; could also do tar + export_dir, + ) + LOGGER.debug( + "Jekyll site web export zipfile for %s is %s", + self.manifest.id, + webzipfile.name, ) - LOGGER.debug('Jekyll site web export zipfile for %s is %s', - self.manifest.id, webzipfile.name) # clean up temporary files shutil.rmtree(export_dir) # NOTE: method has to return the tempfile itself, or else it will @@ -437,7 +461,7 @@ def use_github(self, user): self.github = GithubApi.connect_as_user(user) self.github_username = GithubApi.github_username(user) self.github_token = GithubApi.github_token(user) - self.github.session.headers['Authorization'] = f'token {self.github_token}' + self.github.session.headers["Authorization"] = f"token {self.github_token}" def github_auth_repo(self, repo_name=None, repo_url=None): """Generate a GitHub repo url with an oauth token in order to @@ -452,9 +476,9 @@ def github_auth_repo(self, repo_name=None, repo_url=None): """ if repo_url: parsed_repo_url = urlparse(repo_url) - return f'https://{self.github_username}:{GithubApi.github_token(self.user)}@github.com/{parsed_repo_url.path[1:]}.git' + return f"https://{self.github_username}:{GithubApi.github_token(self.user)}@github.com/{parsed_repo_url.path[1:]}.git" - return f'https://{self.github_username}:{GithubApi.github_token(self.user)}@github.com/{self.github_username}/{repo_name}.git' + return f"https://{self.github_username}:{GithubApi.github_token(self.user)}@github.com/{self.github_username}/{repo_name}.git" def gitrepo_exists(self): """Check to see if GitHub repo already exists. @@ -463,17 +487,14 @@ def gitrepo_exists(self): :rtype: bool """ current_repos = self.github.list_repos(self.github_username) - current_repo_names = [repo['name'] for repo in current_repos] + current_repo_names = [repo["name"] for repo in current_repos] LOGGER.debug( - 'Checking to see if {gr} in {rns}'.format( - gr=self.github_repo, - rns=" ".join(current_repo_names) + "Checking to see if {gr} in {rns}".format( + gr=self.github_repo, rns=" ".join(current_repo_names) ) ) return self.github_repo in current_repo_names - - def website_gitrepo(self): """Create a new GitHub repository and populate it with content from a newly generated jekyll website export created via :meth:`website`. @@ -484,25 +505,21 @@ def website_gitrepo(self): """ # NOTE: github pages sites now default to https - github_pages_url = 'https://{un}.github.io/{gr}/'.format( - un=self.github_username, - gr=self.github_repo + github_pages_url = "https://{un}.github.io/{gr}/".format( + un=self.github_username, gr=self.github_repo ) # before even starting to generate the jekyll site, # check if requested repo name already exists; if so, bail out with an error LOGGER.debug( - 'Checking github repo {gr} for {un}'.format( - gr=self.github_repo, - un=self.github_username + "Checking github repo {gr} for {un}".format( + gr=self.github_repo, un=self.github_username ) ) if self.gitrepo_exists(): raise GithubExportException( - 'GitHub repo {gr} already exists.'.format( - gr=self.github_repo - ) + "GitHub repo {gr} already exists.".format(gr=self.github_repo) ) export_dir = self.generate_website() @@ -513,34 +530,33 @@ def website_gitrepo(self): jekyll_dir = self.edition_dir(export_dir) # modify the jekyll config for relative url on github.io - config_file_path = os.path.join(jekyll_dir, '_config.yml') - with open(config_file_path, 'r') as configfile: + config_file_path = os.path.join(jekyll_dir, "_config.yml") + with open(config_file_path, "r") as configfile: config_data = load(configfile, Loader=Loader) # split out github pages url into the site url and path parsed_gh_url = urlparse(github_pages_url) - config_data['url'] = '{s}://{n}'.format( - s=parsed_gh_url.scheme, - n=parsed_gh_url.netloc + config_data["url"] = "{s}://{n}".format( + s=parsed_gh_url.scheme, n=parsed_gh_url.netloc ) - config_data['baseurl'] = parsed_gh_url.path.rstrip('/') - with open(config_file_path, 'w') as configfile: - safe_dump(config_data, configfile, - default_flow_style=False) + config_data["baseurl"] = parsed_gh_url.path.rstrip("/") + with open(config_file_path, "w") as configfile: + safe_dump(config_data, configfile, default_flow_style=False) # using safe_dump to generate only standard yaml output # NOTE: pyyaml requires default_flow_style=false to output # nested collections in block format LOGGER.debug( - 'Creating github repo {gr} for {un}'.format( - gr=self.github_repo, - un=self.github_username + "Creating github repo {gr} for {un}".format( + gr=self.github_repo, un=self.github_username ) ) self.github.create_repo( - self.github_repo, homepage=github_pages_url, user=self.user, - description='An annotated digital edition created with Readux' + self.github_repo, + homepage=github_pages_url, + user=self.user, + description="An annotated digital edition created with Readux", ) # get auth repo url to use to push data @@ -570,78 +586,64 @@ def website_gitrepo(self): # https://developer.github.com/changes/2020-02-14-deprecating-oauth-app-endpoint/ gitcmd.config("user.password", GithubApi.github_token(self.user)) - gitcmd.add(['.']) - gitcmd.commit([ - '-m', - 'Import Jekyll site generated by Readux {v}'.format( - v=__version__ - ), - '--author="{fn} <{ue}>"'.format( - fn=git_author, - ue=self.user.email - ) - ]) + gitcmd.add(["."]) + gitcmd.commit( + [ + "-m", + "Import Jekyll site generated by Readux {v}".format(v=__version__), + '--author="{fn} <{ue}>"'.format(fn=git_author, ue=self.user.email), + ] + ) # push local master to the gh-pages branch of the newly created repo, # using the user's oauth token credentials - self.log_status('Pushing new content to GitHub') - if os.environ['DJANGO_ENV'] != 'test': # pragma: no cover - gitcmd.push([repo_url, 'master:gh-pages']) # pragma: no cover + self.log_status("Pushing new content to GitHub") + if os.environ["DJANGO_ENV"] != "test": # pragma: no cover + gitcmd.push([repo_url, "master:gh-pages"]) # pragma: no cover # clean up temporary files after push to github shutil.rmtree(export_dir) # generate public repo url for display to user - public_repo_url = 'https://github.com/{un}/{gr}'.format( - un=self.github_username, - gr=self.github_repo + public_repo_url = "https://github.com/{un}/{gr}".format( + un=self.github_username, gr=self.github_repo ) return (public_repo_url, github_pages_url) def update_gitrepo(self): - '''Update an existing GitHub repository previously created by + """Update an existing GitHub repository previously created by Readux export. Checks out the repository, creates a new branch, runs the iiif_to_jekyll import on that branch, pushes it to github, and creates a pull request. Returns the HTML url for the new - pull request on success.''' + pull request on success.""" - repo_url = 'github.com/{un}/{gr}.git'.format( - un=self.github_username, - gr=self.github_repo + repo_url = "github.com/{un}/{gr}.git".format( + un=self.github_username, gr=self.github_repo ) # get auth repo url to use to create branch auth_repo_url = self.github_auth_repo(repo_name=self.github_repo) # create a tmpdir to clone the git repo into - tmpdir = tempfile.mkdtemp(prefix='tmp-rdx-export-update') - LOGGER.debug( - 'Cloning {r} to {t}'.format( - r=repo_url, - t=tmpdir - ) - ) + tmpdir = tempfile.mkdtemp(prefix="tmp-rdx-export-update") + LOGGER.debug("Cloning {r} to {t}".format(r=repo_url, t=tmpdir)) repo = None - if os.environ['DJANGO_ENV'] == 'test': + if os.environ["DJANGO_ENV"] == "test": repo = git.Repo.init(tmpdir) - yml_config_path = os.path.join(tmpdir, '_config.yml') - open(yml_config_path, 'a').close() - repo.index.commit('initial commit') - repo.git.checkout('HEAD', b='gh-pages') + yml_config_path = os.path.join(tmpdir, "_config.yml") + open(yml_config_path, "a").close() + repo.index.commit("initial commit") + repo.git.checkout("HEAD", b="gh-pages") else: - repo = git.Repo.clone_from(auth_repo_url, tmpdir, branch='gh-pages') + repo = git.Repo.clone_from(auth_repo_url, tmpdir, branch="gh-pages") repo.remote().pull() # pragma: no cover # create and switch to a new branch and switch to it; using datetime # for uniqueness - git_branch_name = 'readux-update-%s' % \ - datetime.now().strftime('%Y%m%d-%H%M%S') + git_branch_name = "readux-update-%s" % datetime.now().strftime("%Y%m%d-%H%M%S") update_branch = repo.create_head(git_branch_name) update_branch.checkout() LOGGER.debug( - 'Updating export for {m} in {t}'.format( - m=self.manifest.pid, - t=tmpdir - ) + "Updating export for {m} in {t}".format(m=self.manifest.pid, t=tmpdir) ) # remove all annotations and tag pages so that if an annotation is removed @@ -649,7 +651,7 @@ def update_gitrepo(self): # (annotations and tags that are unchanged will be restored by the IIIF # jekyll import, and look unchanged to git if no different) try: - repo.index.remove(['_annotations/*', 'tags/*', 'iiif_export/*']) + repo.index.remove(["_annotations/*", "tags/*", "iiif_export/*"]) except git.GitCommandError: # it's possible that an export has no annotations or tags # (although unlikely to occur anywhere but development & testing) @@ -664,14 +666,14 @@ def update_gitrepo(self): self.jekyll_site_dir = tmpdir - LOGGER.debug('Exporting IIIF bundle') - iiif_zip_stream = IiifManifestExport.get_zip(self.manifest, 'v2', owners=self.owners) + LOGGER.debug("Exporting IIIF bundle") + iiif_zip_stream = IiifManifestExport.get_zip( + self.manifest, "v2", owners=self.owners + ) iiif_zip = zipfile.ZipFile(io.BytesIO(iiif_zip_stream), "r") iiif_zip.extractall(self.iiif_dir()) - - # TODO # # save image files if requested, and update image paths # # to use local references @@ -680,44 +682,47 @@ def update_gitrepo(self): # if self.include_deep_zoom: # self.generate_deep_zoom(jekyll_site_dir) - if os.environ['DJANGO_ENV'] != 'test': + if os.environ["DJANGO_ENV"] != "test": # run the script to import IIIF as jekyll site content - self.import_iiif_jekyll(self.manifest, self.jekyll_site_dir) # pragma: no cover + self.import_iiif_jekyll( + self.manifest, self.jekyll_site_dir + ) # pragma: no cover # add any files that could be updated to the git index - repo.index.add([ # pragma: no cover - '_config.yml', '_volume_pages/*', '_annotations/*', - '_data/tags.yml', 'tags/*', 'iiif_export/*' - ]) + repo.index.add( + [ # pragma: no cover + "_config.yml", + "_volume_pages/*", + "_annotations/*", + "_data/tags.yml", + "tags/*", + "iiif_export/*", + ] + ) # TODO: if deep zoom is added, we must add that directory as well - git_author = git.Actor( - self.user.name, - self.user.email - ) + git_author = git.Actor(self.user.name, self.user.email) # commit all changes repo.index.commit( - 'Updated Jekyll site by Readux {v}'.format( - v=__version__ - ), - author=git_author + "Updated Jekyll site by Readux {v}".format(v=__version__), author=git_author ) - if os.environ['DJANGO_ENV'] != 'test': + if os.environ["DJANGO_ENV"] != "test": # push the update to a new branch on github - repo.remotes.origin.push( # pragma: no cover - '{b}s:{b}s'.format(b=git_branch_name) + repo.remotes.origin.push( # pragma: no cover + "{b}s:{b}s".format(b=git_branch_name) ) # convert repo url to form needed to generate pull request - repo = repo_url.replace('github.com/', '').replace('.git', '') + repo = repo_url.replace("github.com/", "").replace(".git", "") pullrequest = self.github.create_pull_request( - repo, 'Updated export', git_branch_name, 'gh-pages') + repo, "Updated export", git_branch_name, "gh-pages" + ) # clean up local checkout after successful push shutil.rmtree(tmpdir) # return the html url for the new pull request - return pullrequest['html_url'] + return pullrequest["html_url"] # from readux/books/consumers.py in Readux 1. @@ -730,14 +735,14 @@ def github_export(self, user_email): :return: List of export URLs :rtype: list """ - LOGGER.debug('Background export started.') + LOGGER.debug("Background export started.") # user_has_github = False if self.user: # check if user has a github account linked try: GithubApi.github_account(self.user) except GithubAccountNotFound: - LOGGER.info('User attempted github export with no github account.') + LOGGER.info("User attempted github export with no github account.") # connect to github as the user in order to create the repository self.use_github(self.user) @@ -746,9 +751,12 @@ def github_export(self, user_email): # to do needed export steps # TODO: httpretty seems to include the HEAD method, but it errors when # making the request because the Head method is not implemented. - if os.environ['DJANGO_ENV'] != 'test' and 'repo' not in self.github.oauth_scopes(): - LOGGER.error('TODO: bad scope message') - return None # pragma: no cover + if ( + os.environ["DJANGO_ENV"] != "test" + and "repo" not in self.github.oauth_scopes() + ): + LOGGER.error("TODO: bad scope message") + return None # pragma: no cover repo_url = None ghpages_url = None @@ -758,55 +766,68 @@ def github_export(self, user_email): try: repo_url, ghpages_url = self.website_gitrepo() - LOGGER.info('Exported %s to GitHub repo %s for user %s', - self.manifest.pid, repo_url, self.user.username) + LOGGER.info( + "Exported %s to GitHub repo %s for user %s", + self.manifest.pid, + repo_url, + self.user.username, + ) except GithubExportException as err: - LOGGER.info('Export failed: {e}'.format(e=err)) + LOGGER.info("Export failed: {e}".format(e=err)) else: # update an existing github repository with new branch and # a pull request try: # TODO: How to highjack the request to # https://58816:x-oauth-basic@github.com/zaphod/marx.git/ when testing. - if os.environ['DJANGO_ENV'] != 'test': - pr_url = self.update_gitrepo() # pragma: no cover + if os.environ["DJANGO_ENV"] != "test": + pr_url = self.update_gitrepo() # pragma: no cover else: - pr_url = 'https://github.com/{u}/{r}/pull/2'.format( - u=self.github_username, - r=self.github_repo + pr_url = "https://github.com/{u}/{r}/pull/2".format( + u=self.github_username, r=self.github_repo ) - LOGGER.info('GitHub jekyll site update completed') - repo_url = 'https://github.com/%s/%s' % (self.github_username, self.github_repo) - ghpages_url = 'https://%s.github.io/%s/' % (self.github_username, self.github_repo) + LOGGER.info("GitHub jekyll site update completed") + repo_url = "https://github.com/%s/%s" % ( + self.github_username, + self.github_repo, + ) + ghpages_url = "https://%s.github.io/%s/" % ( + self.github_username, + self.github_repo, + ) except GithubExportException as err: - self.notify_msg('Export failed: {e}'.format(e=err)) + self.notify_msg("Export failed: {e}".format(e=err)) context = {} - context['repo_url'] = repo_url - context['ghpages_url'] = ghpages_url - context['pr_url'] = pr_url + context["repo_url"] = repo_url + context["ghpages_url"] = ghpages_url + context["pr_url"] = pr_url # It takes GitHub a few to build the site. This holds the email till the site # is available. If it takes longer than 10 minutes, and email is sent saying # that is is taking longer than expected. tries = 0 - sleep_for = 15 if os.environ['DJANGO_ENV'] != 'test' else 0.1 + sleep_for = 15 if os.environ["DJANGO_ENV"] != "test" else 0.1 while not self.__check_site(ghpages_url, tries): - for _ in range(0,10): + for _ in range(0, 10): print(tries) tries += 1 sleep(sleep_for) if tries < 45: - email_subject = 'Your Readux site export is ready!' - email_contents = get_template('jekyll_export_email.html').render(context) - text_contents = get_template('jekyll_export_email.txt').render(context) + email_subject = "Your Readux site export is ready!" + email_contents = get_template("jekyll_export_email.html").render(context) + text_contents = get_template("jekyll_export_email.txt").render(context) else: - email_subject = 'Your Readux site export is taking longer than expected' - email_contents = get_template('jekyll_export_email_error.html').render(context) - text_contents = get_template('jekyll_export_email_error.txt').render(context) + email_subject = "Your Readux site export is taking longer than expected" + email_contents = get_template("jekyll_export_email_error.html").render( + context + ) + text_contents = get_template("jekyll_export_email_error.txt").render( + context + ) send_mail( email_subject, @@ -814,7 +835,7 @@ def github_export(self, user_email): settings.READUX_EMAIL_SENDER, [user_email], fail_silently=False, - html_message=email_contents + html_message=email_contents, ) return [repo_url, ghpages_url, pr_url] @@ -831,7 +852,7 @@ def download_export(self, user_email, volume): """ LOGGER.debug( - 'Background download export started. Sending email to {ue}'.format( + "Background download export started. Sending email to {ue}".format( ue=user_email ) ) @@ -842,17 +863,17 @@ def download_export(self, user_email, volume): context["volume"] = volume context["hostname"] = settings.HOSTNAME - email_contents = get_template('download_export_email.html').render(context) - text_contents = get_template('download_export_email.txt').render(context) + email_contents = get_template("download_export_email.html").render(context) + text_contents = get_template("download_export_email.txt").render(context) # TODO: Maybe break this out so we can test it? send_mail( - 'Your Readux site export is ready!', + "Your Readux site export is ready!", text_contents, settings.READUX_EMAIL_SENDER, [user_email], fail_silently=False, - html_message=email_contents + html_message=email_contents, ) return zip_file.name diff --git a/apps/export/tests/fixtures/digitaledition-jekylltheme.zip b/apps/export/tests/fixtures/digitaledition-jekylltheme.zip new file mode 100644 index 000000000..de7aa75ca Binary files /dev/null and b/apps/export/tests/fixtures/digitaledition-jekylltheme.zip differ diff --git a/apps/export/tests/test_export.py b/apps/export/tests/test_export.py index 35ad384c8..930599dd1 100644 --- a/apps/export/tests/test_export.py +++ b/apps/export/tests/test_export.py @@ -1,4 +1,5 @@ -""" Test functions for the Readux export """ +"""Test functions for the Readux export""" + import io import json import os @@ -14,150 +15,183 @@ from django.urls import reverse from apps.iiif.manifests.models import Manifest from apps.iiif.canvases.models import Canvas -from apps.export.export import IiifManifestExport, JekyllSiteExport, GithubExportException, ExportException +from apps.export.export import ( + IiifManifestExport, + JekyllSiteExport, + GithubExportException, + ExportException, +) from apps.export.github import GithubApi from apps.readux.tests.factories import UserAnnotationFactory from apps.readux.models import UserAnnotation -from apps.users.tests.factories import SocialAccountFactory, SocialAppFactory, SocialTokenFactory +from apps.users.tests.factories import ( + SocialAccountFactory, + SocialAppFactory, + SocialTokenFactory, + UserFactory, +) from apps.export.views import JekyllExport, ManifestExport User = get_user_model() + class ManifestExportTests(TestCase): - fixtures = ['users.json', 'kollections.json', 'manifests.json', 'canvases.json', 'annotations.json', 'userannotation.json'] + fixtures = [ + "users.json", + "kollections.json", + "manifests.json", + "canvases.json", + "annotations.json", + "userannotation.json", + ] def setUp(self): self.user = get_user_model().objects.get(pk=111) + UserFactory.create(username="ocr", name="OCR") self.factory = RequestFactory() self.client = Client() - self.volume = Manifest.objects.get(pk='464d82f6-6ae5-4503-9afc-8e3cdd92a3f1') + self.volume = Manifest.objects.get(pk="464d82f6-6ae5-4503-9afc-8e3cdd92a3f1") + for canvas in self.volume.canvas_set.all(): + for ocr in canvas.annotation_set.all(): + ocr.save() self.start_canvas = self.volume.canvas_set.filter(is_starting_page=True).first() - self.default_start_canvas = self.volume.canvas_set.filter(is_starting_page=False).first() - self.assumed_label = ' Descrizione del Palazzo Apostolico Vaticano ' - self.assumed_pid = 'readux:st7r6' + self.default_start_canvas = self.volume.canvas_set.filter( + is_starting_page=False + ).first() + self.assumed_label = " Descrizione del Palazzo Apostolico Vaticano " + self.assumed_pid = "readux:st7r6" self.manifest_export_view = ManifestExport.as_view() self.jekyll_export_view = JekyllExport.as_view() - self.sa_app = SocialAppFactory.create( - provider='github', - name='GitHub' - ) + self.sa_app = SocialAppFactory.create(provider="github", name="GitHub") self.sa_acct = SocialAccountFactory.create( - provider='github', + provider="github", user_id=self.user.pk, - extra_data={'login': self.user.username} + extra_data={"login": self.user.username}, ) self.sa_token = SocialTokenFactory.create( - app_id = self.sa_app.pk, - account_id = self.sa_acct.pk + app_id=self.sa_app.pk, account_id=self.sa_acct.pk ) - self.jse = JekyllSiteExport(self.volume, 'v2') + self.jse = JekyllSiteExport(self.volume, "v2") self.jse.user = self.user self.jse.use_github(self.user) - self.jse.github_repo = 'marx' + self.jse.github_repo = "marx" # self.jse.is_testing = True self.jse.owners = [self.user.id] def test_zip_creation(self): - zip_file = IiifManifestExport.get_zip(self.volume, 'v2', owners=[self.user.id]) + zip_file = IiifManifestExport.get_zip(self.volume, "v2", owners=[self.user.id]) assert isinstance(zip_file, bytes) # unzip the file somewhere - tmpdir = tempfile.mkdtemp(prefix='tmp-rdx-export-') + tmpdir = tempfile.mkdtemp(prefix="tmp-rdx-export-") iiif_zip = zipfile.ZipFile(io.BytesIO(zip_file), "r") iiif_zip.extractall(tmpdir) - manifest_path = os.path.join(tmpdir, 'manifest.json') + manifest_path = os.path.join(tmpdir, "manifest.json") with open(manifest_path) as json_file: manifest = json.load(json_file) - ocr_annotation_list_id = manifest['sequences'][0]['canvases'][0]['otherContent'][0]['@id'] + ocr_annotation_list_id = manifest["sequences"][0]["canvases"][0][ + "otherContent" + ][0]["@id"] ocr_annotation_list_path = os.path.join( - tmpdir, - re.sub(r'\W', '_', ocr_annotation_list_id) + ".json" + tmpdir, re.sub(r"\W", "_", ocr_annotation_list_id) + ".json" ) assert os.path.exists(ocr_annotation_list_path) == 1 with open(ocr_annotation_list_path) as json_file: ocr_annotation_list = json.load(json_file) - assert ocr_annotation_list['@id'] == ocr_annotation_list_id + assert ocr_annotation_list["@id"] == ocr_annotation_list_id + + other_content = manifest["sequences"][0]["canvases"][0]["otherContent"] + + comment_annotation_list_id = [ + x["@id"] + for x in other_content + if x["@type"] == "sc:AnnotationList" and "OCR" not in x["label"] + ][0] - comment_annotation_list_id = manifest['sequences'][0]['canvases'][0]['otherContent'][1]['@id'] comment_annotation_list_path = os.path.join( - tmpdir, - re.sub(r'\W', '_', comment_annotation_list_id) + ".json" + tmpdir, re.sub(r"\W", "_", comment_annotation_list_id) + ".json" ) assert os.path.exists(comment_annotation_list_path) == 1 with open(comment_annotation_list_path) as json_file: comment_annotation_list = json.load(json_file) - assert comment_annotation_list['@id'] == comment_annotation_list_id + assert comment_annotation_list["@id"] == comment_annotation_list_id def test_jekyll_site_export(self): - user_anno = UserAnnotation.objects.get(pk='18f24705-398a-401d-a106-acfca6e72070') + user_anno = UserAnnotation.objects.get( + pk="18f24705-398a-401d-a106-acfca6e72070" + ) # self.user.userannotation_set.clear() # user_anno = UserAnnotationFactory.create(canvas=self.volume.canvas_set.first(), owner=self.user) - user_anno.tags.add('tag1', 'tag2', 'tag3') + user_anno.tags.add("tag1", "tag2", "tag3") assert user_anno.owner == self.user assert user_anno.canvas.manifest == self.volume self.volume.refresh_from_db() - j = JekyllSiteExport(self.volume, 'v2', owners=[self.user.id]) + j = JekyllSiteExport(self.volume, "v2", owners=[self.user.id]) zip_file = j.get_zip() tempdir = j.generate_website() web_zip = j.website_zip() assert isinstance(zip_file, tempfile._TemporaryFileWrapper) assert "%s_annotated_site_" % (str(self.volume.pk)) in zip_file.name - assert zip_file.name.endswith('.zip') + assert zip_file.name.endswith(".zip") assert isinstance(web_zip, tempfile._TemporaryFileWrapper) assert "%s_annotated_site_" % (str(self.volume.pk)) in web_zip.name - assert web_zip.name.endswith('.zip') - assert 'tmp-rdx-export' in tempdir - assert tempdir.endswith('/export') - tmpdir = tempfile.mkdtemp(prefix='tmp-rdx-export-') + assert web_zip.name.endswith(".zip") + assert "tmp-rdx-export" in tempdir + assert tempdir.endswith("/export") + tmpdir = tempfile.mkdtemp(prefix="tmp-rdx-export-") jekyll_zip = zipfile.ZipFile(zip_file, "r") jekyll_zip.extractall(tmpdir) jekyll_dir = os.listdir(tmpdir)[0] jekyll_path = os.path.join(tmpdir, jekyll_dir) # verify the iiif export is embedded - iiif_path = os.path.join(jekyll_path, 'iiif_export') - manifest_path = os.path.join(iiif_path, 'manifest.json') + iiif_path = os.path.join(jekyll_path, "iiif_export") + manifest_path = os.path.join(iiif_path, "manifest.json") assert os.path.exists(manifest_path) # verify page count is correct - assert len(os.listdir(os.path.join(jekyll_path, '_volume_pages'))) == 2 + assert len(os.listdir(os.path.join(jekyll_path, "_volume_pages"))) == 2 # verify ocr annotation count is correct - with open(os.path.join(jekyll_path, '_volume_pages', '0000.html')) as page_file: + with open(os.path.join(jekyll_path, "_volume_pages", "0000.html")) as page_file: contents = page_file.read() # Depending on the order the tests are run, there might be more or less in the database. # TODO: Why does the database not get reset? # assert contents.count(' 0 - assert os.path.exists(os.path.join(jekyll_path, 'overlays', 'ocr', '6.json')) - assert os.path.exists(os.path.join(jekyll_path, 'overlays', 'annotations', '6.json')) - assert os.path.exists(os.path.join(jekyll_path, 'tags', 'tag1.md')) - tags_yaml = open(os.path.join(jekyll_path, '_data', 'tags.yml')).read() - assert 'tag1' in tags_yaml + assert len(os.listdir(os.path.join(jekyll_path, "_annotations"))) == 1 + assert os.path.exists(os.path.join(jekyll_path, "overlays", "ocr", "6.json")) + assert os.path.exists( + os.path.join(jekyll_path, "overlays", "annotations", "6.json") + ) + assert os.path.exists(os.path.join(jekyll_path, "tags", "tag1.md")) + tags_yaml = open(os.path.join(jekyll_path, "_data", "tags.yml")).read() + assert "tag1" in tags_yaml def test_jekyll_export_error(self): - export = JekyllSiteExport(self.volume, 'v2', owners=[self.user.id], deep_zoom='exclude') - export.jekyll_site_dir = 'nope' + export = JekyllSiteExport( + self.volume, "v2", owners=[self.user.id], deep_zoom="exclude" + ) + export.jekyll_site_dir = "nope" try: - export.import_iiif_jekyll(self.volume, 'non_existing_directory') + export.import_iiif_jekyll(self.volume, "non_existing_directory") assert False except ExportException: assert True def test_get_zip_file(self): # Make an empty file - dummy_file = os.path.join(tempfile.tempdir, 'file.txt') - open(dummy_file, 'a').close() - j = JekyllSiteExport(self.volume, 'v2', owners=[self.user.id]) + dummy_file = os.path.join(tempfile.tempdir, "file.txt") + open(dummy_file, "a").close() + j = JekyllSiteExport(self.volume, "v2", owners=[self.user.id]) zip_file = j.get_zip_file(dummy_file) assert isinstance(zip_file, bytes) def test_manifest_export(self): - kwargs = { 'pid': self.volume.pid, 'version': 'v2' } - url = reverse('ManifestExport', kwargs=kwargs) + kwargs = {"pid": self.volume.pid, "version": "v2"} + url = reverse("ManifestExport", kwargs=kwargs) request = self.factory.post(url, kwargs=kwargs) request.user = self.user - response = self.manifest_export_view(request, pid=self.volume.pid, version='v2') + response = self.manifest_export_view(request, pid=self.volume.pid, version="v2") assert isinstance(response.getvalue(), bytes) # def test_setting_jekyll_site_dir(self): @@ -171,112 +205,140 @@ def test_manifest_export(self): # * Verify that the annotationList filename matches the @id within the annotation # * Verify the contents of the annotationList match the OCR (or the commenting annotation) - def test_jekyll_export_exclude_download(self): - kwargs = { 'pid': self.volume.pid, 'version': 'v2' } - url = reverse('JekyllExport', kwargs=kwargs) - kwargs['deep_zoom'] = 'exclude' - kwargs['mode'] = 'download' + kwargs = {"pid": self.volume.pid, "version": "v2"} + url = reverse("JekyllExport", kwargs=kwargs) + kwargs["deep_zoom"] = "exclude" + kwargs["mode"] = "download" request = self.factory.post(url, data=kwargs) request.user = self.user - response = self.jekyll_export_view(request, pid=self.volume.pid, version='v2', content_type="application/x-www-form-urlencoded") + response = self.jekyll_export_view( + request, + pid=self.volume.pid, + version="v2", + content_type="application/x-www-form-urlencoded", + ) assert isinstance(response.getvalue(), bytes) def test_jekyll_export_include_download(self): - kwargs = {'pid': self.volume.pid, 'version': 'v2'} - url = reverse('JekyllExport', kwargs=kwargs) - kwargs['deep_zoom'] = 'include' - kwargs['mode'] = 'download' + kwargs = {"pid": self.volume.pid, "version": "v2"} + url = reverse("JekyllExport", kwargs=kwargs) + kwargs["deep_zoom"] = "include" + kwargs["mode"] = "download" request = self.factory.post(url, data=kwargs) request.user = self.user response = self.jekyll_export_view( request, pid=self.volume.pid, - version='v2', - content_type='x-www-form-urlencoded' + version="v2", + content_type="x-www-form-urlencoded", ) assert isinstance(response.getvalue(), bytes) @httpretty.httprettified(allow_net_connect=False) def test_jekyll_export_to_github_repo_name_has_spaces(self): - httpretty.register_uri(httpretty.GET, 'https://zaphod.github.io/has-spaces/', status=200) httpretty.register_uri( - httpretty.GET, - 'https://api.github.com/users/{u}/repos?per_page=3'.format(u=self.jse.github_username), - body='[{"name":"marx"}]', - content_type="text/json" + httpretty.GET, "https://zaphod.github.io/has-spaces/", status=200 ) httpretty.register_uri( - httpretty.POST, 'https://api.github.com/user/repos' + httpretty.GET, + "https://api.github.com/users/{u}/repos?per_page=3".format( + u=self.jse.github_username + ), + body='[{"name":"marx"}]', + content_type="text/json", ) - kwargs = {'pid': self.volume.pid, 'version': 'v2'} - url = reverse('JekyllExport', kwargs=kwargs) - kwargs['deep_zoom'] = 'exclude' - kwargs['mode'] = 'github' - kwargs['github_repo'] = 'has spaces' + httpretty.register_uri(httpretty.POST, "https://api.github.com/user/repos") + kwargs = {"pid": self.volume.pid, "version": "v2"} + url = reverse("JekyllExport", kwargs=kwargs) + kwargs["deep_zoom"] = "exclude" + kwargs["mode"] = "github" + kwargs["github_repo"] = "has spaces" request = self.factory.post(url, data=kwargs) request.user = self.user response = self.jekyll_export_view( request, pid=self.volume.pid, - version='v2', - content_type="application/x-www-form-urlencoded" + version="v2", + content_type="application/x-www-form-urlencoded", ) assert response.status_code == 200 - assert 'https://github.com/zaphod/has-spaces' in response.content.decode('utf-8') + assert "https://github.com/zaphod/has-spaces" in response.content.decode( + "utf-8" + ) self.assertEqual(len(mail.outbox), 1) - self.assertEqual(mail.outbox[0].subject, 'Your Readux site export is ready!') + self.assertEqual(mail.outbox[0].subject, "Your Readux site export is ready!") @httpretty.httprettified(allow_net_connect=False) def test_jekyll_export_to_github_repo_name_not_given(self): - httpretty.register_uri(httpretty.GET, f'https://zaphod.github.io/{slugify(self.volume.label, lowercase=False, max_length=50)}/', status=200) httpretty.register_uri( httpretty.GET, - 'https://api.github.com/users/{u}/repos?per_page=3'.format(u=self.jse.github_username), - body='[{"name":"marx"}]', - content_type="text/json" + f"https://zaphod.github.io/{slugify(self.volume.label, lowercase=False, max_length=50)}/", + status=200, ) httpretty.register_uri( - httpretty.POST, 'https://api.github.com/user/repos' + httpretty.GET, + "https://api.github.com/users/{u}/repos?per_page=3".format( + u=self.jse.github_username + ), + body='[{"name":"marx"}]', + content_type="text/json", ) - kwargs = {'pid': self.volume.pid, 'version': 'v2'} - url = reverse('JekyllExport', kwargs=kwargs) - kwargs['deep_zoom'] = 'exclude' - kwargs['mode'] = 'github' - kwargs['github_repo'] = '' + httpretty.register_uri(httpretty.POST, "https://api.github.com/user/repos") + kwargs = {"pid": self.volume.pid, "version": "v2"} + url = reverse("JekyllExport", kwargs=kwargs) + kwargs["deep_zoom"] = "exclude" + kwargs["mode"] = "github" + kwargs["github_repo"] = "" request = self.factory.post(url, data=kwargs) request.user = self.user response = self.jekyll_export_view( request, pid=self.volume.pid, - version='v2', - content_type="application/x-www-form-urlencoded" + version="v2", + content_type="application/x-www-form-urlencoded", ) assert response.status_code == 200 - assert f'https://github.com/zaphod/{slugify(self.volume.label, lowercase=False, max_length=50)}' in response.content.decode('utf-8') - assert f'https://zaphod.github.io/{slugify(self.volume.label, lowercase=False, max_length=50)}' in response.content.decode('utf-8') + assert ( + f"https://github.com/zaphod/{slugify(self.volume.label, lowercase=False, max_length=50)}" + in response.content.decode("utf-8") + ) + assert ( + f"https://zaphod.github.io/{slugify(self.volume.label, lowercase=False, max_length=50)}" + in response.content.decode("utf-8") + ) def test_use_github(self): assert isinstance(self.jse.github, GithubApi) - assert self.jse.github_username == self.sa_acct.extra_data['login'] + assert self.jse.github_username == self.sa_acct.extra_data["login"] assert self.jse.github_token == self.sa_token.token def test_github_auth_repo_given_name(self): auth_repo = self.jse.github_auth_repo(repo_name=self.jse.github_repo) - assert auth_repo == f'https://{self.jse.github_username}:{self.sa_token.token}@github.com/{self.jse.github_username}/{self.jse.github_repo}.git' + assert ( + auth_repo + == f"https://{self.jse.github_username}:{self.sa_token.token}@github.com/{self.jse.github_username}/{self.jse.github_repo}.git" + ) def test_github_auth_repo_given_url(self): - auth_repo = self.jse.github_auth_repo(repo_url=f'https://github.com/{self.jse.github_username}/{self.jse.github_repo}') - assert auth_repo == f'https://{self.jse.github_username}:{self.sa_token.token}@github.com/{self.jse.github_username}/{self.jse.github_repo}.git' #.format(t=self.sa_token.token, r=) + auth_repo = self.jse.github_auth_repo( + repo_url=f"https://github.com/{self.jse.github_username}/{self.jse.github_repo}" + ) + assert ( + auth_repo + == f"https://{self.jse.github_username}:{self.sa_token.token}@github.com/{self.jse.github_username}/{self.jse.github_repo}.git" + ) # .format(t=self.sa_token.token, r=) @httpretty.activate def test_github_exists(self): resp_body = '[{"name":"marx"}]' httpretty.register_uri( httpretty.GET, - 'https://api.github.com/users/{u}/repos?per_page=3'.format(u=self.jse.github_username), + "https://api.github.com/users/{u}/repos?per_page=3".format( + u=self.jse.github_username + ), body=resp_body, - content_type="text/json" + content_type="text/json", ) assert self.jse.gitrepo_exists() @@ -286,9 +348,11 @@ def test_github_does_not_exist(self): resp_body = '[{"name":"engels"}]' httpretty.register_uri( httpretty.GET, - 'https://api.github.com/users/{u}/repos?per_page=3'.format(u=self.jse.github_username), + "https://api.github.com/users/{u}/repos?per_page=3".format( + u=self.jse.github_username + ), body=resp_body, - content_type="text/json" + content_type="text/json", ) assert self.jse.gitrepo_exists() is False @@ -299,138 +363,221 @@ def test_create_website_gitrepo_when_repo_already_exists(self): resp_body = '[{"name":"marx"}]' httpretty.register_uri( httpretty.GET, - 'https://api.github.com/users/{u}/repos?per_page=3'.format(u=self.jse.github_username), + "https://api.github.com/users/{u}/repos?per_page=3".format( + u=self.jse.github_username + ), body=resp_body, - content_type="text/json" + content_type="text/json", ) - with self.assertRaisesMessage(GithubExportException, 'GitHub repo {r} already exists.'.format(r=self.jse.github_repo)): + with self.assertRaisesMessage( + GithubExportException, + "GitHub repo {r} already exists.".format(r=self.jse.github_repo), + ): self.jse.website_gitrepo() @httpretty.activate def test_website_github_repo(self): httpretty.register_uri( httpretty.GET, - 'https://{t}:x-oauth-basic@github.com/{u}/{r}.git/'.format(t=self.jse.github_token, u=self.jse.github_username, r=self.jse.github_repo), - body='', - status=200 + "https://{t}:x-oauth-basic@github.com/{u}/{r}.git/".format( + t=self.jse.github_token, + u=self.jse.github_username, + r=self.jse.github_repo, + ), + body="", + status=200, + ) + httpretty.register_uri( + httpretty.POST, + "https://api.github.com/user/repos", + body="hello", + status=201, + ) + httpretty.register_uri( + httpretty.GET, + f"https://{self.jse.github_username}.github.io/{self.jse.github_repo}/", + status=200, ) - httpretty.register_uri(httpretty.POST, 'https://api.github.com/user/repos', body='hello', status=201) - httpretty.register_uri(httpretty.GET, f'https://{self.jse.github_username}.github.io/{self.jse.github_repo}/', status=200) resp_body = '[{"name":"foo"}]' httpretty.register_uri( httpretty.GET, - 'https://api.github.com/users/{u}/repos?per_page=3'.format(u=self.jse.github_username), + "https://api.github.com/users/{u}/repos?per_page=3".format( + u=self.jse.github_username + ), body=resp_body, - content_type="text/json" + content_type="text/json", ) website = self.jse.website_gitrepo() - assert website == ('https://github.com/{u}/{r}'.format(u=self.jse.github_username, r=self.jse.github_repo), 'https://{u}.github.io/{r}/'.format(u=self.jse.github_username, r=self.jse.github_repo)) + assert website == ( + "https://github.com/{u}/{r}".format( + u=self.jse.github_username, r=self.jse.github_repo + ), + "https://{u}.github.io/{r}/".format( + u=self.jse.github_username, r=self.jse.github_repo + ), + ) @httpretty.activate def test_update_githubrepo(self): httpretty.register_uri( httpretty.GET, - 'https://{t}:x-oauth-basic@github.com/{u}/{r}.git/'.format(t=self.jse.github_token, u=self.jse.github_username, r=self.jse.github_repo), - body='', - status=200 + "https://{t}:x-oauth-basic@github.com/{u}/{r}.git/".format( + t=self.jse.github_token, + u=self.jse.github_username, + r=self.jse.github_repo, + ), + body="", + status=200, + ) + pull_response_body = '{"html_url":"https://github.com/%s/%s/pull/2"}' % ( + self.jse.github_username, + self.jse.github_repo, ) - pull_response_body = '{"html_url":"https://github.com/%s/%s/pull/2"}' % (self.jse.github_username, self.jse.github_repo) httpretty.register_uri( httpretty.POST, - re.compile('https://api.github.com/.*/pulls'), + re.compile("https://api.github.com/.*/pulls"), status=201, - body=pull_response_body + body=pull_response_body, ) resp_body = '[{"name":"marx"}]' httpretty.register_uri( httpretty.GET, - 'https://api.github.com/users/{u}/repos?per_page=3'.format(u=self.jse.github_username), + "https://api.github.com/users/{u}/repos?per_page=3".format( + u=self.jse.github_username + ), body=resp_body, - content_type="text/json" + content_type="text/json", ) new_pull = self.jse.update_gitrepo() - assert new_pull == 'https://github.com/{u}/{r}/pull/2'.format(u=self.jse.github_username, r=self.jse.github_repo) + assert new_pull == "https://github.com/{u}/{r}/pull/2".format( + u=self.jse.github_username, r=self.jse.github_repo + ) @httpretty.activate def test_github_export_first_time(self): httpretty.register_uri( httpretty.GET, - 'https://{t}:x-oauth-basic@github.com/{u}/{r}.git/'.format(t=self.jse.github_token, u=self.jse.github_username, r=self.jse.github_repo), - body='', - status=200 + "https://{t}:x-oauth-basic@github.com/{u}/{r}.git/".format( + t=self.jse.github_token, + u=self.jse.github_username, + r=self.jse.github_repo, + ), + body="", + status=200, + ) + httpretty.register_uri( + httpretty.POST, + "https://api.github.com/user/repos", + body="hello", + status=201, ) - httpretty.register_uri(httpretty.POST, 'https://api.github.com/user/repos', body='hello', status=201) resp_body = '[{"name":"engels"}]' httpretty.register_uri( httpretty.GET, - 'https://api.github.com/users/{u}/repos?per_page=3'.format(u=self.jse.github_username), + "https://api.github.com/users/{u}/repos?per_page=3".format( + u=self.jse.github_username + ), body=resp_body, - content_type="text/json" + content_type="text/json", + ) + httpretty.register_uri( + httpretty.GET, + f"https://{self.jse.github_username}.github.io/{self.jse.github_repo}/", + status=200, ) - httpretty.register_uri(httpretty.GET, f'https://{self.jse.github_username}.github.io/{self.jse.github_repo}/', status=200) gh_export = self.jse.github_export(self.user.email) assert gh_export == [ - 'https://github.com/{u}/{r}'.format(u=self.jse.github_username, r=self.jse.github_repo), - 'https://{u}.github.io/{r}/'.format(u=self.jse.github_username, r=self.jse.github_repo), - None + "https://github.com/{u}/{r}".format( + u=self.jse.github_username, r=self.jse.github_repo + ), + "https://{u}.github.io/{r}/".format( + u=self.jse.github_username, r=self.jse.github_repo + ), + None, ] @httpretty.activate def test_github_export_update(self): httpretty.register_uri( httpretty.GET, - 'https://{t}:x-oauth-basic@github.com/{u}/{r}.git/'.format(t=self.jse.github_token, u=self.jse.github_username, r=self.jse.github_repo), - body='', - status=200 + "https://{t}:x-oauth-basic@github.com/{u}/{r}.git/".format( + t=self.jse.github_token, + u=self.jse.github_username, + r=self.jse.github_repo, + ), + body="", + status=200, + ) + httpretty.register_uri( + httpretty.POST, + "https://api.github.com/user/repos", + body="hello", + status=201, ) - httpretty.register_uri(httpretty.POST, 'https://api.github.com/user/repos', body='hello', status=201) resp_body = '[{"name":"marx"}]' httpretty.register_uri( httpretty.GET, - 'https://api.github.com/users/{u}/repos?per_page=3'.format(u=self.jse.github_username), + "https://api.github.com/users/{u}/repos?per_page=3".format( + u=self.jse.github_username + ), body=resp_body, - content_type="text/json" + content_type="text/json", + ) + httpretty.register_uri( + httpretty.GET, + f"https://{self.jse.github_username}.github.io/{self.jse.github_repo}/", + status=200, ) - httpretty.register_uri(httpretty.GET, f'https://{self.jse.github_username}.github.io/{self.jse.github_repo}/', status=200) gh_export = self.jse.github_export(self.user.email) assert gh_export == [ - 'https://github.com/{u}/{r}'.format(u=self.jse.github_username, r=self.jse.github_repo), - 'https://{u}.github.io/{r}/'.format(u=self.jse.github_username, r=self.jse.github_repo), - 'https://github.com/{u}/{r}/pull/2'.format(u=self.jse.github_username, r=self.jse.github_repo) + "https://github.com/{u}/{r}".format( + u=self.jse.github_username, r=self.jse.github_repo + ), + "https://{u}.github.io/{r}/".format( + u=self.jse.github_username, r=self.jse.github_repo + ), + "https://github.com/{u}/{r}/pull/2".format( + u=self.jse.github_username, r=self.jse.github_repo + ), ] def test_download_export(self): - self.user.email = 'karl@marx.org' + self.user.email = "karl@marx.org" download = self.jse.download_export(self.user.email, self.volume) - assert download.endswith('.zip') + assert download.endswith(".zip") def test_notify_message(self): - self.jse.notify_msg('hey') + self.jse.notify_msg("hey") @httpretty.httprettified(allow_net_connect=False) def test_jekyll_export_to_github_site_timeout(self): - httpretty.register_uri(httpretty.GET, 'https://zaphod.github.io/not-found/', status=404) httpretty.register_uri( - httpretty.GET, - 'https://api.github.com/users/{u}/repos?per_page=3'.format(u=self.jse.github_username), - body='[{"name":"marx"}]', - content_type="text/json" + httpretty.GET, "https://zaphod.github.io/not-found/", status=404 ) httpretty.register_uri( - httpretty.POST, 'https://api.github.com/user/repos' + httpretty.GET, + "https://api.github.com/users/{u}/repos?per_page=3".format( + u=self.jse.github_username + ), + body='[{"name":"marx"}]', + content_type="text/json", ) - kwargs = {'pid': self.volume.pid, 'version': 'v2'} - url = reverse('JekyllExport', kwargs=kwargs) - kwargs['deep_zoom'] = 'exclude' - kwargs['mode'] = 'github' - kwargs['github_repo'] = 'not found' + httpretty.register_uri(httpretty.POST, "https://api.github.com/user/repos") + kwargs = {"pid": self.volume.pid, "version": "v2"} + url = reverse("JekyllExport", kwargs=kwargs) + kwargs["deep_zoom"] = "exclude" + kwargs["mode"] = "github" + kwargs["github_repo"] = "not found" request = self.factory.post(url, data=kwargs) request.user = self.user self.jekyll_export_view( request, pid=self.volume.pid, - version='v2', - content_type="application/x-www-form-urlencoded" + version="v2", + content_type="application/x-www-form-urlencoded", ) self.assertEqual(len(mail.outbox), 1) - self.assertEqual(mail.outbox[0].subject, 'Your Readux site export is taking longer than expected') \ No newline at end of file + self.assertEqual( + mail.outbox[0].subject, + "Your Readux site export is taking longer than expected", + ) diff --git a/apps/iiif/annotations/choices.py b/apps/iiif/annotations/choices.py new file mode 100644 index 000000000..c930d4781 --- /dev/null +++ b/apps/iiif/annotations/choices.py @@ -0,0 +1,29 @@ + # pylint: disable=invalid-name +from django.db.models import TextChoices + +class AnnotationSelector(TextChoices): + FragmentSelector = 'FR' + CssSelector = 'CS' + XPathSelector = 'XP' + TextQuoteSelector = 'TQ' + TextPositionSelector = 'TP' + DataPositionSelector = 'DP' + SvgSelector = 'SV' + RangeSelector = 'RG' + +class AnnotationPurpose(TextChoices): + assessing = 'AS' + bookmarking = 'BM' + classifying = 'CL' + commenting = 'CM' + describing = 'DS' + editing = 'ED' + highlighting = 'HL' + identifying = 'ID' + linking = 'LK' + moderating = 'MO' + painting = 'PT' + questioning = 'QT' + replying = 'RE' + supplementing = 'SP' + tagging = 'TG' diff --git a/apps/iiif/annotations/migrations/0008_auto_20220607_1407.py b/apps/iiif/annotations/migrations/0008_auto_20220607_1407.py new file mode 100644 index 000000000..158807cb1 --- /dev/null +++ b/apps/iiif/annotations/migrations/0008_auto_20220607_1407.py @@ -0,0 +1,58 @@ +# Generated by Django 3.2.13 on 2022-06-07 14:07 + +from django.db import migrations, models + +def assign_selector(app, _): + """_summary_ + + :param app: _description_ + :type app: _type_ + :param _: _description_ + :type _: _type_ + """ + # Annotation = apps.get_model("annotations", "Annotation") # pylint: disable=invalid-name + # for anno in Annotation.objects.all(): + # anno.created_at = datetime.now() + # anno.updated_at = datetime.now() + + + +class Migration(migrations.Migration): + + dependencies = [ + ('annotations', '0007_auto_20210309_1840'), + ] + + operations = [ + migrations.AddField( + model_name='annotation', + name='primary_selector', + field=models.CharField(choices=[('FR', 'FragmentSelector'), ('CS', 'CssSelector'), ('XP', 'XPathSelector'), ('TQ', 'TextQuoteSelector'), ('TP', 'TextPositionSelector'), ('DP', 'DataPositionSelector'), ('SV', 'SvgSelector'), ('RG', 'RangeSelector')], default='FR', max_length=2), + ), + migrations.AddField( + model_name='annotation', + name='purpose', + field=models.CharField(choices=[('AS', 'assessing'), ('BM', 'bookmarking'), ('CL', 'classifying'), ('CM', 'commenting'), ('DS', 'describing'), ('ED', 'editing'), ('HL', 'highlighting'), ('ID', 'identifying'), ('LK', 'linking'), ('MO', 'moderating'), ('QT', 'questioning'), ('RE', 'replying'), ('TG', 'tagging')], default='CM', max_length=2), + ), + migrations.AlterField( + model_name='annotation', + name='h', + field=models.IntegerField(default=0), + ), + migrations.AlterField( + model_name='annotation', + name='w', + field=models.IntegerField(default=0), + ), + migrations.AlterField( + model_name='annotation', + name='x', + field=models.IntegerField(default=0), + ), + migrations.AlterField( + model_name='annotation', + name='y', + field=models.IntegerField(default=0), + ), + migrations.RunPython(assign_selector, migrations.RunPython.noop), + ] diff --git a/apps/iiif/annotations/migrations/0009_auto_20220607_1453.py b/apps/iiif/annotations/migrations/0009_auto_20220607_1453.py new file mode 100644 index 000000000..b7fd874f2 --- /dev/null +++ b/apps/iiif/annotations/migrations/0009_auto_20220607_1453.py @@ -0,0 +1,51 @@ +# Generated by Django 3.2.13 on 2022-06-07 14:53 + +from datetime import datetime +import apps.utils.noid +from django.db import migrations, models + +def add_dates(apps, _): + Annotation = apps.get_model("annotations", "Annotation") # pylint: disable=invalid-name + for anno in Annotation.objects.all(): + anno.created_at = datetime.now() + anno.updated_at = datetime.now() + +class Migration(migrations.Migration): + + dependencies = [ + ('annotations', '0008_auto_20220607_1407'), + ] + + operations = [ + migrations.AddField( + model_name='annotation', + name='created_at', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='annotation', + name='label', + field=models.CharField(default='', max_length=1000), + ), + migrations.AddField( + model_name='annotation', + name='modified_at', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='annotation', + name='pid', + field=models.CharField(default=apps.utils.noid.encode_noid, help_text="Unique ID. Do not use _'s or spaces in the pid.", max_length=255), + ), + migrations.AlterField( + model_name='annotation', + name='primary_selector', + field=models.CharField(choices=[('FR', 'Fragmentselector'), ('CS', 'Cssselector'), ('XP', 'Xpathselector'), ('TQ', 'Textquoteselector'), ('TP', 'Textpositionselector'), ('DP', 'Datapositionselector'), ('SV', 'Svgselector'), ('RG', 'Rangeselector')], default='FR', max_length=2), + ), + migrations.AlterField( + model_name='annotation', + name='purpose', + field=models.CharField(choices=[('AS', 'Assessing'), ('BM', 'Bookmarking'), ('CL', 'Classifying'), ('CM', 'Commenting'), ('DS', 'Describing'), ('ED', 'Editing'), ('HL', 'Highlighting'), ('ID', 'Identifying'), ('LK', 'Linking'), ('MO', 'Moderating'), ('QT', 'Questioning'), ('RE', 'Replying'), ('TG', 'Tagging')], default='CM', max_length=2), + ), + migrations.RunPython(add_dates, migrations.RunPython.noop), + ] diff --git a/apps/iiif/annotations/migrations/0010_auto_20230925_2040.py b/apps/iiif/annotations/migrations/0010_auto_20230925_2040.py new file mode 100644 index 000000000..f071f2fb9 --- /dev/null +++ b/apps/iiif/annotations/migrations/0010_auto_20230925_2040.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.15 on 2023-09-25 20:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('annotations', '0009_auto_20220607_1453'), + ] + + operations = [ + migrations.AlterField( + model_name='annotation', + name='oa_annotation', + field=models.JSONField(default=dict), + ), + migrations.AlterField( + model_name='annotation', + name='purpose', + field=models.CharField(choices=[('AS', 'Assessing'), ('BM', 'Bookmarking'), ('CL', 'Classifying'), ('CM', 'Commenting'), ('DS', 'Describing'), ('ED', 'Editing'), ('HL', 'Highlighting'), ('ID', 'Identifying'), ('LK', 'Linking'), ('MO', 'Moderating'), ('PT', 'Painting'), ('QT', 'Questioning'), ('RE', 'Replying'), ('SP', 'Supplementing'), ('TG', 'Tagging')], default='SP', max_length=2), + ), + ] diff --git a/apps/iiif/annotations/migrations/0011_annotation_raw_content.py b/apps/iiif/annotations/migrations/0011_annotation_raw_content.py new file mode 100644 index 000000000..7023b1b2d --- /dev/null +++ b/apps/iiif/annotations/migrations/0011_annotation_raw_content.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.25 on 2025-03-04 15:23 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('annotations', '0010_auto_20230925_2040'), + ] + + operations = [ + migrations.AddField( + model_name='annotation', + name='raw_content', + field=models.TextField(blank=True, default=' ', null=True), + ), + ] diff --git a/apps/iiif/annotations/models.py b/apps/iiif/annotations/models.py index 9d9a6c465..4574a3a3b 100644 --- a/apps/iiif/annotations/models.py +++ b/apps/iiif/annotations/models.py @@ -1,120 +1,148 @@ """Django models for :class:`apps.iiif.annotations`""" -from django.contrib.postgres.fields import JSONField -from django.db import models, IntegrityError -from django.conf import settings -from django.db.models import signals -from django.core.exceptions import ValidationError -from django.dispatch import receiver + +import uuid +import logging +from django.db import models from django.utils.translation import gettext as _ from django.contrib.auth import get_user_model -from abc import abstractmethod from bs4 import BeautifulSoup -import json -import uuid -import logging +from ..models import IiifBase +from .choices import AnnotationSelector, AnnotationPurpose USER = get_user_model() LOGGER = logging.getLogger(__name__) -class AbstractAnnotation(models.Model): + +class AbstractAnnotation(IiifBase): """Base class for IIIF annotations.""" - OCR = 'cnt:ContentAsText' - TEXT = 'dctypes:Text' - TYPE_CHOICES = ( - (OCR, 'ocr'), - (TEXT, 'text') - ) - COMMENTING = 'oa:commenting' - PAINTING = 'sc:painting' + OCR = "cnt:ContentAsText" + TEXT = "dctypes:Text" + TYPE_CHOICES = ((OCR, "ocr"), (TEXT, "text")) + + OA_COMMENTING = "oa:commenting" + SC_PAINTING = "sc:painting" + SUP = "supplementing" MOTIVATION_CHOICES = ( - (COMMENTING, 'commenting'), - (PAINTING, 'painting') + (OA_COMMENTING, "commenting"), + (SC_PAINTING, "painting"), + (SUP, "supplementing"), ) - PLAIN = 'text/plain' - HTML = 'text/html' - FORMAT_CHOICES = ( - (PLAIN, 'plain text'), - (HTML, 'html') - ) + PLAIN = "text/plain" + HTML = "text/html" + FORMAT_CHOICES = ((PLAIN, "plain text"), (HTML, "html")) id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - x = models.IntegerField() - y = models.IntegerField() - w = models.IntegerField() - h = models.IntegerField() + x = models.IntegerField(default=0) + y = models.IntegerField(default=0) + w = models.IntegerField(default=0) + h = models.IntegerField(default=0) order = models.IntegerField(default=0) - content = models.TextField(blank=True, null=True, default=' ') + content = models.TextField(blank=True, null=True, default=" ") + raw_content = models.TextField(blank=True, null=True, default=" ") resource_type = models.CharField(max_length=50, choices=TYPE_CHOICES, default=TEXT) - motivation = models.CharField(max_length=50, choices=MOTIVATION_CHOICES, default=PAINTING) + # TODO: replace + motivation = models.CharField( + max_length=50, choices=MOTIVATION_CHOICES, default=SC_PAINTING + ) + purpose = models.CharField( + max_length=2, choices=AnnotationPurpose.choices, default=AnnotationPurpose("SP") + ) + primary_selector = models.CharField( + max_length=2, + choices=AnnotationSelector.choices, + default=AnnotationSelector("FR"), + ) format = models.CharField(max_length=20, choices=FORMAT_CHOICES, default=PLAIN) - canvas = models.ForeignKey('canvases.Canvas', on_delete=models.CASCADE, null=True) - language = models.CharField(max_length=10, default='en') - owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE, blank=True, null=True) - oa_annotation = JSONField(default=dict, blank=False) + canvas = models.ForeignKey("canvases.Canvas", on_delete=models.CASCADE, null=True) + language = models.CharField(max_length=10, default="en") + owner = models.ForeignKey( + get_user_model(), on_delete=models.CASCADE, blank=True, null=True + ) + oa_annotation = models.JSONField(default=dict, blank=False) # TODO: Should we keep this for annotations from Mirador, or just get rid of it? svg = models.TextField(blank=True, null=True) style = models.CharField(max_length=1000, blank=True, null=True) item = None - ordering = ['order'] + ordering = ["order"] + + @property + def content_is_html(self): + """ + Is the content of the annotation HTML? + + :return: True if HTML tags are present in the content. + :rtype: bool + """ + return bool(BeautifulSoup(self.content, "html.parser").find()) + + @property + def fragment(self): + """Web Annotation fragment selector. + https://www.w3.org/TR/annotation-model/#fragment-selector + + Returns: + str: FragmentSelector + """ + return f"xywh=pixel:{self.x},{self.y},{self.w},{self.h}" def __str__(self): return str(self.pk) - class Meta: # pylint: disable=too-few-public-methods, missing-class-docstring + class Meta: # pylint: disable=too-few-public-methods, missing-class-docstring abstract = True + class Annotation(AbstractAnnotation): """Model class for IIIF annotations.""" + def save(self, *args, **kwargs): - # if not self.content or self.content.isspace(): - # raise ValidationError('Content cannot be empty') - # self.content = ' ' - super(Annotation, self).save(*args, **kwargs) + self.set_span_element() + super().save(*args, **kwargs) - class Meta: # pylint: disable=too-few-public-methods, missing-class-docstring - ordering = ['order'] + class Meta: # pylint: disable=too-few-public-methods, missing-class-docstring + ordering = ["order"] abstract = False -@receiver(signals.pre_save, sender=Annotation) -def set_span_element(sender, instance, **kwargs): - """ - Post save function to wrap the OCR content in a `` to be overlayed in OpenSeadragon. - - :param sender: Class calling function - :type sender: apps.iiif.annotations.models.Annotation - :param instance: Annotation object - :type instance: apps.iiif.annotations.models.Annotation - """ - # Guard for when an OCR annotation gets re-saved. - # Without this, it would nest the current span in a new span. - if instance.content.startswith(' 0: - # And this is what we're short. - space_to_fill = instance.w - string_width - # Divide up the space to fill and space the letters. - letter_spacing = space_to_fill / character_count - # Percent of letter spacing of overall width. - # This is used by OpenSeadragon. OSD will update the letter spacing relative to - # the width of the overlayed element when someone zooms in and out. - relative_letter_spacing = letter_spacing / instance.w - instance.content = "{content}".format( - pk=instance.pk, content=instance.content, p=str(relative_letter_spacing) - ) - instance.style = ".anno-{c}: {{ height: {h}px; width: {w}px; font-size: {f}px; letter-spacing: {ls}px;}}".format( - c=(instance.pk), h=str(instance.h), w=str(instance.w), f=str(font_size), ls=str(letter_spacing) - ) + # @receiver(signals.pre_save, sender=Annotation) + def set_span_element(self): + """ + Post save function to wrap the OCR content in a `` to be overlaid in OpenSeadragon. + + :param sender: Class calling function + :type sender: apps.iiif.annotations.models.Annotation + :param instance: Annotation object + :type instance: apps.iiif.annotations.models.Annotation + """ + # Guard for when an OCR annotation gets re-saved. + # Without this, it would nest the current span in a new span. + if self.content.startswith(" 0: + # And this is what we're short. + space_to_fill = self.w - string_width + # Divide up the space to fill and space the letters. + letter_spacing = space_to_fill / character_count + # Percent of letter spacing of overall width. + # This is used by OpenSeadragon. OSD will update the letter spacing relative to + # the width of the overlaid element when someone zooms in and out. + relative_letter_spacing = letter_spacing / self.w + # pylint: disable=line-too-long + self.content = f"{self.content}" + self.style = f".anno-{self.pk}: {{ height: {self.h}px; width: {self.w}px; font-size: {font_size}px; letter-spacing: {letter_spacing}px;}}" + # pylint: enable=line-too-long diff --git a/apps/iiif/annotations/tests/tests.py b/apps/iiif/annotations/tests/tests.py index 73cfb4eea..3a0922b0d 100644 --- a/apps/iiif/annotations/tests/tests.py +++ b/apps/iiif/annotations/tests/tests.py @@ -1,53 +1,68 @@ # pylint: disable = missing-function-docstring, invalid-name, line-too-long """Test cases for :class:`apps.iiif.annotations`.""" +import json +from io import StringIO +from bs4 import BeautifulSoup from django.test import TestCase, Client from django.test import RequestFactory -from django.conf import settings -from django.core.exceptions import ValidationError from django.core.management import call_command from django.urls import reverse from django.core.serializers import serialize from django.contrib.auth import get_user_model +from apps.users.tests.factories import UserFactory +from .factories import AnnotationFactory from ..views import AnnotationsForPage from ..models import Annotation from ..apps import AnnotationsConfig from ...canvases.models import Canvas +from ...canvases.tests.factories import CanvasFactory from ...manifests.models import Manifest -from bs4 import BeautifulSoup -from io import StringIO -import warnings -import json USER = get_user_model() + class AnnotationTests(TestCase): """Annotation test cases.""" - fixtures = ['kollections.json', 'manifests.json', 'canvases.json', 'annotations.json'] + + fixtures = [ + "kollections.json", + "manifests.json", + "canvases.json", + "annotations.json", + ] def setUp(self): + self.ocr_user = UserFactory.create(username="ocr", name="OCR") self.factory = RequestFactory() self.client = Client() self.view = AnnotationsForPage.as_view() - self.volume = Manifest.objects.get(pid='readux:st7r6') - self.canvas = Canvas.objects.get(pid='fedora:emory:5622') - self.annotations = Annotation.objects.filter(canvas=self.canvas) + self.volume = Manifest.objects.get(pid="readux:st7r6") + self.canvas = Canvas.objects.get(pid="fedora:emory:5622") + self.annotations = Annotation.objects.filter( + canvas=self.canvas, owner=self.ocr_user + ) - def test_app_config(self): # pylint: disable = no-self-use - assert AnnotationsConfig.verbose_name == 'Annotations' - assert AnnotationsConfig.name == 'apps.iiif.annotations' + def test_app_config(self): # pylint: disable = no-self-use + assert AnnotationsConfig.verbose_name == "Annotations" + assert AnnotationsConfig.name == "apps.iiif.annotations" def test_get_annotations_for_page(self): - kwargs = {'vol': self.volume.pid, 'page': self.canvas.pid, 'version': 'v2'} - url = reverse('page_annotations', kwargs=kwargs) + kwargs = { + "vol": self.volume.pid, + "canvas": self.canvas.pid, + "version": "v2", + "username": "ocr", + } + url = reverse("user_comments", kwargs=kwargs) response = self.client.get(url) - annotations = json.loads(response.content.decode('UTF-8-sig')) - assert len(annotations) == self.annotations.count() + annotations = json.loads(response.content.decode("UTF-8-sig")) + assert len(annotations["resources"]) == self.annotations.count() assert response.status_code == 200 def test_order(self): a = [] - for o in self.annotations.values('order'): - a.append(o['order']) + for o in self.annotations.values("order"): + a.append(o["order"]) b = a.copy() a.sort() assert a == b @@ -60,9 +75,15 @@ def test_ocr_span(self): ocr.w = 100 ocr.h = 10 ocr.content = "Obviously you're not a golfer" + ocr.resource_type = Annotation.OCR ocr.save() - assert ocr.content == "Obviously you're not a golfer".format(pk=ocr.pk) - assert ocr.owner == USER.objects.get(username='ocr') + assert ( + ocr.content + == "Obviously you're not a golfer".format( + pk=ocr.pk + ) + ) + assert ocr.owner == USER.objects.get(username="ocr") def test_default_content(self): ocr = Annotation() @@ -72,8 +93,9 @@ def test_default_content(self): ocr.w = 100 ocr.h = 10 ocr.format = Annotation.HTML + ocr.resource_type = Annotation.OCR ocr.save() - assert '> ' in ocr.content + assert "> " in ocr.content def test_annotation_string(self): anno = Annotation.objects.all().first() @@ -82,44 +104,76 @@ def test_annotation_string(self): def test_annotation_choices(self): anno = Annotation() anno.format = Annotation.HTML - assert anno.format == 'text/html' + assert anno.format == "text/html" anno.format = Annotation.PLAIN - assert anno.format == 'text/plain' + assert anno.format == "text/plain" anno.format = Annotation.OCR - assert anno.format == 'cnt:ContentAsText' + assert anno.format == "cnt:ContentAsText" anno.format = Annotation.TEXT - assert anno.format == 'dctypes:Text' - anno.format = Annotation.COMMENTING - assert anno.format == 'oa:commenting' - anno.format = Annotation.PAINTING - assert anno.format == 'sc:painting' - assert Annotation.FORMAT_CHOICES == (('text/plain', 'plain text'), ('text/html', 'html')) - assert Annotation.TYPE_CHOICES == (('cnt:ContentAsText', 'ocr'), ('dctypes:Text', 'text')) - assert Annotation.MOTIVATION_CHOICES == (('oa:commenting', 'commenting'), ('sc:painting', 'painting')) + assert anno.format == "dctypes:Text" + anno.format = Annotation.OA_COMMENTING + assert anno.format == "oa:commenting" + anno.format = Annotation.SC_PAINTING + assert anno.format == "sc:painting" + assert Annotation.FORMAT_CHOICES == ( + ("text/plain", "plain text"), + ("text/html", "html"), + ) + assert Annotation.TYPE_CHOICES == ( + ("cnt:ContentAsText", "ocr"), + ("dctypes:Text", "text"), + ) + assert Annotation.MOTIVATION_CHOICES == ( + ("oa:commenting", "commenting"), + ("sc:painting", "painting"), + ("supplementing", "supplementing"), + ) def test_ocr_for_page(self): - kwargs = {'vol': self.volume.pid, 'page': self.canvas.pid, 'version': 'v2'} - url = reverse('ocr', kwargs=kwargs) + canvas = CanvasFactory.create(manifest=self.volume) + AnnotationFactory.create_batch(6, owner=self.ocr_user, canvas=canvas) + kwargs = {"vol": self.volume.pid, "page": canvas.pid, "version": "v2"} + url = reverse("ocr", kwargs=kwargs) response = self.client.get(url) - annotations = json.loads(response.content.decode('UTF-8-sig'))['resources'] - assert len(annotations) == self.canvas.annotation_set.filter(resource_type='cnt:ContentAsText', canvas=self.canvas).count() + annotations = json.loads(response.content.decode("UTF-8-sig"))["resources"] + assert ( + len(annotations) + == canvas.annotation_set.filter( + resource_type="cnt:ContentAsText", canvas=canvas + ).count() + ) assert response.status_code == 200 def test_annotation_style(self): - anno = Annotation.objects.all().first() - assert anno.style == ".anno-{c}: {{ height: {h}px; width: {w}px; font-size: {f}px; letter-spacing: 15.125px;}}".format(c=(anno.pk), h=str(anno.h), w=str(anno.w), f=str(anno.h / 1.6)) + anno = AnnotationFactory.create() + assert anno.style.startswith( + f".anno-{anno.pk}: {{ height: {anno.h}px; width: {anno.w}px;" + ) def test_annotation_style_serialization(self): - kwargs = {'vol': self.volume.pid, 'page': self.canvas.pid, 'version': 'v2'} - url = reverse('ocr', kwargs=kwargs) + canvas = CanvasFactory(manifest=self.volume) + AnnotationFactory.create(canvas=canvas) + kwargs = {"vol": self.volume.pid, "page": canvas.pid, "version": "v2"} + url = reverse("ocr", kwargs=kwargs) response = self.client.get(url) - serialized_anno = json.loads(response.content.decode('UTF-8-sig'))['resources'][0] - assert serialized_anno['stylesheet']['type'] == 'CssStylesheet' - assert serialized_anno['stylesheet']['value'].startswith(".anno-{id}".format(id=serialized_anno['@id'])) + serialized_anno = json.loads(response.content.decode("UTF-8-sig"))["resources"][ + 0 + ] + assert serialized_anno["stylesheet"]["type"] == "CssStylesheet" + assert serialized_anno["stylesheet"]["value"].startswith( + ".anno-{id}".format(id=serialized_anno["@id"]) + ) def test_serialize_list_of_annotations(self): - data = json.loads(serialize('annotation_list', [self.canvas], is_list=True, owners=USER.objects.all())) - assert data[0]['@type'] == 'sc:AnnotationList' + data = json.loads( + serialize( + "annotation_list", + [self.canvas], + is_list=True, + owners=USER.objects.all(), + ) + ) + assert data[0]["@type"] == "sc:AnnotationList" assert isinstance(data, list) def test_ocr_char_with_zero_width(self): @@ -129,12 +183,23 @@ def test_ocr_char_with_zero_width(self): ocr.y = 10 ocr.w = 0 ocr.h = 10 - ocr.content = 'nice marmot' + ocr.content = "nice marmot" ocr.format = Annotation.HTML + ocr.resource_type = Annotation.OCR ocr.save() - assert ocr.content == "nice marmot".format(a=ocr.id) - assert ocr.style == ".anno-{a}: {{ height: 10px; width: 0px; font-size: 6.25px; letter-spacing: 0px;}}".format(a=ocr.id) - assert ocr.format == 'text/html' + assert ( + ocr.content + == "nice marmot".format( + a=ocr.id + ) + ) + assert ( + ocr.style + == ".anno-{a}: {{ height: 10px; width: 0px; font-size: 6.25px; letter-spacing: 0px;}}".format( + a=ocr.id + ) + ) + assert ocr.format == "text/html" def test_ocr_char_with_zero_height(self): ocr = Annotation() @@ -143,12 +208,23 @@ def test_ocr_char_with_zero_height(self): ocr.y = 10 ocr.w = 10 ocr.h = 0 - ocr.content = 'nice marmot' + ocr.content = "nice marmot" ocr.format = Annotation.HTML + ocr.resource_type = Annotation.OCR ocr.save() - assert ocr.content == "nice marmot".format(a=ocr.id) - assert ocr.style == ".anno-{a}: {{ height: 0px; width: 10px; font-size: 0.0px; letter-spacing: 0.9090909090909091px;}}".format(a=ocr.id) - assert ocr.format == 'text/html' + assert ( + ocr.content + == "nice marmot".format( + a=ocr.id + ) + ) + assert ( + ocr.style + == ".anno-{a}: {{ height: 0px; width: 10px; font-size: 0.0px; letter-spacing: 0.9090909090909091px;}}".format( + a=ocr.id + ) + ) + assert ocr.format == "text/html" def test_command_output_remove_empty_ocr(self): anno_count = self.annotations.count() @@ -156,16 +232,17 @@ def test_command_output_remove_empty_ocr(self): # anno.content = ' ' # anno.save() out = StringIO() - call_command('remove_empty_ocr', stdout=out) - assert 'Empty OCR annotations have been removed' in out.getvalue() + call_command("remove_empty_ocr", stdout=out) + assert "Empty OCR annotations have been removed" in out.getvalue() # assert anno_count > self.annotations.count() def test_resaving_ocr_annotation(self): # Span should not change - anno = Annotation.objects.all().first() + anno = AnnotationFactory.create(owner=self.ocr_user) + print(anno.owner) orig_span = anno.content anno.save() anno.refresh_from_db() assert orig_span == anno.content - assert anno.content.startswith('/annotations//', - views.AnnotationsForPage.as_view(), - name='page_annotations' - ), path('iiif///list/', views.OcrForPage.as_view(), name='ocr') ] diff --git a/apps/iiif/annotations/views.py b/apps/iiif/annotations/views.py index 9271940e6..7614a8c82 100644 --- a/apps/iiif/annotations/views.py +++ b/apps/iiif/annotations/views.py @@ -20,14 +20,14 @@ def get_queryset(self): """ canvas = Canvas.objects.get(pid=self.kwargs['page']) return Annotation.objects.filter(canvas=canvas).distinct('order') - + def get(self, request, *args, **kwargs): # pylint: disable = unused-argument """ Function to respond to HTTP GET requests for annotations for a given canvas. :param request: HTTP GET request :type request: django request object? - :return: Serialized JSON based on the IIIF presentation API standads. + :return: Serialized JSON based on the IIIF presentation API standards. :rtype: JSON """ # TODO: Does this view need owners? diff --git a/apps/iiif/canvases/admin.py b/apps/iiif/canvases/admin.py index b8d7d10e3..b75aa8756 100644 --- a/apps/iiif/canvases/admin.py +++ b/apps/iiif/canvases/admin.py @@ -11,6 +11,11 @@ from .tasks import add_ocr_task from . import services +def resave_gethw_admin_action(modeladmin, request, queryset): + for canvas in queryset: + canvas.save() +resave_gethw_admin_action.short_description = 'Resave for Height Width' + class CanvasResource(resources.ModelResource): """Django admin Canvas resource""" manifest_id = fields.Field( @@ -38,6 +43,7 @@ class CanvasAdmin(ImportExportModelAdmin, admin.ModelAdmin): 'pid', 'is_starting_page', 'manifest__pid', 'manifest__label' ) + actions = (resave_gethw_admin_action, ) def save_model(self, request, obj, form, change): obj.save() diff --git a/apps/iiif/canvases/management/commands/rebuild_ocr.py b/apps/iiif/canvases/management/commands/rebuild_ocr.py index b345470f4..60f88cbd7 100644 --- a/apps/iiif/canvases/management/commands/rebuild_ocr.py +++ b/apps/iiif/canvases/management/commands/rebuild_ocr.py @@ -1,6 +1,7 @@ """ Manage commands for Canvas objects. """ + from os import environ from progress.bar import Bar import httpretty @@ -14,67 +15,72 @@ USER = get_user_model() + class Command(BaseCommand): - help = 'Rebuild OCR for a canvas' + help = "Rebuild OCR for a canvas" def add_arguments(self, parser): parser.add_argument( - '--canvas', - help='Rebuild OCR for specific canvas with supplied pid.', + "--canvas", + help="Rebuild OCR for specific canvas with supplied pid.", ) parser.add_argument( - '--manifest', - help='Rebuild OCR for entire manifest/volume with supplied pid.', + "--manifest", + help="Rebuild OCR for entire manifest/volume with supplied pid.", ) parser.add_argument( - '--testing', - help='Tells command to mock http requests with testing', - default=False + "--testing", + help="Tells command to mock http requests with testing", + default=False, ) def handle(self, *args, **options): - if options['manifest']: + if options["manifest"]: try: - manifest = Manifest.objects.get(pid=options['manifest']) + manifest = Manifest.objects.get(pid=options["manifest"]) for canvas in manifest.canvas_set.all(): self.__rebuild(canvas) self.stdout.write( self.style.SUCCESS( - 'OCR rebuilt for manifest {m}'.format(m=options['manifest']) + "OCR rebuilt for manifest {m}".format(m=options["manifest"]) ) ) except Manifest.DoesNotExist: self.stdout.write( self.style.ERROR( - 'ERROR: manifest not found with pid {m}'.format(m=options['manifest']) + "ERROR: manifest not found with pid {m}".format( + m=options["manifest"] + ) ) ) - elif options['canvas']: + elif options["canvas"]: try: - canvas = Canvas.objects.get(pid=options['canvas']) + canvas = Canvas.objects.get(pid=options["canvas"]) + print(canvas.image_server) - self.__rebuild(canvas, options['testing']) + self.__rebuild(canvas, options["testing"]) self.stdout.write( self.style.SUCCESS( - 'OCR rebuilt for canvas {c}'.format(c=options['canvas']) + "OCR rebuilt for canvas {c}".format(c=options["canvas"]) ) ) except Canvas.DoesNotExist: self.stdout.write( self.style.ERROR( - 'ERROR: canvas not found with pid {p}'.format(p=options['canvas']) + "ERROR: canvas not found with pid {p}".format( + p=options["canvas"] + ) ) ) else: self.stdout.write( - self.style.ERROR( - 'ERROR: your must provide a manifest or canvas pid' - ) + self.style.ERROR("ERROR: your must provide a manifest or canvas pid") ) def __rebuild(self, canvas, testing=False): + print(canvas.annotation_set.exists()) if not canvas.annotation_set.exists(): - if environ['DJANGO_ENV'] != 'test': + if environ["DJANGO_ENV"] != "test": add_ocr_task(canvas.id) else: ocr = services.get_ocr(canvas) @@ -82,45 +88,46 @@ def __rebuild(self, canvas, testing=False): if ocr is not None: services.add_ocr_annotations(canvas, ocr) else: - # canvas.annotation_set.all().delete() ocr = services.get_ocr(canvas) if ocr is None: + print("GRRRRRRRRRRRRRRRRRRRRRRRRRRR") return word_order = 1 - self.stdout.write('Adding OCR for canvas {c}'.format(c=canvas.pid)) - with Bar('Processing', max=len(ocr)) as prog_bar: + self.stdout.write("Adding OCR for canvas {c}".format(c=canvas.pid)) + with Bar("Processing", max=len(ocr)) as prog_bar: for word in ocr: if ( - word == '' or - 'content' not in word or - not word['content'] or - word['content'].isspace() + word == "" + or "content" not in word + or not word["content"] + or word["content"].isspace() ): continue anno = None try: anno = Annotation.objects.get( - w=word['w'], - h=word['h'], - x=word['x'], - y=word['y'], - owner=USER.objects.get(username='ocr'), - canvas=canvas + w=word["w"], + h=word["h"], + x=word["x"], + y=word["y"], + owner=USER.objects.get(username="ocr"), + canvas=canvas, ) except Annotation.DoesNotExist: anno = Annotation( - w=word['w'], - h=word['h'], - x=word['x'], - y=word['y'], + w=word["w"], + h=word["h"], + x=word["x"], + y=word["y"], canvas=canvas, - owner=USER.objects.get(username='ocr'), + owner=USER.objects.get(username="ocr"), resource_type=Annotation.OCR, - order=word_order + order=word_order, ) word_order += 1 - anno.content = word['content'] + anno.content = word["content"] anno.save() prog_bar.next() + canvas.save() prog_bar.finish() diff --git a/apps/iiif/canvases/migrations/0027_auto_20220607_1453.py b/apps/iiif/canvases/migrations/0027_auto_20220607_1453.py new file mode 100644 index 000000000..2f35b184c --- /dev/null +++ b/apps/iiif/canvases/migrations/0027_auto_20220607_1453.py @@ -0,0 +1,37 @@ +# Generated by Django 3.2.13 on 2022-06-07 14:53 + +from datetime import datetime +import apps.utils.noid +from django.db import migrations, models + +def add_dates(apps, _): + Canvas = apps.get_model("canvases", "Canvas") # pylint: disable=invalid-name + for canvas in Canvas.objects.all(): + canvas.created_at = canvas.manifest.created_at + canvas.updated_at = canvas.manifest.updated_at + + +class Migration(migrations.Migration): + + dependencies = [ + ('canvases', '0026_alter_canvas_pid'), + ] + + operations = [ + migrations.AddField( + model_name='canvas', + name='created_at', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='canvas', + name='modified_at', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AlterField( + model_name='canvas', + name='label', + field=models.CharField(default='', max_length=1000), + ), + migrations.RunPython(add_dates, migrations.RunPython.noop), +] diff --git a/apps/iiif/canvases/migrations/0028_alter_canvas_label.py b/apps/iiif/canvases/migrations/0028_alter_canvas_label.py new file mode 100644 index 000000000..9dbea01a7 --- /dev/null +++ b/apps/iiif/canvases/migrations/0028_alter_canvas_label.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.15 on 2023-02-02 19:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('canvases', '0027_auto_20220607_1453'), + ] + + operations = [ + migrations.AlterField( + model_name='canvas', + name='label', + field=models.CharField(blank=True, default='', max_length=1000, null=True), + ), + ] diff --git a/apps/iiif/canvases/models.py b/apps/iiif/canvases/models.py index 9e6f60500..12e0c9964 100644 --- a/apps/iiif/canvases/models.py +++ b/apps/iiif/canvases/models.py @@ -1,12 +1,13 @@ """Django models representing IIIF canvases and IIIF image server info.""" -from genericpath import exists + import os +from functools import cached_property +from urllib.parse import quote from boto3 import resource from bs4 import BeautifulSoup -from urllib.parse import quote -import config.settings.local as settings from django.db import models from django.contrib.auth import get_user_model +import config.settings.local as settings from ..models import IiifBase from ..manifests.models import Manifest, ImageServer from ..annotations.models import Annotation @@ -14,37 +15,36 @@ USER = get_user_model() + class Canvas(IiifBase): """Django model for IIIF Canvas objects.""" + + label = models.CharField(max_length=1000, default="", blank=True, null=True) summary = models.TextField(blank=True, null=True) manifest = models.ForeignKey(Manifest, on_delete=models.CASCADE) - image_server = models.ForeignKey(ImageServer, on_delete=models.DO_NOTHING, null=True) + image_server = models.ForeignKey( + ImageServer, on_delete=models.DO_NOTHING, null=True + ) position = models.IntegerField() height = models.IntegerField(default=0) width = models.IntegerField(default=0) ocr_offset = models.IntegerField(default=0) resource = models.TextField(blank=True, null=True) is_starting_page = models.BooleanField(default=False) - preferred_ocr = ( - ('word', 'word'), - ('line', 'line'), - ('both', 'both') - ) - # TODO: move this to the mainfest level. + preferred_ocr = (("word", "word"), ("line", "line"), ("both", "both")) + # TODO: move this to the manifest level. default_ocr = models.CharField(max_length=30, choices=preferred_ocr, default="word") ocr_file_path = models.CharField(max_length=500, null=True, blank=True) @property def file_name(self): - return self.pid.replace('_', '/') + return self.pid.replace("_", "/") @property def identifier(self): """Concatenated property to represent IIIF identifier.""" - return '{h}/iiif/{m}/canvas/{c}'.format( - h=settings.HOSTNAME, - m=self.manifest.pid, - c=self.pid + return "{h}/iiif/{m}/canvas/{c}".format( + h=settings.HOSTNAME, m=self.manifest.pid, c=self.pid ) @property @@ -55,10 +55,7 @@ def service_id(self): return None - return '{h}/{c}'.format( - h=self.image_server.server_base, - c=quote(self.pid) - ) + return f"{self.image_server.server_base}/{quote(self.pid)}" @property def resource_id(self): @@ -67,18 +64,13 @@ def resource_id(self): if self.image_server is None: return None - return '{h}/{r}'.format( - h=self.image_server.server_base, - r=self.resource or self.pid - ) + return f"{self.image_server.server_base}/{self.resource}" @property def anno_id(self): """Concatenated property to represent IIIF annotation links.""" - return '{h}/iiif/{m}/annotation/{c}'.format( - h=settings.HOSTNAME, - m=self.manifest.pid, - c=self.pid + return "{h}/iiif/{m}/annotation/{c}".format( + h=settings.HOSTNAME, m=self.manifest.pid, c=self.pid ) @property @@ -89,7 +81,7 @@ def image_info(self): @property def thumbnail(self): """Concatenated property to represent IIIF thumbnail link.""" - return self.resource_id + '/full/200,/0/default.jpg' + return self.resource_id + "/full/200,/0/default.jpg" @property def social_media(self): @@ -99,110 +91,108 @@ def social_media(self): if self.image_server is None: return None - return '{h}/{c}/full/600,/0/default.jpg'.format( - h=self.image_server.server_base, - c=self.resource + return "{h}/{c}/full/600,/0/default.jpg".format( + h=self.image_server.server_base, c=self.resource ) @property def twitter_media1(self): """Concatenated property for twitter cards and Open Graph metadata.""" # TODO: shouldn't this use `self.image_server.server_base` - return f'{self.resource_id}/full/600,/0/default.jpg' + return f"{self.resource_id}/full/600,/0/default.jpg" @property def twitter_media2(self): """Concatenated property for twitter cards and Open Graph metadata.""" - return f'{self.resource_id}/full/600,/0/default.jpg' + return f"{self.resource_id}/full/600,/0/default.jpg" @property def uri(self): """Concatenated property to represent IIIF manifest URI""" - return '{h}/iiif/{m}/'.format( - h=settings.HOSTNAME, - m=self.manifest.pid - ) + return "{h}/iiif/{m}/".format(h=settings.HOSTNAME, m=self.manifest.pid) @property def thumbnail_crop_landscape(self): """Concatenated property for cropped landscape URI""" if self.height > self.width: # portrait - return f'{self.resource_id}/full/,250/0/default.jpg' + return f"{self.resource_id}/full/,250/0/default.jpg" # landscape - return f'{self.resource_id}/pct:25,0,50,100/,250/0/default.jpg' + return f"{self.resource_id}/pct:25,0,50,100/,250/0/default.jpg" @property def thumbnail_crop_tallwide(self): """Concatenated property for cropped tallwide URI""" if self.height > self.width: # portrait - return f'{self.resource_id}/pct:5,5,90,90/,250/0/default.jpg' + return f"{self.resource_id}/pct:5,5,90,90/,250/0/default.jpg" # landscape - return f'{self.resource_id}/pct:5,5,90,90/250,/0/default.jpg' + return f"{self.resource_id}/pct:5,5,90,90/250,/0/default.jpg" @property def thumbnail_crop_volume(self): """Concatenated property for cropped volume URI""" if self.height > self.width: # portrait - return f'{self.resource_id}/pct:15,15,70,70/,600/0/default.jpg' + return f"{self.resource_id}/pct:15,15,70,70/,600/0/default.jpg" # landscape - return f'{self.resource_id}/pct:25,15,50,85/,600/0/default.jpg' + return f"{self.resource_id}/pct:25,15,50,85/,600/0/default.jpg" - @property + @cached_property def result(self): - """Empty attribute to hold the result of requests to get OCR data.""" - words = Annotation.objects.filter( - owner=USER.objects.get(username='ocr'), - canvas=self.id).order_by('order') + """Cached property containing OCR text content from associated annotations.""" + words = self.annotation_set.filter(owner__username="ocr").order_by("order") clean_words = [] for word in words: - clean_word = BeautifulSoup(word.content, 'html.parser').text + clean_word = BeautifulSoup(word.content, "html.parser").text clean_words.append(clean_word) - return ' '.join(clean_words) + return " ".join(clean_words) - def save(self, *args, **kwargs): # pylint: disable = signature-differs + def before_save(self): """ - Override save function to set `resource_id` add OCR, + Pre-save function to set `resource_id` add OCR, set as manifest's `start_canvas` if manifest does not have one, - and set + and set position """ + if self.manifest: + self.manifest.refresh_from_db() + self.__check_image_server() - if self.manifest and self.position is None: + if self.position is None: self.position = self.manifest.canvas_set.count() + 1 - if self.image_info: + if self.image_info is not None: # TODO: Consider changing the default value for height and width # so we don't have to check for 0 in addition to None. if self.width == 0 or self.height == 0: self.width = None self.height = None if self.width is None and self.height is None: - self.width = self.image_info['width'] - self.height = self.image_info['height'] - - super().save(*args, **kwargs) + self.width = self.image_info["width"] + self.height = self.image_info["height"] if self.resource is None: self.resource = self.pid - self.save() - if self.manifest and self.manifest.start_canvas is None: - self.manifest.save() + def save(self, *args, **kwargs): # pylint: disable = signature-differs + """ + Override save to call the before_save function. + """ + self.before_save() + super().save(*args, **kwargs) def delete(self, *args, **kwargs): """ Override the delete function to clean up files. """ - if self.image_server.storage_service == 's3': - s3 = resource('s3') + if self.image_server.storage_service == "s3": + s3 = resource("s3") s3.Object(self.image_server.storage_path, self.file_name).delete() if self.ocr_file_path: ocr_file = self.ocr_file_path.split("/")[-1] - key = f'{self.manifest.pid}/_*ocr*_/{ocr_file}' + key = f"{self.manifest.pid}/_*ocr*_/{ocr_file}" s3.Object(self.image_server.storage_path, key).delete() else: try: @@ -214,16 +204,15 @@ def delete(self, *args, **kwargs): except (FileNotFoundError, TypeError): pass - super().delete(*args, **kwargs) # TODO: The way we construct PIDs for Canvas objects might need some # rethinking. def clean_pid(self): - """ Override the `__clean_pid` method that replaces underscores (_). + """Override the `__clean_pid` method that replaces underscores (_). Canvas PIDs are combonation of the Manifest PID and the Canvase's file name, seperated by an underscore. This is how Cantaloupe finds - the image file. """ + the image file.""" pass def __str__(self): @@ -236,11 +225,12 @@ def __check_image_server(self): except Manifest.DoesNotExist: return None - class Meta: # pylint: disable=too-few-public-methods, missing-class-docstring - ordering = ['position'] + class Meta: # pylint: disable=too-few-public-methods, missing-class-docstring + ordering = ["position"] + -class Meta: # pylint: disable=too-few-public-methods, missing-class-docstring - verbose_name = 'canvas' +class Meta: # pylint: disable=too-few-public-methods, missing-class-docstring + verbose_name = "canvas" # Translators: admin:skip - verbose_name_plural = 'canvases' - app_label = 'canvas' + verbose_name_plural = "canvases" + app_label = "canvas" diff --git a/apps/iiif/canvases/services.py b/apps/iiif/canvases/services.py index f7b02b197..605aeb38f 100644 --- a/apps/iiif/canvases/services.py +++ b/apps/iiif/canvases/services.py @@ -1,11 +1,14 @@ # pylint: disable=invalid-name """Module to provide some common functions for Canvas objects.""" import csv +import pysftp +from tempfile import gettempdir from io import BytesIO import json -from os import environ, path, unlink +from os import environ, path, unlink, remove import re import tempfile +import logging from hocr_spec import HocrValidator from lxml import etree from django.conf import settings @@ -14,16 +17,23 @@ from apps.iiif.annotations.models import Annotation from apps.utils.fetch import fetch_url -class IncludeQuotesDialect(csv.Dialect): # pylint: disable=too-few-public-methods +LOGGER = logging.getLogger(__name__) + + +class IncludeQuotesDialect(csv.Dialect): # pylint: disable=too-few-public-methods """Subclass of csv.Dialect to include the quote marks in OCR content.""" + # include the quote marks in content - lineterminator = '\n' - delimiter = '\t' - quoting = csv.QUOTE_NONE # perform no special processing of quote characters + lineterminator = "\n" + delimiter = "\t" + quoting = csv.QUOTE_NONE # perform no special processing of quote characters + class HocrValidationError(Exception): """Exception for hOCR validation errors.""" - pass # pylint: disable=unnecessary-pass + + pass # pylint: disable=unnecessary-pass + # @httpretty.activate def activate_fake_canvas_info(canvas): @@ -34,9 +44,13 @@ def activate_fake_canvas_info(canvas): :return: OCR data :rtype: requests.models.Response """ - with open('apps/iiif/canvases/fixtures/info.json', 'r') as file: - iiif_image_info = file.read().replace('\n', '') - httpretty.register_uri(httpretty.GET, canvas.service_id, body=iiif_image_info) + with open("apps/iiif/canvases/fixtures/info.json", "r") as file: + iiif_image_info = file.read().replace("\n", "") + + httpretty.register_uri( + httpretty.GET, f"{canvas.resource_id}/info.json", body=iiif_image_info + ) + def get_ocr(canvas): """Function to determine method for fetching OCR for a canvas. @@ -53,6 +67,7 @@ def get_ocr(canvas): result = fetch_positional_ocr(canvas) return add_positional_ocr(canvas, result) + def get_canvas_info(canvas): """Given a canvas, this function returns the IIIF image info. @@ -62,18 +77,20 @@ def get_canvas_info(canvas): :rtype: requests.models.Response """ # If testing, just fake it. - if environ['DJANGO_ENV'] == 'test': + if environ["DJANGO_ENV"] == "test": httpretty.enable(allow_net_connect=False) activate_fake_canvas_info(canvas) # return response response = fetch_url( - canvas.resource_id, + f"{canvas.resource_id}/info.json", timeout=settings.HTTP_REQUEST_TIMEOUT, - data_format='json' + data_format="json", ) + return response + def fetch_tei_ocr(canvas): """Function to fetch TEI OCR data for a given canvas. @@ -82,14 +99,14 @@ def fetch_tei_ocr(canvas): :return: Positional OCR data :rtype: requests.models.Response """ - if 'archivelab' in canvas.manifest.image_server.server_base: + if "archivelab" in canvas.manifest.image_server.server_base: return None url = "{p}{c}/datastreams/tei/content".format( - p=settings.DATASTREAM_PREFIX, - c=canvas.pid.replace('fedora:', '') + p=settings.DATASTREAM_PREFIX, c=canvas.pid.replace("fedora:", "") ) - return fetch_url(url, data_format='text/plain') + return fetch_url(url, data_format="text/plain") + # TODO: Maybe add "OCR Source" and "OCR Type" attributes to the manifest model. That might # help make this more universal. @@ -101,68 +118,102 @@ def fetch_positional_ocr(canvas): :return: Positional OCR data :rtype: requests.models.Response """ - if 'archivelab' in canvas.manifest.image_server.server_base: - if '$' in canvas.pid: - pid = str(int(canvas.pid.split('$')[-1]) - canvas.ocr_offset) + if "archivelab" in canvas.manifest.image_server.server_base: + if "$" in canvas.pid: + pid = str(int(canvas.pid.split("$")[-1]) - canvas.ocr_offset) else: pid = canvas.pid url = f"https://api.archivelab.org/books/{canvas.manifest.pid}/pages/{pid}/ocr?mode=words" - if environ['DJANGO_ENV'] == 'test': - fake_ocr = open(path.join(settings.APPS_DIR, 'iiif/canvases/fixtures/ocr_words.json')) + if environ["DJANGO_ENV"] == "test": + fake_ocr = open( + path.join(settings.APPS_DIR, "iiif/canvases/fixtures/ocr_words.json"), + encoding="utf-8", + ) words = fake_ocr.read() + print(words) httpretty.enable() httpretty.register_uri(httpretty.GET, url, body=words) return fetch_url(url) - if 'images.readux.ecds.emory' in canvas.manifest.image_server.server_base: + if "images.readux.ecds.emory" in canvas.manifest.image_server.server_base: # Fake TSV data for testing. - if environ['DJANGO_ENV'] == 'test': - fake_tsv = open(path.join(settings.APPS_DIR, 'iiif/canvases/fixtures/sample.tsv')) - tsv = fake_tsv.read() - url = "https://raw.githubusercontent.com/ecds/ocr-bucket/master/{m}/boo.tsv".format( - m=canvas.manifest.pid + if environ["DJANGO_ENV"] == "test": + fake_tsv = open( + path.join(settings.APPS_DIR, "iiif/canvases/fixtures/sample.tsv"), + encoding="utf-8", ) + tsv = fake_tsv.read() + url = f"https://raw.githubusercontent.com/ecds/ocr-bucket/master/{canvas.manifest.pid}/boo.tsv" httpretty.enable() httpretty.register_uri(httpretty.GET, url, body=tsv) if canvas.ocr_file_path is None: + pid = ( + canvas.pid.split("_")[-1] + .replace(".jp2", "") + .replace(".jpg", "") + .replace(".tif", "") + ) return fetch_url( - "https://raw.githubusercontent.com/ecds/ocr-bucket/master/{m}/{p}.tsv".format( - m=canvas.manifest.pid, - p=canvas.pid.split('_')[-1] - .replace('.jp2', '') - .replace('.jpg', '') - .replace('.tif', '') - ), - data_format='text' + f"https://raw.githubusercontent.com/ecds/ocr-bucket/master/{canvas.manifest.pid}/{pid}.tsv", + data_format="text", ) - url = "{p}{c}{s}".format( - p=settings.DATASTREAM_PREFIX, - c=canvas.pid.replace('fedora:', ''), - s=settings.DATASTREAM_SUFFIX + url = ( + settings.DATASTREAM_PREFIX + + canvas.pid.replace("fedora:", "") + + settings.DATASTREAM_SUFFIX ) if ( - environ['DJANGO_ENV'] == 'test' - and 'images.readux.ecds.emory' not in canvas.manifest.image_server.server_base + environ["DJANGO_ENV"] == "test" + and "images.readux.ecds.emory" not in canvas.manifest.image_server.server_base and canvas.ocr_file_path is None ): - fake_json = open(path.join(settings.APPS_DIR, 'iiif/canvases/fixtures/ocr_words.json')) + fake_json = open( + path.join(settings.APPS_DIR, "iiif/canvases/fixtures/ocr_words.json") + ) words = fake_json.read() - httpretty.enable() + httpretty.enable(allow_net_connect=True) httpretty.register_uri(httpretty.GET, url, body=words) if canvas.ocr_file_path is not None: - if canvas.image_server.storage_service == 's3': - return canvas.image_server.bucket.Object(canvas.ocr_file_path).get()['Body'].read() - # if canvas.image_server.storage_service == 'sftp': - # Do something different + if canvas.image_server.storage_service == "s3": + return ( + canvas.image_server.bucket.Object(canvas.ocr_file_path) + .get()["Body"] + .read() + ) + if canvas.image_server.storage_service == "sftp": + try: + if environ["DJANGO_ENV"] == "test": + httpretty.disable() + + connection_options = pysftp.CnOpts() + connection_options.hostkeys = None + sftp = pysftp.Connection( + **canvas.image_server.sftp_connection, cnopts=connection_options + ) + ocr_local_file_path = path.join(gettempdir(), canvas.ocr_file_path) + sftp.get(canvas.ocr_file_path, localpath=ocr_local_file_path) + + with open(ocr_local_file_path, "r") as ocr_file: + ocr_contents = ocr_file.read() + + remove(ocr_local_file_path) + + return ocr_contents.encode() + except Exception as error: + LOGGER.error( + f"Failed to get OCR files from SFTP server with exception: {error}" + ) + return None + + return fetch_url(url, data_format="text/plain") - return fetch_url(url, data_format='text/plain') def is_json(to_test): """Function to test if data is shaped like JSON. @@ -173,7 +224,7 @@ def is_json(to_test): :rtype: bool """ if isinstance(to_test, bytes): - as_str = to_test.decode('utf-8') + as_str = to_test.decode("utf-8") else: as_str = str(to_test) try: @@ -182,6 +233,7 @@ def is_json(to_test): return False return True + def is_tsv(to_test): """Function to test if data is shaped like a TSV. @@ -191,16 +243,17 @@ def is_tsv(to_test): :rtype: bool """ if isinstance(to_test, bytes): - as_str = to_test.decode('utf-8') + as_str = to_test.decode("utf-8") as_list = as_str.splitlines() else: as_str = str(to_test) - as_list = as_str.split('\n') + as_list = as_str.split("\n") if len(as_list) > 1: - if len(as_str.split('\t')) > 1: + if len(as_str.split("\t")) > 1: return True return False + def add_positional_ocr(canvas, result): """Function to disambiguate and parse fetched OCR data for a canvas. @@ -211,29 +264,29 @@ def add_positional_ocr(canvas, result): :return: List of dicts of parsed OCR data. :rtype: list """ + ocr = None if result is None: return None if canvas.ocr_file_path is None: if isinstance(result, dict) or is_json(result): ocr = parse_dict_ocr(result) elif is_tsv(result) and isinstance(result, bytes): - if result.decode('utf-8') == result.decode('UTF-8-sig'): + if result.decode("utf-8") == result.decode("UTF-8-sig"): ocr = parse_tsv_ocr(result) else: ocr = parse_fedora_ocr(result) elif is_tsv(result): ocr = parse_tsv_ocr(result) - elif canvas.ocr_file_path.endswith('.json'): + elif canvas.ocr_file_path.endswith(".json"): ocr = parse_dict_ocr(result) - elif canvas.ocr_file_path.endswith('.tsv') or canvas.ocr_file_path.endswith('.tab'): + elif canvas.ocr_file_path.endswith(".tsv") or canvas.ocr_file_path.endswith(".tab"): ocr = parse_tsv_ocr(result) - elif canvas.ocr_file_path.endswith('.xml'): + elif canvas.ocr_file_path.endswith(".xml"): ocr = parse_xml_ocr(result) - elif canvas.ocr_file_path.endswith('.hocr'): + elif canvas.ocr_file_path.endswith(".hocr"): ocr = parse_hocr_ocr(result) - if ocr: - return ocr - return None + return ocr + def parse_alto_ocr(result): """Function to parse fetched ALTO OCR data for a given canvas. @@ -247,33 +300,36 @@ def parse_alto_ocr(result): return None ocr = [] unvalidated_root = etree.fromstring(result) - if 'ns-v2' in unvalidated_root.tag: - schema_file = 'xml_schema/alto-2-1.xsd' - elif 'ns-v3' in unvalidated_root.tag: - schema_file = 'xml_schema/alto-3-1.xsd' - elif 'ns-v4' in unvalidated_root.tag: - schema_file = 'xml_schema/alto-4-2.xsd' + if "ns-v2" in unvalidated_root.tag: + schema_file = "xml_schema/alto-2-1.xsd" + elif "ns-v3" in unvalidated_root.tag: + schema_file = "xml_schema/alto-3-1.xsd" + elif "ns-v4" in unvalidated_root.tag: + schema_file = "xml_schema/alto-4-2.xsd" else: - schema_file = 'xml_schema/alto-1-4.xsd' - parser = etree.XMLParser(schema = etree.XMLSchema(file=schema_file)) + schema_file = "xml_schema/alto-1-4.xsd" + parser = etree.XMLParser(schema=etree.XMLSchema(file=schema_file)) # The following will raise etree.XMLSyntaxError if invalid root = etree.fromstring(result, parser=parser) - strings = root.findall('.//String') + strings = root.findall(".//String") if not strings: - strings = root.findall('.//{*}String') + strings = root.findall(".//{*}String") for string in strings: - attrib = {k.lower(): v for k, v in string.attrib.items()} - ocr.append({ - 'content': attrib['content'], - 'h': int(attrib['height']), - 'w': int(attrib['width']), - 'x': int(attrib['hpos']), - 'y': int(attrib['vpos']) - }) + attrib = {k.lower(): v for k, v in string.attrib.items()} + ocr.append( + { + "content": attrib["content"], + "h": int(attrib["height"]), + "w": int(attrib["width"]), + "x": int(attrib["hpos"]), + "y": int(attrib["vpos"]), + } + ) if ocr: return ocr return None + def parse_hocr_ocr(result): """Function to parse fetched hOCR data for a given canvas. @@ -283,26 +339,27 @@ def parse_hocr_ocr(result): :rtype: list """ if isinstance(result, bytes): - as_string = result.decode('utf-8') + as_string = result.decode("utf-8") else: as_string = str(result) # Regex to ignore x_size, x_ascenders, x_descenders. this is a known issue with # tesseract producded hOCR: https://github.com/tesseract-ocr/tesseract/issues/3303 result_without_invalid = re.sub( - r'([ ;]+)(x_size [0-9\.\-;]+)|( x_descenders [0-9\.\-;]+)|( x_ascenders [0-9\.\-;]+)', - repl='', string=as_string + r"([ ;]+)(x_size [0-9\.\-;]+)|( x_descenders [0-9\.\-;]+)|( x_ascenders [0-9\.\-;]+)", + repl="", + string=as_string, ) - file_like_hocr = BytesIO(result_without_invalid.encode('utf-8')) + file_like_hocr = BytesIO(result_without_invalid.encode("utf-8")) with tempfile.NamedTemporaryFile(delete=False) as tmp_file: file_like_hocr.seek(0) tmp_file.write(file_like_hocr.read()) tmp_file.flush() temp_file_name = tmp_file.name - validator = HocrValidator(profile='relaxed') + validator = HocrValidator(profile="relaxed") report = validator.validate(source=temp_file_name) - is_valid = report.format('bool') + is_valid = report.format("bool") if not is_valid: - report_text = report.format('text') + report_text = report.format("text") unlink(temp_file_name) raise HocrValidationError(str(report_text)) unlink(temp_file_name) @@ -313,23 +370,26 @@ def parse_hocr_ocr(result): if not words: words = tree.findall(".//{*}span[@class]") for word in words: - if word.attrib['class'] == 'ocrx_word': - all_attribs = word.attrib['title'].split(';') - bbox = next((attrib for attrib in all_attribs if 'bbox' in attrib), '') + if word.attrib["class"] == "ocrx_word": + all_attribs = word.attrib["title"].split(";") + bbox = next((attrib for attrib in all_attribs if "bbox" in attrib), "") # Splitting 'bbox x0 y0 x1 y1' - bbox_attribs = bbox.split(' ') + bbox_attribs = bbox.split(" ") if len(bbox_attribs) == 5: - ocr.append({ - 'content': word.text, - 'h': int(bbox_attribs[4]) - int(bbox_attribs[2]), - 'w': int(bbox_attribs[3]) - int(bbox_attribs[1]), - 'x': int(bbox_attribs[1]), - 'y': int(bbox_attribs[2]) - }) + ocr.append( + { + "content": word.text, + "h": int(bbox_attribs[4]) - int(bbox_attribs[2]), + "w": int(bbox_attribs[3]) - int(bbox_attribs[1]), + "x": int(bbox_attribs[1]), + "y": int(bbox_attribs[2]), + } + ) if ocr: return ocr return None + def parse_dict_ocr(result): """Function to parse dict or JSON OCR data. @@ -340,27 +400,30 @@ def parse_dict_ocr(result): """ ocr = [] if isinstance(result, bytes): - as_string = result.decode('utf-8') + as_string = result.decode("utf-8") as_dict = json.loads(as_string) elif isinstance(result, str): as_dict = json.loads(result) else: as_dict = result - if 'ocr' in as_dict and as_dict['ocr'] is not None: - for index, word in enumerate(as_dict['ocr']): # pylint: disable=unused-variable + if "ocr" in as_dict and as_dict["ocr"] is not None: + for index, word in enumerate(as_dict["ocr"]): # pylint: disable=unused-variable if len(word) > 0: for w in word: - ocr.append({ - 'content': w[0], - 'w': (w[1][2] - w[1][0]), - 'h': (w[1][1] - w[1][3]), - 'x': w[1][0], - 'y': w[1][3], - }) + ocr.append( + { + "content": w[0], + "w": (w[1][2] - w[1][0]), + "h": (w[1][1] - w[1][3]), + "x": w[1][0], + "y": w[1][3], + } + ) if ocr: return ocr return None + def parse_tei_ocr(result): """Function to parse fetched TEI OCR data for a given canvas. @@ -372,25 +435,28 @@ def parse_tei_ocr(result): if result is None: return None ocr = [] - parser = etree.XMLParser(schema = etree.XMLSchema(file='xml_schema/tei_all.xsd')) + parser = etree.XMLParser(schema=etree.XMLSchema(file="xml_schema/tei_all.xsd")) # The following will raise etree.XMLSyntaxError if invalid surface = etree.fromstring(result, parser=parser)[-1][0] for zones in surface: - if 'zone' in zones.tag: + if "zone" in zones.tag: for line in zones: # if line[-1].text is None: # continue - ocr.append({ - 'content': line[-1].text, - 'h': int(line.get('lry')) - int(line.get('uly')), - 'w': int(line.get('lrx')) - int(line.get('ulx')), - 'x': int(line.get('ulx')), - 'y': int(line.get('uly')) - }) + ocr.append( + { + "content": line[-1].text, + "h": int(line.get("lry")) - int(line.get("uly")), + "w": int(line.get("lrx")) - int(line.get("ulx")), + "x": int(line.get("ulx")), + "y": int(line.get("uly")), + } + ) if ocr: return ocr return None + def parse_tsv_ocr(result): """Function to parse fetched TSV OCR data for a given canvas. @@ -401,9 +467,9 @@ def parse_tsv_ocr(result): """ ocr = [] if isinstance(result, bytes): - lines = result.decode('utf-8').splitlines() + lines = result.decode("utf-8").splitlines() else: - lines = str(result).split('\n') + lines = str(result).split("\n") # Sometimes the TSV has some extra tabs at the beginning and the end. These have # to be cleaned out. It gets complicatied. @@ -413,28 +479,31 @@ def parse_tsv_ocr(result): lines[index] = line # It might be true that the "content" column is empty. However, we just # removed it. So we have to add it back. - if lines[index].count('\t') == 3: - lines[index] = ' \t' + lines[index] + if lines[index].count("\t") == 3: + lines[index] = " \t" + lines[index] reader = csv.DictReader(lines, dialect=IncludeQuotesDialect) for row in reader: - content = row['content'] - w = int(row['w']) - h = int(row['h']) - x = int(row['x']) - y = int(row['y']) - ocr.append({ - 'content': content, - 'w': w, - 'h': h, - 'x': x, - 'y': y, - }) + content = row["content"] + w = int(row["w"]) + h = int(row["h"]) + x = int(row["x"]) + y = int(row["y"]) + ocr.append( + { + "content": content, + "w": w, + "h": h, + "x": x, + "y": y, + } + ) if ocr: return ocr return None + def parse_fedora_ocr(result): """Function to parse fetched Fedora OCR data for a given canvas. @@ -446,17 +515,20 @@ def parse_fedora_ocr(result): ocr = [] if isinstance(result, bytes): # What comes back from fedora is 8-bit bytes - for _, word in enumerate(result.decode('UTF-8-sig').strip().split('\r\n')): - if len(word.split('\t')) == 5: - ocr.append({ - 'content': word.split('\t')[4], - 'w': int(word.split('\t')[2]), - 'h': int(word.split('\t')[3]), - 'x': int(word.split('\t')[0]), - 'y': int(word.split('\t')[1]) - }) + for _, word in enumerate(result.decode("UTF-8-sig").strip().split("\r\n")): + if len(word.split("\t")) == 5: + ocr.append( + { + "content": word.split("\t")[4], + "w": int(word.split("\t")[2]), + "h": int(word.split("\t")[3]), + "x": int(word.split("\t")[0]), + "y": int(word.split("\t")[1]), + } + ) return ocr + def parse_xml_ocr(result): """Function to determine the flavor of XML OCR and then parse accordingly. @@ -467,46 +539,52 @@ def parse_xml_ocr(result): """ root = etree.fromstring(result) if ( - re.match(r'{[0-9A-Za-z.:/#-]+}alto|alto', root.tag) - or 'www.loc.gov/standards/alto' in root.find('.//*').tag + re.match(r"{[0-9A-Za-z.:/#-]+}alto|alto", root.tag) + or "www.loc.gov/standards/alto" in root.find(".//*").tag ): return parse_alto_ocr(result) - if root.find('.//teiHeader') is not None or root.find('.//{*}teiHeader') is not None: + if ( + root.find(".//teiHeader") is not None + or root.find(".//{*}teiHeader") is not None + ): return parse_tei_ocr(result) - if root.find('.//div') is not None or root.find('.//{*}div') is not None: + if root.find(".//div") is not None or root.find(".//{*}div") is not None: # Fallback to hOCR if it looks like XHTML return parse_hocr_ocr(result) return None + def add_ocr_annotations(canvas, ocr): word_order = 1 for word in ocr: # A quick check to make sure the header row didn't slip through. - if word['x'] == 'x': + if word["x"] == "x": continue # Set the content to a single space if it's missing. if ( - word == '' or - 'content' not in word or - not word['content'] or - word['content'].isspace() + word == "" + or "content" not in word + or not word["content"] + or word["content"].isspace() ): - word['content'] = ' ' + word["content"] = " " anno = Annotation() anno.canvas = canvas - anno.x = word['x'] - anno.y = word['y'] - anno.w = word['w'] - anno.h = word['h'] + anno.x = word["x"] + anno.y = word["y"] + anno.w = word["w"] + anno.h = word["h"] anno.resource_type = anno.OCR - anno.content = word['content'] + anno.content = word["content"] anno.order = word_order anno.save() word_order += 1 + def add_oa_annotations(annotation_list_url): data = fetch_url(annotation_list_url) - for oa_annotation in data['resources']: - anno = deserialize('annotation', oa_annotation) - anno.save() \ No newline at end of file + for oa_annotation in data["resources"]: + anno, _ = deserialize("annotation", oa_annotation) + annotation = Annotation(**anno) + Annotation.objects.bulk_create([annotation]) diff --git a/apps/iiif/canvases/tasks.py b/apps/iiif/canvases/tasks.py index af06a2315..78acfdbab 100644 --- a/apps/iiif/canvases/tasks.py +++ b/apps/iiif/canvases/tasks.py @@ -16,6 +16,7 @@ def add_ocr_task(canvas_id, *args, **kwargs): if ocr is not None: add_ocr_annotations(canvas, ocr) + canvas.save() # trigger reindex @app.task(name='adding_oa_ocr_to_canvas', retry_backoff=5) def add_oa_ocr_task(annotation_list_url): diff --git a/apps/iiif/canvases/tests/factories.py b/apps/iiif/canvases/tests/factories.py index df384b574..f33614c13 100644 --- a/apps/iiif/canvases/tests/factories.py +++ b/apps/iiif/canvases/tests/factories.py @@ -1,12 +1,13 @@ """ Factory for created canvas objects for testing. """ + import random from factory.django import DjangoModelFactory from factory import Faker -from apps.utils.noid import encode_noid from ..models import Canvas + class CanvasFactory(DjangoModelFactory): label = Faker("name") height = random.randrange(200, 500) @@ -14,12 +15,12 @@ class CanvasFactory(DjangoModelFactory): position = random.randrange(5) manifest = None ocr_file_path = None - default_ocr = 'word' - pid = encode_noid() + default_ocr = "word" - class Meta: # pylint: disable=too-few-public-methods, missing-class-docstring + class Meta: # pylint: disable=too-few-public-methods, missing-class-docstring model = Canvas + class CanvasNoDimensionsFactory(CanvasFactory): height = 0 width = 0 diff --git a/apps/iiif/canvases/tests/test_manage_cmd.py b/apps/iiif/canvases/tests/test_manage_cmd.py deleted file mode 100644 index e8a7a83b8..000000000 --- a/apps/iiif/canvases/tests/test_manage_cmd.py +++ /dev/null @@ -1,113 +0,0 @@ -""" -Test class for manage command to rebuild OCR -""" -from io import StringIO -from bs4 import BeautifulSoup -from django.test import TestCase -from django.core.management import call_command -from ...canvases.models import Canvas -from ...canvases.tests.factories import CanvasFactory -from ...manifests.tests.factories import ImageServerFactory -from .factories import CanvasFactory - -class CanvasTests(TestCase): - fixtures = ['kollections.json', 'manifests.json', 'canvases.json', 'annotations.json'] - - def setUp(self): - self.canvas = Canvas.objects.get(pk='7261fae2-a24e-4a1c-9743-516f6c4ea0c9') - - def test_command_output_rebuild_manifest(self): - # manifest = Manifest.objects - out = StringIO() - call_command('rebuild_ocr', manifest=self.canvas.manifest.pid, stdout=out) - assert 'OCR rebuilt for manifest' in out.getvalue() - - def test_command_output_rebuild_canvas(self): - out = StringIO() - call_command('rebuild_ocr', canvas=Canvas.objects.all().first().pid, stdout=out) - assert 'OCR rebuilt for canvas' in out.getvalue() - - def test_command_output_rebuild_canvas_with_no_existing_annotations(self): - canvas = CanvasFactory.create(manifest=self.canvas.manifest) - out = StringIO() - call_command('rebuild_ocr', canvas=canvas.pid, stdout=out) - assert 'OCR rebuilt for canvas' in out.getvalue() - - def test_command_output_rebuild_canvas_not_found(self): - out = StringIO() - call_command('rebuild_ocr', canvas='barco', stdout=out) - assert 'ERROR: canvas not found' in out.getvalue() - - def test_command_output_rebuild_manifest_not_found(self): - out = StringIO() - call_command('rebuild_ocr', manifest='josef', stdout=out) - assert 'ERROR: manifest not found' in out.getvalue() - - def test_command_output_rebuild_pid_not_given(self): - out = StringIO() - call_command('rebuild_ocr', stdout=out) - assert 'ERROR: your must provide a manifest or canvas pid' in out.getvalue() - - def test_command_rebuild_ocr_canvas(self): - original_anno_count = self.canvas.annotation_set.all().count() - # Check the OCR attributes before rebuilding. - first_anno = self.canvas.annotation_set.all().first() - assert first_anno.h == 22 - assert first_anno.w == 22 - assert first_anno.x == 1146 - assert first_anno.y == 928 - original_span = BeautifulSoup(first_anno.content, 'html.parser') - assert 'Dope' not in original_span.string - assert original_span.span is not None - assert original_span.span.span is None - self.canvas.manifest.image_server = ImageServerFactory.create( - server_base='http://archivelab.info' - ) - self.canvas.manifest.save() - out = StringIO() - call_command('rebuild_ocr', canvas=self.canvas.pid, testing=True, stdout=out) - assert 'OCR rebuilt for canvas' in out.getvalue() - ocr = self.canvas.annotation_set.all().first() - assert ocr.h == 22 - assert ocr.w == 22 - assert ocr.x == 1146 - assert ocr.y == 928 - new_span = BeautifulSoup(ocr.content, 'html.parser') - assert 'Dope' in new_span.string - assert original_span.string not in new_span.string - assert new_span.span is not None - assert new_span.span.span is None - assert self.canvas.annotation_set.count() > original_anno_count - - def test_command_rebuild_ocr_manifest(self): - canvas = Canvas.objects.get(pk='a7f1bd69-766c-4dd4-ab66-f4051fdd4cff') - original_anno_count = canvas.annotation_set.all().count() - # Check the OCR attributes before rebuilding. - first_anno = canvas.annotation_set.all().first() - assert first_anno.h == 22 - assert first_anno.w == 22 - assert first_anno.x == 1146 - assert first_anno.y == 928 - original_span = BeautifulSoup(first_anno.content, 'html.parser') - assert 'Dope' not in original_span.string - assert original_span.span is not None - assert original_span.span.span is None - manifest = canvas.manifest - manifest.image_server = ImageServerFactory.create( - server_base='http://archivelab.info' - ) - manifest.save() - out = StringIO() - call_command('rebuild_ocr', manifest=manifest.pid, stdout=out) - assert 'OCR rebuilt for manifest' in out.getvalue() - ocr = canvas.annotation_set.all().first() - assert ocr.h == 22 - assert ocr.w == 22 - assert ocr.x == 1146 - assert ocr.y == 928 - new_span = BeautifulSoup(ocr.content, 'html.parser') - assert 'Dope' in new_span.string - assert original_span.string not in new_span.string - assert new_span.span is not None - assert new_span.span.span is None - assert canvas.annotation_set.count() > original_anno_count diff --git a/apps/iiif/canvases/tests/test_model.py b/apps/iiif/canvases/tests/test_model.py index 8dfed0c5d..bba0a8ce3 100644 --- a/apps/iiif/canvases/tests/test_model.py +++ b/apps/iiif/canvases/tests/test_model.py @@ -4,66 +4,126 @@ from django.test import TestCase, Client from boto3 import client, resource from botocore.exceptions import ClientError -from moto import mock_s3 +from moto import mock_aws from django.conf import settings from apps.iiif.manifests.tests.factories import ManifestFactory, ImageServerFactory from .factories import CanvasFactory, CanvasNoDimensionsFactory + class TestCanvasModels(TestCase): - fixtures = ['kollections.json', 'manifests.json', 'canvases.json', 'annotations.json'] + fixtures = [ + "kollections.json", + "manifests.json", + "canvases.json", + "annotations.json", + ] def setUp(self): self.client = Client() - self.canvas = Canvas.objects.get(pk='7261fae2-a24e-4a1c-9743-516f6c4ea0c9') + self.canvas = Canvas.objects.get(pk="7261fae2-a24e-4a1c-9743-516f6c4ea0c9") self.manifest = self.canvas.manifest - self.assumed_canvas_pid = 'fedora:emory:5622' - self.assumed_canvas_resource = '5622' - self.assumed_volume_pid = 'readux:st7r6' - self.assumed_iiif_base = 'https://loris.library.emory.edu' + self.assumed_canvas_pid = "fedora:emory:5622" + self.assumed_canvas_resource = "5622" + self.assumed_volume_pid = "readux:st7r6" + self.assumed_iiif_base = "https://loris.library.emory.edu" def test_properties(self): # httpretty.register_uri(httpretty.GET, 'https://loris.library.emory.edu') - assert self.canvas.identifier == "%s/iiif/%s/canvas/%s" % (settings.HOSTNAME, self.assumed_volume_pid, self.assumed_canvas_pid) - assert self.canvas.service_id == "%s/%s" % (self.assumed_iiif_base, quote(self.assumed_canvas_pid)) - assert self.canvas.anno_id == "%s/iiif/%s/annotation/%s" % (settings.HOSTNAME, self.assumed_volume_pid, self.assumed_canvas_pid) - assert self.canvas.thumbnail == "%s/%s/full/200,/0/default.jpg" % (self.assumed_iiif_base, self.assumed_canvas_resource) - assert self.canvas.social_media == "%s/%s/full/600,/0/default.jpg" % (self.assumed_iiif_base, self.assumed_canvas_resource) - assert self.canvas.twitter_media1 == "%s/%s/full/600,/0/default.jpg" % (self.assumed_iiif_base, self.assumed_canvas_resource) - assert self.canvas.twitter_media2 == "%s/%s/full/600,/0/default.jpg" % (self.assumed_iiif_base, self.assumed_canvas_resource) - assert self.canvas.uri == "%s/iiif/%s/" % (settings.HOSTNAME, self.assumed_volume_pid) - assert self.canvas.thumbnail_crop_landscape == "%s/%s/full/,250/0/default.jpg" % (self.assumed_iiif_base, self.assumed_canvas_resource) - assert self.canvas.thumbnail_crop_tallwide == "%s/%s/pct:5,5,90,90/,250/0/default.jpg" % (self.assumed_iiif_base, self.assumed_canvas_resource) - assert self.canvas.thumbnail_crop_volume == "%s/%s/pct:15,15,70,70/,600/0/default.jpg" % (self.assumed_iiif_base, self.assumed_canvas_resource) + assert self.canvas.identifier == "%s/iiif/%s/canvas/%s" % ( + settings.HOSTNAME, + self.assumed_volume_pid, + self.assumed_canvas_pid, + ) + assert self.canvas.service_id == "%s/%s" % ( + self.assumed_iiif_base, + quote(self.assumed_canvas_pid), + ) + assert self.canvas.anno_id == "%s/iiif/%s/annotation/%s" % ( + settings.HOSTNAME, + self.assumed_volume_pid, + self.assumed_canvas_pid, + ) + assert self.canvas.thumbnail == "%s/%s/full/200,/0/default.jpg" % ( + self.assumed_iiif_base, + self.assumed_canvas_resource, + ) + assert self.canvas.social_media == "%s/%s/full/600,/0/default.jpg" % ( + self.assumed_iiif_base, + self.assumed_canvas_resource, + ) + assert self.canvas.twitter_media1 == "%s/%s/full/600,/0/default.jpg" % ( + self.assumed_iiif_base, + self.assumed_canvas_resource, + ) + assert self.canvas.twitter_media2 == "%s/%s/full/600,/0/default.jpg" % ( + self.assumed_iiif_base, + self.assumed_canvas_resource, + ) + assert self.canvas.uri == "%s/iiif/%s/" % ( + settings.HOSTNAME, + self.assumed_volume_pid, + ) + assert ( + self.canvas.thumbnail_crop_landscape + == "%s/%s/full/,250/0/default.jpg" + % (self.assumed_iiif_base, self.assumed_canvas_resource) + ) + assert ( + self.canvas.thumbnail_crop_tallwide + == "%s/%s/pct:5,5,90,90/,250/0/default.jpg" + % (self.assumed_iiif_base, self.assumed_canvas_resource) + ) + assert ( + self.canvas.thumbnail_crop_volume + == "%s/%s/pct:15,15,70,70/,600/0/default.jpg" + % (self.assumed_iiif_base, self.assumed_canvas_resource) + ) - @mock_s3 + @mock_aws def test_delete_canvas_from_s3(self): """When deleted, it should delete the S3 objects""" - conn = resource('s3', region_name='us-east-1') - conn.create_bucket(Bucket='eagles') - image_server = ImageServerFactory.create(storage_service='s3', storage_path='eagles') + conn = resource("s3", region_name="us-east-1") + conn.create_bucket(Bucket="eagles") + image_server = ImageServerFactory.create( + storage_service="s3", storage_path="eagles" + ) manifest = ManifestFactory.create(image_server=image_server) canvas = CanvasFactory.create( manifest=manifest, - pid=f'{manifest.pid}_00000002.jpg', - ocr_file_path=f'https://{image_server.storage_path}.s3.amazonaws.com/{manifest.pid}/_*ocr_*/00000002.tsv' + pid=f"{manifest.pid}_00000002.jpg", + ocr_file_path=f"https://{image_server.storage_path}.s3.amazonaws.com/{manifest.pid}/_*ocr_*/00000002.tsv", ) - with open(join(settings.APPS_DIR, 'iiif/canvases/fixtures/00000002.jpg'), 'rb') as image_file: - client('s3').upload_fileobj( + with open( + join(settings.APPS_DIR, "iiif/canvases/fixtures/00000002.jpg"), "rb" + ) as image_file: + client("s3").upload_fileobj( image_file, Bucket=image_server.storage_path, - Key=f'{canvas.manifest.pid}/00000002.jpg' + Key=f"{canvas.manifest.pid}/00000002.jpg", ) - with open(join(settings.APPS_DIR, 'iiif/canvases/fixtures/00000002.tsv'), 'rb') as image_file: - client('s3').upload_fileobj( + with open( + join(settings.APPS_DIR, "iiif/canvases/fixtures/00000002.tsv"), "rb" + ) as image_file: + client("s3").upload_fileobj( image_file, Bucket=image_server.storage_path, - Key=f'{canvas.manifest.pid}/_*ocr*_/00000002.tsv' + Key=f"{canvas.manifest.pid}/_*ocr*_/00000002.tsv", ) - self.assertEqual(f'{canvas.manifest.pid}/00000002.jpg', canvas.file_name) - s3_image_obj = resource('s3').Object(image_server.storage_path, canvas.file_name) - s3_ocr_obj = resource('s3').Object(image_server.storage_path, f'{canvas.manifest.pid}/_*ocr*_/00000002.tsv') - self.assertEqual(s3_image_obj.get()['ResponseMetadata']['HTTPHeaders']['etag'], s3_image_obj.e_tag) - self.assertEqual(s3_ocr_obj.get()['ResponseMetadata']['HTTPHeaders']['etag'], s3_ocr_obj.e_tag) + self.assertEqual(f"{canvas.manifest.pid}/00000002.jpg", canvas.file_name) + s3_image_obj = resource("s3").Object( + image_server.storage_path, canvas.file_name + ) + s3_ocr_obj = resource("s3").Object( + image_server.storage_path, f"{canvas.manifest.pid}/_*ocr*_/00000002.tsv" + ) + self.assertEqual( + s3_image_obj.get()["ResponseMetadata"]["HTTPHeaders"]["etag"], + s3_image_obj.e_tag, + ) + self.assertEqual( + s3_ocr_obj.get()["ResponseMetadata"]["HTTPHeaders"]["etag"], + s3_ocr_obj.e_tag, + ) canvas.delete() @@ -73,14 +133,14 @@ def test_delete_canvas_from_s3(self): try: s3_image_obj.get() except ClientError as error: - get_image_error = error.response['Error']['Code'] + get_image_error = error.response["Error"]["Code"] try: s3_ocr_obj.get() except ClientError as error: - get_ocr_error = error.response['Error']['Code'] + get_ocr_error = error.response["Error"]["Code"] - self.assertEqual(get_image_error, 'NoSuchKey') - self.assertEqual(get_ocr_error, 'NoSuchKey') + self.assertEqual(get_image_error, "NoSuchKey") + self.assertEqual(get_ocr_error, "NoSuchKey") def test_no_manifest(self): canvas = Canvas() @@ -93,18 +153,18 @@ def test_string_representation(self): assert str(canvas) == canvas.pid def test_get_image_info(self): - image_server = ImageServerFactory.create(server_base='http://fake.info') + image_server = ImageServerFactory.create(server_base="http://fake.info") manifest = ManifestFactory.create(image_server=image_server) canvas = CanvasFactory.create(manifest=manifest) - assert canvas.image_info['height'] == 3000 - assert canvas.image_info['width'] == 3000 + assert canvas.image_info["height"] == 3000 + assert canvas.image_info["width"] == 3000 def test_setting_height_width_from_iiif(self): - image_server = ImageServerFactory.create(server_base='http://fake.info') + image_server = ImageServerFactory.create(server_base="http://fake.info") manifest = ManifestFactory.create(image_server=image_server) canvas = CanvasFactory.build() - canvas.height = None - canvas.width = None + canvas.height = 0 + canvas.width = 0 assert canvas.height != 3000 assert canvas.width != 3000 canvas.manifest = manifest @@ -120,3 +180,9 @@ def test_setting_height_and_width(self): canvas.save() assert canvas.height == 3000 assert canvas.width == 3000 + + def test_setting_resource(self): + canvas = CanvasNoDimensionsFactory.build(manifest=ManifestFactory.create()) + assert canvas.resource is None + canvas.before_save() + assert canvas.resource == canvas.pid diff --git a/apps/iiif/canvases/tests/test_services.py b/apps/iiif/canvases/tests/test_services.py index f806796ac..b3b318371 100644 --- a/apps/iiif/canvases/tests/test_services.py +++ b/apps/iiif/canvases/tests/test_services.py @@ -1,11 +1,12 @@ """ Test cases for :class:`apps.iiif.canvases` """ + import json from os.path import join import boto3 import httpretty -from moto import mock_s3 +from moto import mock_aws from django.test import TestCase, Client from django.urls import reverse from django.core.serializers import serialize @@ -13,6 +14,7 @@ import config.settings.local as settings from apps.iiif.manifests.tests.factories import ManifestFactory, ImageServerFactory from apps.iiif.annotations.tests.factories import AnnotationFactory +from apps.iiif.annotations.models import Annotation from apps.users.tests.factories import UserFactory from apps.utils.noid import encode_noid from ..models import Canvas @@ -22,127 +24,141 @@ class CanvasTests(TestCase): - fixtures = ['kollections.json', 'manifests.json', 'canvases.json', 'annotations.json'] + fixtures = [ + "kollections.json", + "manifests.json", + "canvases.json", + "annotations.json", + ] def setUp(self): self.client = Client() - self.canvas = Canvas.objects.get(pk='7261fae2-a24e-4a1c-9743-516f6c4ea0c9') + self.canvas = Canvas.objects.get(pk="7261fae2-a24e-4a1c-9743-516f6c4ea0c9") self.manifest = self.canvas.manifest - self.assumed_canvas_pid = 'fedora:emory:5622' - self.assumed_canvas_resource = '5622' - self.assumed_volume_pid = 'readux:st7r6' - self.assumed_iiif_base = 'https://loris.library.emory.edu' + self.assumed_canvas_pid = "fedora:emory:5622" + self.assumed_canvas_resource = "5622" + self.assumed_volume_pid = "readux:st7r6" + self.assumed_iiif_base = "https://loris.library.emory.edu" - def set_up_mock_s3(self, manifest): - conn = boto3.resource('s3', region_name='us-east-1') + def set_up_mock_aws(self, manifest): + conn = boto3.resource("s3", region_name="us-east-1") conn.create_bucket(Bucket=manifest.image_server.storage_path) def test_app_config(self): - assert CanvasesConfig.verbose_name == 'Canvases' - assert CanvasesConfig.name == 'apps.iiif.canvases' + """Test""" + assert CanvasesConfig.verbose_name == "Canvases" + assert CanvasesConfig.name == "apps.iiif.canvases" def test_ia_ocr_creation(self): + """Test""" valid_ia_ocr_response = { - 'ocr': [ - [ - ['III', [120, 1600, 180, 1494, 1597]] - ], - [ - ['chambray', [78, 1734, 116, 1674, 1734]] - ], - [ - ['tacos', [142, 1938, 188, 1854, 1938]] - ], - [ - ['freegan', [114, 2246, 196, 2156, 2245]] - ], + "ocr": [ + [["III", [120, 1600, 180, 1494, 1597]]], + [["chambray", [78, 1734, 116, 1674, 1734]]], + [["tacos", [142, 1938, 188, 1854, 1938]]], + [["freegan", [114, 2246, 196, 2156, 2245]]], + [["Kombucha", [180, 2528, 220, 2444, 2528]]], [ - ['Kombucha', [180, 2528, 220, 2444, 2528]] + ["succulents", [558, 535, 588, 501, 535]], + ["Thundercats", [928, 534, 1497, 478, 527]], ], [ - ['succulents', [558, 535, 588, 501, 535]], - ['Thundercats', [928, 534, 1497, 478, 527]] + ["poke", [557, 617, 646, 575, 614]], + ["VHS", [700, 612, 1147, 555, 610]], + ["chartreuse ", [1191, 616, 1209, 589, 609]], + ["pabst", [1266, 603, 1292, 569, 603]], + ["8-bit", [1354, 602, 1419, 549, 600]], + ["narwhal", [1471, 613, 1566, 553, 592]], + ["XOXO", [1609, 604, 1670, 538, 596]], + ["post-ironic", [1713, 603, 1826, 538, 590]], + ["synth", [1847, 588, 1859, 574, 588]], ], - [ - ['poke', [557, 617, 646, 575, 614]], - ['VHS', [700, 612, 1147, 555, 610]], - ['chartreuse ', [1191, 616, 1209, 589, 609]], - ['pabst', [1266, 603, 1292, 569, 603]], - ['8-bit', [1354, 602, 1419, 549, 600]], - ['narwhal', [1471, 613, 1566, 553, 592]], - ['XOXO', [1609, 604, 1670, 538, 596]], - ['post-ironic', [1713, 603, 1826, 538, 590]], - ['synth', [1847, 588, 1859, 574, 588]] - ], - [ - ['lumbersexual', [1741, 2928, 1904, 2881, 2922]] - ] + [["lumbersexual", [1741, 2928, 1904, 2881, 2922]]], ] } - canvas = Canvas.objects.get(pid='15210893.5622.emory.edu$95') - canvas.manifest.image_server.server_base = 'https://iiif.archivelab.org/iiif/' + canvas = Canvas.objects.get(pid="15210893.5622.emory.edu$95") + canvas.manifest.image_server.server_base = "https://iiif.archivelab.org/iiif/" ocr = services.add_positional_ocr(canvas, valid_ia_ocr_response) assert len(ocr) == 17 for word in ocr: - assert 'w' in word - assert 'h' in word - assert 'x' in word - assert 'y' in word - assert 'content' in word - assert isinstance(word['w'], int) - assert isinstance(word['h'], int) - assert isinstance(word['x'], int) - assert isinstance(word['y'], int) - assert isinstance(word['content'], str) + assert "w" in word + assert "h" in word + assert "x" in word + assert "y" in word + assert "content" in word + assert isinstance(word["w"], int) + assert isinstance(word["h"], int) + assert isinstance(word["x"], int) + assert isinstance(word["y"], int) + assert isinstance(word["content"], str) def test_fedora_ocr_creation(self): - valid_fedora_positional_response = """523\t 116\t 151\t 45\tDistillery\r\n 704\t 117\t 148\t 52\tplaid,"\r\n""".encode('UTF-8-sig') + """Test""" + valid_fedora_positional_response = """523\t 116\t 151\t 45\tDistillery\r\n 704\t 117\t 148\t 52\tplaid,"\r\n""".encode( + "UTF-8-sig" + ) ocr = services.add_positional_ocr(self.canvas, valid_fedora_positional_response) assert len(ocr) == 2 for word in ocr: - assert 'w' in word - assert 'h' in word - assert 'x' in word - assert 'y' in word - assert 'content' in word - assert isinstance(word['w'], int) - assert isinstance(word['h'], int) - assert isinstance(word['x'], int) - assert isinstance(word['y'], int) - assert isinstance(word['content'], str) + assert "w" in word + assert "h" in word + assert "x" in word + assert "y" in word + assert "content" in word + assert isinstance(word["w"], int) + assert isinstance(word["h"], int) + assert isinstance(word["x"], int) + assert isinstance(word["y"], int) + assert isinstance(word["content"], str) def test_ocr_from_tei(self): - tei = open('apps/iiif/canvases/fixtures/tei.xml', 'r').read() - ocr = services.parse_tei_ocr(tei) - assert ocr[1]['content'] == 'AEN DEN LESIIU' - assert ocr[1]['h'] == 28 - assert ocr[1]['w'] == 461 - assert ocr[1]['x'] == 814 - assert ocr[1]['y'] == 185 + """Test""" + with open("apps/iiif/canvases/fixtures/tei.xml", "r", encoding="utf8") as file: + tei = file.read() + ocr = services.parse_tei_ocr(tei) + assert ocr[1]["content"] == "AEN DEN LESIIU" + assert ocr[1]["h"] == 28 + assert ocr[1]["w"] == 461 + assert ocr[1]["x"] == 814 + assert ocr[1]["y"] == 185 def test_line_by_line_from_tei(self): - canvas = CanvasFactory.create(default_ocr='line', manifest=ManifestFactory.create()) - ocr_file = open(join(settings.APPS_DIR, 'iiif/canvases/fixtures/tei.xml'), 'r').read() - tei = services.parse_tei_ocr(ocr_file) - services.add_ocr_annotations(canvas, tei) - updated_canvas = Canvas.objects.get(pk=canvas.pk) - ocr = updated_canvas.annotation_set.first() - assert 'mm' in ocr.content - assert ocr.h == 26 - assert ocr.w == 90 - assert ocr.x == 916 - assert ocr.y == 0 - - for num, anno in enumerate(updated_canvas.annotation_set.all(), start=1): - assert anno.order == num + """Test""" + canvas = CanvasFactory.create( + default_ocr="line", manifest=ManifestFactory.create() + ) + with open( + join(settings.APPS_DIR, "iiif/canvases/fixtures/tei.xml"), + "r", + encoding="utf8", + ) as file: + ocr_file = file.read() + tei = services.parse_tei_ocr(ocr_file) + services.add_ocr_annotations(canvas, tei) + updated_canvas = Canvas.objects.get(pk=canvas.pk) + ocr = updated_canvas.annotation_set.first() + assert "mm" in ocr.content + assert ocr.h == 26 + assert ocr.w == 90 + assert ocr.x == 916 + assert ocr.y == 0 + + for num, anno in enumerate(updated_canvas.annotation_set.all(), start=1): + assert anno.order == num def test_ocr_from_tsv(self): - self.manifest.image_server = ImageServerFactory(server_base='https://images.readux.ecds.emory.fake/') - canvas = CanvasFactory(manifest=self.canvas.manifest, pid='boo') - canvas.save() - # TODO: TOO MANY STEPS TO MAKE OCR???? + """Test""" + manifest = ManifestFactory.create( + image_server=ImageServerFactory.create( + server_base="https://images.readux.ecds.emory.fake/" + ) + ) + canvas = CanvasFactory(manifest=manifest, pid="boo") + manifest.refresh_from_db() + # AnnotationFactory.create(x=459, y=391, w=89, h=43, order=1, canvas=canvas) + # AnnotationFactory.create(x=453, y=397, w=397, h=3, order=2, canvas=canvas) fetched_ocr = services.fetch_positional_ocr(canvas) parsed_ocr = services.add_positional_ocr(canvas, fetched_ocr) services.add_ocr_annotations(canvas, parsed_ocr) @@ -151,289 +167,359 @@ def test_ocr_from_tsv(self): assert ocr.y == 391 assert ocr.w == 89 assert ocr.h == 43 - assert 'Jordan' in ocr.content + assert "Jordan" in ocr.content ocr2 = canvas.annotation_set.all()[1] assert ocr2.x == 453 assert ocr2.y == 397 assert ocr2.w == 397 assert ocr2.h == 3 - assert '> ' in ocr2.content + assert "> " in ocr2.content assert canvas.annotation_set.all().count() == 5 def test_no_tei_from_empty_result(self): + """Test""" ocr = services.parse_tei_ocr(None) assert ocr is None def test_from_bad_tei(self): - tei = open('apps/iiif/canvases/fixtures/bad_tei.xml', 'r').read() - self.assertRaises(XMLSyntaxError, services.parse_tei_ocr, tei) + """Test""" + with open( + "apps/iiif/canvases/fixtures/bad_tei.xml", "r", encoding="utf8" + ) as file: + tei = file.read() + self.assertRaises(XMLSyntaxError, services.parse_tei_ocr, tei) def test_canvas_detail(self): - kwargs = {'manifest': self.manifest.pid, 'pid': self.canvas.pid} - url = reverse('RenderCanvasDetail', kwargs=kwargs) + """Test""" + kwargs = {"manifest": self.manifest.pid, "pid": self.canvas.pid} + url = reverse("RenderCanvasDetail", kwargs=kwargs) response = self.client.get(url) - serialized_canvas = json.loads(response.content.decode('UTF-8-sig')) + serialized_canvas = json.loads(response.content.decode("UTF-8-sig")) assert response.status_code == 200 - assert serialized_canvas['@id'] == self.canvas.identifier - assert serialized_canvas['label'] == str(self.canvas.position) - assert serialized_canvas['images'][0]['@id'] == self.canvas.anno_id - assert serialized_canvas['images'][0]['resource']['@id'] == "%s/full/full/0/default.jpg" % (self.canvas.resource_id) + assert serialized_canvas["@id"] == self.canvas.identifier + assert serialized_canvas["label"] == str(self.canvas.position) + assert serialized_canvas["images"][0]["@id"] == self.canvas.anno_id + assert ( + serialized_canvas["images"][0]["resource"]["@id"] + == f"{self.canvas.resource_id}/full/full/0/default.jpg" + ) def test_canvas_list(self): - kwargs = { 'manifest': self.manifest.pid } - url = reverse('RenderCanvasList', kwargs=kwargs) + """Test""" + kwargs = {"manifest": self.manifest.pid} + url = reverse("RenderCanvasList", kwargs=kwargs) response = self.client.get(url) - canvas_list = json.loads(response.content.decode('UTF-8-sig')) + canvas_list = json.loads(response.content.decode("UTF-8-sig")) assert response.status_code == 200 assert len(canvas_list) == 2 def test_wide_image_crops(self): - pid = '15210893.5622.emory.edu$95' + """Test""" + pid = "15210893.5622.emory.edu$95" canvas = Canvas.objects.get(pid=pid) - # canvas.save() - # canvas.refresh_from_db() - assert canvas.thumbnail_crop_landscape == "%s/%s/pct:25,0,50,100/,250/0/default.jpg" % (canvas.manifest.image_server.server_base, canvas.resource) - for x in range(1, 20): - print(canvas.thumbnail_crop_tallwide) - assert canvas.thumbnail_crop_tallwide == "%s/%s/pct:5,5,90,90/250,/0/default.jpg" % (canvas.manifest.image_server.server_base, canvas.resource) - assert canvas.thumbnail_crop_volume == "%s/%s/pct:25,15,50,85/,600/0/default.jpg" % (canvas.manifest.image_server.server_base, canvas.resource) + assert ( + canvas.thumbnail_crop_landscape + == "%s/%s/pct:25,0,50,100/,250/0/default.jpg" + % (canvas.manifest.image_server.server_base, canvas.resource) + ) + assert ( + canvas.thumbnail_crop_tallwide + == "%s/%s/pct:5,5,90,90/250,/0/default.jpg" + % (canvas.manifest.image_server.server_base, canvas.resource) + ) + assert ( + canvas.thumbnail_crop_volume + == "%s/%s/pct:25,15,50,85/,600/0/default.jpg" + % (canvas.manifest.image_server.server_base, canvas.resource) + ) def test_result_property(self): - assert self.canvas.result == "a retto , dio Quef\u00eca de'" + """Test""" + canvas = CanvasFactory.create(manifest=ManifestFactory.create()) + for order, word in enumerate(["a", "retto", ",", "dio", "Quefìa", "de'"]): + AnnotationFactory.create(content=word, canvas=canvas, order=order + 1) + assert canvas.result == "a retto , dio Quef\u00eca de'" def test_no_tei_for_internet_archive(self): - self.canvas.manifest.image_server.server_base = 'https://iiif.archivelab.org/iiif/' + """Test""" + self.canvas.manifest.image_server.server_base = ( + "https://iiif.archivelab.org/iiif/" + ) assert services.fetch_tei_ocr(self.canvas) is None def test_fetch_positional_ocr(self): - self.canvas.manifest.image_server.server_base = 'https://iiif.archivelab.org/iiif/' - self.canvas.manifest.pid = 'atlantacitydirec1908foot' - self.canvas.pid = '1' - assert services.fetch_positional_ocr(self.canvas)['ocr'] is not None + """Test""" + self.canvas.manifest.image_server.server_base = ( + "https://iiif.archivelab.org/iiif/" + ) + self.canvas.manifest.pid = "atlantacitydirec1908foot" + self.canvas.pid = "1" + assert services.fetch_positional_ocr(self.canvas)["ocr"] is not None def test_fetch_positional_ocr_with_offset(self): - self.canvas.manifest.image_server.server_base = 'https://iiif.archivelab.org/iiif/' - self.canvas.manifest.pid = 'atlantacitydirec1908foot' - self.canvas.pid = '$1' - assert services.fetch_positional_ocr(self.canvas)['ocr'] is not None + """Test""" + self.canvas.manifest.image_server.server_base = ( + "https://iiif.archivelab.org/iiif/" + ) + self.canvas.manifest.pid = "atlantacitydirec1908foot" + self.canvas.pid = "$1" + assert services.fetch_positional_ocr(self.canvas)["ocr"] is not None # def test_fetch_positional_ocr_that_return_none(self): + # """Test""" # self.canvas.manifest.image_server.server_base = 'oxford' # assert services.fetch_positional_ocr(self.canvas) is None - @mock_s3 + @mock_aws def test_ocr_in_s3(self): + """Test""" bucket_name = encode_noid() manifest = ManifestFactory.create( - image_server = ImageServerFactory.create(storage_service = 's3', storage_path=bucket_name, server_base='images.readux.ecds.emory') + image_server=ImageServerFactory.create( + storage_service="s3", + storage_path=bucket_name, + server_base="images.readux.ecds.emory", + ) ) - self.set_up_mock_s3(manifest) - tsv_file_path = 'apps/iiif/canvases/fixtures/00000002.tsv' + self.set_up_mock_aws(manifest) + tsv_file_path = "apps/iiif/canvases/fixtures/00000002.tsv" canvas = manifest.canvas_set.first() - canvas.ocr_file_path = f'{manifest.pid}/_*ocr*_/00000002.tsv' - manifest.image_server.bucket.upload_file(tsv_file_path, f'{manifest.pid}/_*ocr*_/00000002.tsv') + canvas.ocr_file_path = f"{manifest.pid}/_*ocr*_/00000002.tsv" + manifest.image_server.bucket.upload_file( + tsv_file_path, f"{manifest.pid}/_*ocr*_/00000002.tsv" + ) fetched_ocr = services.fetch_positional_ocr(canvas) - assert open(tsv_file_path, 'rb').read() == fetched_ocr + assert open(tsv_file_path, "rb").read() == fetched_ocr - @mock_s3 + @mock_aws def test_fetched_ocr_result_is_string(self): - """ Test when fetched OCR is a string. """ + """Test when fetched OCR is a string.""" bucket_name = encode_noid() manifest = ManifestFactory.create( - image_server = ImageServerFactory.create(storage_service = 's3', storage_path=bucket_name, server_base='images.readux.ecds.emory') + image_server=ImageServerFactory.create( + storage_service="s3", + storage_path=bucket_name, + server_base="images.readux.ecds.emory", + ) ) - self.set_up_mock_s3(manifest) - tsv_file_path = 'apps/iiif/canvases/fixtures/00000002.tsv' + self.set_up_mock_aws(manifest) + tsv_file_path = "apps/iiif/canvases/fixtures/00000002.tsv" canvas = manifest.canvas_set.first() - canvas.ocr_file_path = f'{manifest.pid}/_*ocr*_/00000002.tsv' - manifest.image_server.bucket.upload_file(tsv_file_path, f'{manifest.pid}/_*ocr*_/00000002.tsv') - ocr_result = open(tsv_file_path, 'r').read() - assert isinstance(ocr_result, str) - ocr = services.add_positional_ocr(canvas, ocr_result) - assert len(ocr) == 10 - assert ocr[0]['content'] == 'Manuscript' - - @mock_s3 + canvas.ocr_file_path = f"{manifest.pid}/_*ocr*_/00000002.tsv" + manifest.image_server.bucket.upload_file( + tsv_file_path, f"{manifest.pid}/_*ocr*_/00000002.tsv" + ) + with open(tsv_file_path, "r", encoding="utf8") as file: + ocr_result = file.read() + assert isinstance(ocr_result, str) + ocr = services.add_positional_ocr(canvas, ocr_result) + assert len(ocr) == 10 + assert ocr[0]["content"] == "Manuscript" + + @mock_aws def test_fetched_ocr_result_is_bytes(self): - """ Test when fetched OCR is a bytes. """ + """Test when fetched OCR is a bytes.""" bucket_name = encode_noid() manifest = ManifestFactory.create( - image_server = ImageServerFactory.create(storage_service = 's3', storage_path=bucket_name, server_base='images.readux.ecds.emory') + image_server=ImageServerFactory.create( + storage_service="s3", + storage_path=bucket_name, + server_base="images.readux.ecds.emory", + ) ) - self.set_up_mock_s3(manifest) - tsv_file_path = 'apps/iiif/canvases/fixtures/00000002.tsv' + self.set_up_mock_aws(manifest) + tsv_file_path = "apps/iiif/canvases/fixtures/00000002.tsv" canvas = manifest.canvas_set.first() - canvas.ocr_file_path = f'{manifest.pid}/_*ocr*_/00000002.tsv' - manifest.image_server.bucket.upload_file(tsv_file_path, f'{manifest.pid}/_*ocr*_/00000002.tsv') - ocr_result = open(tsv_file_path, 'rb').read() - assert isinstance(ocr_result, bytes) - ocr = services.add_positional_ocr(canvas, ocr_result) - assert len(ocr) == 10 - assert ocr[0]['content'] == 'Manuscript' + canvas.ocr_file_path = f"{manifest.pid}/_*ocr*_/00000002.tsv" + manifest.image_server.bucket.upload_file( + tsv_file_path, f"{manifest.pid}/_*ocr*_/00000002.tsv" + ) + with open(tsv_file_path, "rb") as file: + ocr_result = file.read() + assert isinstance(ocr_result, bytes) + ocr = services.add_positional_ocr(canvas, ocr_result) + assert len(ocr) == 10 + assert ocr[0]["content"] == "Manuscript" def test_from_alto_ocr(self): - """ Test parsing ALTO OCR """ - alto = open('apps/iiif/canvases/fixtures/alto.xml', 'rb').read() - ocr = services.parse_alto_ocr(alto) - assert ocr[0]['content'] == 'MAGNA' - assert ocr[0]['h'] == 164 - assert ocr[0]['w'] == 758 - assert ocr[0]['x'] == 1894 - assert ocr[0]['y'] == 1787 + """Test parsing ALTO OCR""" + with open("apps/iiif/canvases/fixtures/alto.xml", "rb") as file: + alto = file.read() + ocr = services.parse_alto_ocr(alto) + assert ocr[0]["content"] == "MAGNA" + assert ocr[0]["h"] == 164 + assert ocr[0]["w"] == 758 + assert ocr[0]["x"] == 1894 + assert ocr[0]["y"] == 1787 def test_from_hocr(self): - """ Test parsing hOCR """ - hocr = open('apps/iiif/canvases/fixtures/hocr.hocr', 'rb').read() - ocr = services.parse_hocr_ocr(hocr) - assert ocr[0]['content'] == 'MAGNA' - assert ocr[0]['h'] == 164 - assert ocr[0]['w'] == 758 - assert ocr[0]['x'] == 1894 - assert ocr[0]['y'] == 1787 + """Test parsing hOCR""" + with open("apps/iiif/canvases/fixtures/hocr.hocr", "rb") as file: + hocr = file.read() + ocr = services.parse_hocr_ocr(hocr) + assert ocr[0]["content"] == "MAGNA" + assert ocr[0]["h"] == 164 + assert ocr[0]["w"] == 758 + assert ocr[0]["x"] == 1894 + assert ocr[0]["y"] == 1787 def test_from_bad_hocr(self): - """ Test parsing bad hOCR """ - bad_hocr = open('apps/iiif/canvases/fixtures/bad_hocr.hocr', 'rb').read() - self.assertRaises(services.HocrValidationError, services.parse_hocr_ocr, bad_hocr) + """Test parsing bad hOCR""" + with open("apps/iiif/canvases/fixtures/bad_hocr.hocr", "rb") as file: + bad_hocr = file.read() + self.assertRaises( + services.HocrValidationError, services.parse_hocr_ocr, bad_hocr + ) def test_identifying_alto_xml(self): - """ Test identifying XML file as ALTO OCR """ - alto = open('apps/iiif/canvases/fixtures/alto.xml', 'rb').read() - ocr = services.parse_xml_ocr(alto) - assert ocr[0]['content'] == 'MAGNA' - assert ocr[0]['h'] == 164 - assert ocr[0]['w'] == 758 - assert ocr[0]['x'] == 1894 - assert ocr[0]['y'] == 1787 + """Test identifying XML file as ALTO OCR""" + with open("apps/iiif/canvases/fixtures/alto.xml", "rb") as file: + alto = file.read() + ocr = services.parse_xml_ocr(alto) + assert ocr[0]["content"] == "MAGNA" + assert ocr[0]["h"] == 164 + assert ocr[0]["w"] == 758 + assert ocr[0]["x"] == 1894 + assert ocr[0]["y"] == 1787 def test_identifying_hocr_xml(self): - """ Test identifying XML file as hOCR """ - hocr = open('apps/iiif/canvases/fixtures/hocr.hocr', 'rb').read() - ocr = services.parse_xml_ocr(hocr) - assert ocr[0]['content'] == 'MAGNA' - assert ocr[0]['h'] == 164 - assert ocr[0]['w'] == 758 - assert ocr[0]['x'] == 1894 - assert ocr[0]['y'] == 1787 + """Test identifying XML file as hOCR""" + with open("apps/iiif/canvases/fixtures/hocr.hocr", "rb") as file: + hocr = file.read() + ocr = services.parse_xml_ocr(hocr) + assert ocr[0]["content"] == "MAGNA" + assert ocr[0]["h"] == 164 + assert ocr[0]["w"] == 758 + assert ocr[0]["x"] == 1894 + assert ocr[0]["y"] == 1787 def test_identifying_tei_xml(self): - """ Test identifying XML file as hOCR """ - tei = open('apps/iiif/canvases/fixtures/tei.xml', 'r').read() - ocr = services.parse_xml_ocr(tei) - assert ocr[1]['content'] == 'AEN DEN LESIIU' - assert ocr[1]['h'] == 28 - assert ocr[1]['w'] == 461 - assert ocr[1]['x'] == 814 - assert ocr[1]['y'] == 185 + """Test identifying XML file as hOCR""" + with open("apps/iiif/canvases/fixtures/tei.xml", "r", encoding="utf8") as file: + tei = file.read() + ocr = services.parse_xml_ocr(tei) + assert ocr[1]["content"] == "AEN DEN LESIIU" + assert ocr[1]["h"] == 28 + assert ocr[1]["w"] == 461 + assert ocr[1]["x"] == 814 + assert ocr[1]["y"] == 185 def test_identification_failure(self): - """ Test identifying XML on non-XML fails """ - tsv = open('apps/iiif/canvases/fixtures/sample.tsv', 'r').read() - self.assertRaises(XMLSyntaxError, services.parse_xml_ocr, tsv) + """Test identifying XML on non-XML fails""" + with open( + "apps/iiif/canvases/fixtures/sample.tsv", "r", encoding="utf8" + ) as file: + tsv = file.read() + self.assertRaises(XMLSyntaxError, services.parse_xml_ocr, tsv) def test_unidentifiable_xml(self): - """ Test identifying XML that is not TEI, ALTO, or hOCR """ - hops = open('apps/iiif/canvases/fixtures/hops.xml', 'rb').read() - ocr = services.parse_xml_ocr(hops) - assert ocr is None + """Test identifying XML that is not TEI, ALTO, or hOCR""" + with open("apps/iiif/canvases/fixtures/hops.xml", "rb") as file: + hops = file.read() + ocr = services.parse_xml_ocr(hops) + assert ocr is None - @mock_s3 + @mock_aws def test_add_alto_ocr_by_filename(self): - """ Test get_ocr when OCR is ALTO file (by filename). """ + """Test get_ocr when OCR is ALTO file (by filename).""" bucket_name = encode_noid() manifest = ManifestFactory.create( - image_server = ImageServerFactory.create( - storage_service = 's3', + image_server=ImageServerFactory.create( + storage_service="s3", storage_path=bucket_name, - server_base='images.readux.ecds.emory' + server_base="images.readux.ecds.emory", ) ) - self.set_up_mock_s3(manifest) - tsv_file_path = 'apps/iiif/canvases/fixtures/alto.xml' + self.set_up_mock_aws(manifest) + tsv_file_path = "apps/iiif/canvases/fixtures/alto.xml" canvas = manifest.canvas_set.first() - canvas.ocr_file_path = f'{manifest.pid}/_*ocr*_/alto.xml' - manifest.image_server.bucket.upload_file(tsv_file_path, f'{manifest.pid}/_*ocr*_/alto.xml') + canvas.ocr_file_path = f"{manifest.pid}/_*ocr*_/alto.xml" + manifest.image_server.bucket.upload_file( + tsv_file_path, f"{manifest.pid}/_*ocr*_/alto.xml" + ) ocr = services.get_ocr(canvas) - assert ocr[0]['content'] == 'MAGNA' - assert ocr[0]['h'] == 164 - assert ocr[0]['w'] == 758 - assert ocr[0]['x'] == 1894 - assert ocr[0]['y'] == 1787 + assert ocr[0]["content"] == "MAGNA" + assert ocr[0]["h"] == 164 + assert ocr[0]["w"] == 758 + assert ocr[0]["x"] == 1894 + assert ocr[0]["y"] == 1787 - @mock_s3 + @mock_aws def test_add_hocr_by_filename(self): - """ Test get_ocr when OCR is hOCR file (by filename). """ + """Test get_ocr when OCR is hOCR file (by filename).""" bucket_name = encode_noid() manifest = ManifestFactory.create( - image_server = ImageServerFactory.create( - storage_service = 's3', + image_server=ImageServerFactory.create( + storage_service="s3", storage_path=bucket_name, - server_base='images.readux.ecds.emory' + server_base="images.readux.ecds.emory", ) ) - self.set_up_mock_s3(manifest) - tsv_file_path = 'apps/iiif/canvases/fixtures/hocr.hocr' + self.set_up_mock_aws(manifest) + tsv_file_path = "apps/iiif/canvases/fixtures/hocr.hocr" canvas = manifest.canvas_set.first() - canvas.ocr_file_path = f'{manifest.pid}/_*ocr*_/hocr.hocr' - manifest.image_server.bucket.upload_file(tsv_file_path, f'{manifest.pid}/_*ocr*_/hocr.hocr') + canvas.ocr_file_path = f"{manifest.pid}/_*ocr*_/hocr.hocr" + manifest.image_server.bucket.upload_file( + tsv_file_path, f"{manifest.pid}/_*ocr*_/hocr.hocr" + ) ocr = services.get_ocr(canvas) - assert ocr[0]['content'] == 'MAGNA' - assert ocr[0]['h'] == 164 - assert ocr[0]['w'] == 758 - assert ocr[0]['x'] == 1894 - assert ocr[0]['y'] == 1787 + assert ocr[0]["content"] == "MAGNA" + assert ocr[0]["h"] == 164 + assert ocr[0]["w"] == 758 + assert ocr[0]["x"] == 1894 + assert ocr[0]["y"] == 1787 - @mock_s3 + @mock_aws def test_add_json_ocr_by_filename(self): - """ Test get_ocr when OCR is JSON file (by filename). """ + """Test get_ocr when OCR is JSON file (by filename).""" bucket_name = encode_noid() manifest = ManifestFactory.create( - image_server = ImageServerFactory.create( - storage_service = 's3', + image_server=ImageServerFactory.create( + storage_service="s3", storage_path=bucket_name, - server_base='images.readux.ecds.emory' + server_base="images.readux.ecds.emory", ) ) - self.set_up_mock_s3(manifest) - tsv_file_path = 'apps/iiif/canvases/fixtures/ocr_words.json' + self.set_up_mock_aws(manifest) + tsv_file_path = "apps/iiif/canvases/fixtures/ocr_words.json" canvas = manifest.canvas_set.first() - canvas.ocr_file_path = f'{manifest.pid}/_*ocr*_/ocr_words.json' + canvas.ocr_file_path = f"{manifest.pid}/_*ocr*_/ocr_words.json" manifest.image_server.bucket.upload_file( - tsv_file_path, f'{manifest.pid}/_*ocr*_/ocr_words.json' + tsv_file_path, f"{manifest.pid}/_*ocr*_/ocr_words.json" ) ocr = services.get_ocr(canvas) - assert ocr[0]['content'] == 'Dope' + assert ocr[0]["content"] == "Dope" def test_none_ocr(self): - """ Test add_positional_ocr when fetched OCR is None. """ + """Test add_positional_ocr when fetched OCR is None.""" ocr = services.add_positional_ocr(self.canvas, None) assert ocr is None def test_none_alto_ocr(self): - """ Test add_positional_ocr when fetched OCR is None. """ + """Test add_positional_ocr when fetched OCR is None.""" ocr = services.parse_alto_ocr(None) assert ocr is None def test_not_tsv(self): - """ Test is_tsv with something that is not TSV """ - not_tsv = 'test string' + """Test is_tsv with something that is not TSV""" + not_tsv = "test string" is_tsv = services.is_tsv(not_tsv) self.assertFalse(is_tsv) @httpretty.httprettified(allow_net_connect=False) def test_ocr_from_oa_annotation(self): - """ Test deserializing OA annotations """ + """Test deserializing OA annotations""" canvas = CanvasFactory.create(manifest=ManifestFactory.create()) for _ in range(0, 12): AnnotationFactory.create(canvas=canvas) anno_list = serialize( - 'annotation_list', + "annotation_list", [canvas], - version='v2', - owners=[UserFactory.create(username='ocr')] + version="v2", + owners=[UserFactory.create(username="ocr")], ) canvas.annotation_set.clear() @@ -443,11 +529,14 @@ def test_ocr_from_oa_annotation(self): httpretty.register_uri( httpretty.GET, - f'https://readux.io/iiif/v2/{canvas.manifest.pid}/list/{canvas.pid}', + f"https://readux.io/iiif/v2/{canvas.manifest.pid}/list/{canvas.pid}", body=anno_list, - content_type="text/json" + content_type="text/json", + ) + # Clear out the annotation because the service will create them. + Annotation.objects.all().delete() + services.add_oa_annotations( + f"https://readux.io/iiif/v2/{canvas.manifest.pid}/list/{canvas.pid}" ) - - services.add_oa_annotations(f'https://readux.io/iiif/v2/{canvas.manifest.pid}/list/{canvas.pid}') canvas.refresh_from_db() assert canvas.annotation_set.count() == 12 diff --git a/apps/iiif/choices.py b/apps/iiif/choices.py index 0a10fe196..0d9b60831 100644 --- a/apps/iiif/choices.py +++ b/apps/iiif/choices.py @@ -6,11 +6,13 @@ class Choices(): """ List of Mime type choices. """ MIMETYPES = ( - ('text/html', 'HTML'), + ('text/html', 'HTML or web page'), ('application/json', 'JSON'), ('application/ld+json', 'JSON-LD'), + ('application/pdf', 'PDF'), + ('text/plain', 'Text'), ('application/xml', 'XML'), - ('text/plan', 'Text'), + ('application/octet-stream', 'Other'), ) """ diff --git a/apps/iiif/kollections/admin.py b/apps/iiif/kollections/admin.py index e2416e11c..571c8aedf 100644 --- a/apps/iiif/kollections/admin.py +++ b/apps/iiif/kollections/admin.py @@ -4,7 +4,8 @@ from django.contrib import admin from import_export import resources from import_export.admin import ImportExportModelAdmin -from django_summernote.admin import SummernoteModelAdmin + +from apps.iiif.manifests.admin import SummernoteMixin from .models import Collection from ..manifests.models import Manifest @@ -37,7 +38,7 @@ def manifest_pid(self, instance): manifest_pid.short_description = 'Manifest Local ID' -class CollectionAdmin(ImportExportModelAdmin, SummernoteModelAdmin, admin.ModelAdmin): +class CollectionAdmin(ImportExportModelAdmin, SummernoteMixin, admin.ModelAdmin): """Django admin configuration for a collection.""" inlines = [ ManifestInline, diff --git a/apps/iiif/kollections/migrations/0023_auto_20220607_1453.py b/apps/iiif/kollections/migrations/0023_auto_20220607_1453.py new file mode 100644 index 000000000..8ab3b875f --- /dev/null +++ b/apps/iiif/kollections/migrations/0023_auto_20220607_1453.py @@ -0,0 +1,33 @@ +# Generated by Django 3.2.13 on 2022-06-07 14:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('kollections', '0022_alter_collection_pid'), + ] + + operations = [ + migrations.AddField( + model_name='collection', + name='modified_at', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AlterField( + model_name='collection', + name='label', + field=models.CharField(default='', max_length=1000), + ), + migrations.AlterField( + model_name='collection', + name='label_de', + field=models.CharField(default='', max_length=1000, null=True), + ), + migrations.AlterField( + model_name='collection', + name='label_en', + field=models.CharField(default='', max_length=1000, null=True), + ), + ] diff --git a/apps/iiif/kollections/migrations/0024_alter_collection_metadata.py b/apps/iiif/kollections/migrations/0024_alter_collection_metadata.py new file mode 100644 index 000000000..05ee2f02e --- /dev/null +++ b/apps/iiif/kollections/migrations/0024_alter_collection_metadata.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.15 on 2023-09-25 20:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('kollections', '0023_auto_20220607_1453'), + ] + + operations = [ + migrations.AlterField( + model_name='collection', + name='metadata', + field=models.JSONField(null=True), + ), + ] diff --git a/apps/iiif/kollections/models.py b/apps/iiif/kollections/models.py index ed88a4949..77ca5e8cb 100644 --- a/apps/iiif/kollections/models.py +++ b/apps/iiif/kollections/models.py @@ -1,10 +1,8 @@ """Django model for Collection app""" import os.path -import uuid from io import BytesIO from PIL import Image from django.db import models -from django.contrib.postgres.fields import JSONField from django.core.files.base import ContentFile import config.settings.local as settings from ..models import IiifBase @@ -19,7 +17,7 @@ class Collection(IiifBase): null=True, help_text="Repository holding the collection. List multiple if the manifests are from multiple collections." # pylint: disable = line-too-long ) - metadata = JSONField(null=True) + metadata = models.JSONField(null=True) upload = models.FileField(upload_to='uploads/', null=True, blank=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) @@ -69,7 +67,7 @@ def autocomplete_label(self): return self.label def __str__(self): - return self.label + return str(self.label) def get_absolute_url(self): """Concatenated property for collection's URI""" @@ -96,48 +94,55 @@ def __make_thumbnail(self): # If height is higher we resize vertically, if not we resize horizontally size = (400, 500) image = Image.open(self.thumbnail) - # Get current and desired ratio for the images - img_ratio = image.size[0] / float(image.size[1]) - ratio = size[0] / float(size[1]) - #The image is scaled/cropped vertically or horizontally depending on the ratio - if ratio > img_ratio: - image = image.resize( - (int(size[0]), int(size[0] * image.size[1] / image.size[0])), - Image.Resampling.LANCZOS) - # Crop in the top, middle or bottom - box = (0, (image.size[1] - size[1]) / 2, image.size[0], (image.size[1] + size[1]) / 2) - image = image.crop(box) - elif ratio < img_ratio: - imageratio = (int(size[1] * image.size[0] / image.size[1]), int(size[1])) - image = image.resize(imageratio, Image.Resampling.LANCZOS) - box = ((image.size[0] - size[0]) / 2, 0, (image.size[0] + size[0]) / 2, image.size[1]) - image = image.crop(box) - else: - image = image.resize((size[0], size[1]), Image.Resampling.LANCZOS) - # If the scale is the same, we do not need to crop - thumb_name, thumb_extension = os.path.splitext(self.thumbnail.name) - thumb_extension = thumb_extension.lower() - - thumb_filename = thumb_name + '_thumb' + thumb_extension - - if thumb_extension in ['.jpg', '.jpeg']: - file_type = 'JPEG' - elif thumb_extension == '.gif': - file_type = 'GIF' - elif thumb_extension == '.png': - file_type = 'PNG' - else: - return False # Unrecognized file type - # Save thumbnail to in-memory file as StringIO - temp_thumb = BytesIO() - image.save(temp_thumb, file_type) - temp_thumb.seek(0) - - # set save=False, otherwise it will run in an infinite loop - self.thumbnail.save(thumb_filename, ContentFile(temp_thumb.read()), save=False) - temp_thumb.close() - return True + # We need to test that this if else works correctly so that new files can be + # uploaded and existing files are not renamed. + if 'thumbnails/' in self.thumbnail.name: + return True + else: + # Get current and desired ratio for the images + img_ratio = image.size[0] / float(image.size[1]) + ratio = size[0] / float(size[1]) + #The image is scaled/cropped vertically or horizontally depending on the ratio + if ratio > img_ratio: + image = image.resize( + (int(size[0]), int(size[0] * image.size[1] / image.size[0])), + Image.Resampling.LANCZOS) + # Crop in the top, middle or bottom + box = (0, (image.size[1] - size[1]) / 2, image.size[0], (image.size[1] + size[1]) / 2) + image = image.crop(box) + elif ratio < img_ratio: + imageratio = (int(size[1] * image.size[0] / image.size[1]), int(size[1])) + image = image.resize(imageratio, Image.Resampling.LANCZOS) + box = ((image.size[0] - size[0]) / 2, 0, (image.size[0] + size[0]) / 2, image.size[1]) + image = image.crop(box) + else: + image = image.resize((size[0], size[1]), Image.Resampling.LANCZOS) + # If the scale is the same, we do not need to crop + thumb_name, thumb_extension = os.path.splitext(self.thumbnail.name) + thumb_extension = thumb_extension.lower() + + thumb_filename = thumb_name + '_thumb' + thumb_extension + + if thumb_extension in ['.jpg', '.jpeg']: + file_type = 'JPEG' + elif thumb_extension == '.gif': + file_type = 'GIF' + elif thumb_extension == '.png': + file_type = 'PNG' + else: + return False # Unrecognized file type + + # Save thumbnail to in-memory file as StringIO + temp_thumb = BytesIO() + image.save(temp_thumb, file_type) + temp_thumb.seek(0) + + # set save=False, otherwise it will run in an infinite loop + # pylint: disable = no-member + self.thumbnail.save(thumb_filename, ContentFile(temp_thumb.read()), save=False) + temp_thumb.close() + return True def __make_header(self): """Private method to generate a thumbnail for the collection. @@ -148,62 +153,68 @@ def __make_header(self): # If height is higher we resize vertically, if not we resize horizontally sizebanner = (1200, 300) forcrop = Image.open(self.header) - # Get current and desired ratio for the images - img_ratio_banner = forcrop.size[0] / float(forcrop.size[1]) - ratio_banner = sizebanner[0] / float(sizebanner[1]) - #The image is scaled/cropped vertically or horizontally depending on the ratio - if ratio_banner > img_ratio_banner: #then it needs to be shorter - forcrop = forcrop.resize( - (int(sizebanner[0]), int(sizebanner[0] * forcrop.size[1] / forcrop.size[0])), - Image.Resampling.LANCZOS) - (width, height) = forcrop.size - # pylint: disable = invalid-name - x = (0) - y = (height - sizebanner[1]) / 2 - w = width - h = (height + sizebanner[1]) / 2 - # pylint: enable = invalid-name - - box = (x, y, w, h) - cropped_image = forcrop.crop(box) - # Crop in the top, middle or bottom - elif ratio_banner < img_ratio_banner: #then it needs to be narrower - imagebannerratio = (int(sizebanner[1] * forcrop.size[0] / forcrop.size[1]), int(sizebanner[1])) - forcrop = forcrop.resize(imagebannerratio, Image.Resampling.LANCZOS) - (width, height) = forcrop.size - # pylint: disable = invalid-name - x = (width - sizebanner[0]) / 2 #crop from middle - y = (0) - w = (width + sizebanner[0]) / 2 - h = height - # pylint: enable = invalid-name - - box = (x, y, w, h) - cropped_image = forcrop.crop(box) - else: - cropped_image = forcrop.resize((sizebanner[0], sizebanner[1]), Image.Resampling.LANCZOS) - # If the scale is the same, we do not need to crop - thename, theextension = os.path.splitext(self.header.name) - theextension = theextension.lower() - - thefilename = thename + '_header' + theextension - - if theextension in ['.jpg', '.jpeg']: - file_type = 'JPEG' - elif theextension == '.gif': - file_type = 'GIF' - elif theextension == '.png': - file_type = 'PNG' - else: - return False # Unrecognized file type - - # Save header to in-memory file as StringIO - temp_header = BytesIO() - cropped_image.save(temp_header, file_type) - temp_header.seek(0) - # set save=False, otherwise it will run in an infinite loop - self.header.save(thefilename, ContentFile(temp_header.read()), save=False) - temp_header.close() - - return True + # We need to test that this if else works correctly so that new files can be uploaded and existing files are not renamed. + if 'headers/' in self.header.name: + return True + else: + # Get current and desired ratio for the images + img_ratio_banner = forcrop.size[0] / float(forcrop.size[1]) + ratio_banner = sizebanner[0] / float(sizebanner[1]) + #The image is scaled/cropped vertically or horizontally depending on the ratio + if ratio_banner > img_ratio_banner: #then it needs to be shorter + forcrop = forcrop.resize( + (int(sizebanner[0]), int(sizebanner[0] * forcrop.size[1] / forcrop.size[0])), + Image.Resampling.LANCZOS) + (width, height) = forcrop.size + # pylint: disable = invalid-name + x = (0) + y = (height - sizebanner[1]) / 2 + w = width + h = (height + sizebanner[1]) / 2 + # pylint: enable = invalid-name + + box = (x, y, w, h) + cropped_image = forcrop.crop(box) + # Crop in the top, middle or bottom + elif ratio_banner < img_ratio_banner: #then it needs to be narrower + imagebannerratio = (int(sizebanner[1] * forcrop.size[0] / forcrop.size[1]), int(sizebanner[1])) + forcrop = forcrop.resize(imagebannerratio, Image.Resampling.LANCZOS) + (width, height) = forcrop.size + # pylint: disable = invalid-name + x = (width - sizebanner[0]) / 2 #crop from middle + y = (0) + w = (width + sizebanner[0]) / 2 + h = height + # pylint: enable = invalid-name + + box = (x, y, w, h) + cropped_image = forcrop.crop(box) + else: + cropped_image = forcrop.resize((sizebanner[0], sizebanner[1]), Image.Resampling.LANCZOS) + # If the scale is the same, we do not need to crop + thename, theextension = os.path.splitext(self.header.name) + theextension = theextension.lower() + + thefilename = thename + '_header' + theextension + + if theextension in ['.jpg', '.jpeg']: + file_type = 'JPEG' + elif theextension == '.gif': + file_type = 'GIF' + elif theextension == '.png': + file_type = 'PNG' + else: + return False # Unrecognized file type + + # Save header to in-memory file as StringIO + temp_header = BytesIO() + cropped_image.save(temp_header, file_type) + temp_header.seek(0) + + # set save=False, otherwise it will run in an infinite loop + # pylint: disable = no-member + self.header.save(thefilename, ContentFile(temp_header.read()), save=False) + temp_header.close() + + return True diff --git a/apps/iiif/manifests/admin.py b/apps/iiif/manifests/admin.py index 64d496621..126cc0fda 100644 --- a/apps/iiif/manifests/admin.py +++ b/apps/iiif/manifests/admin.py @@ -1,4 +1,5 @@ -"""Django admin module for maninfests""" +"""Django admin module for manifests""" + from django.contrib import admin from django.http import HttpResponseRedirect from django.urls.conf import path @@ -6,78 +7,171 @@ from import_export.admin import ImportExportModelAdmin from import_export.widgets import ManyToManyWidget, ForeignKeyWidget from django_summernote.admin import SummernoteModelAdmin -from .models import Manifest, Note, ImageServer +from .models import Manifest, Note, ImageServer, RelatedLink, Language from .forms import ManifestAdminForm -from .views import AddToCollectionsView +from .views import AddToCollectionsView, MetadataImportView from ..kollections.models import Collection + class ManifestResource(resources.ModelResource): """Django admin manifest resource.""" + collection_id = fields.Field( - column_name='collections', - attribute='collections', - widget=ManyToManyWidget(Collection, field='label') + column_name="collections", + attribute="collections", + widget=ManyToManyWidget(Collection, field="label"), ) image_server_link = fields.Field( - column_name='image_server', - attribute='image_server', - widget=ForeignKeyWidget(ImageServer, 'image_server') + column_name="image_server", + attribute="image_server", + widget=ForeignKeyWidget(ImageServer, "image_server"), ) - class Meta: # pylint: disable=too-few-public-methods, missing-class-docstring + + class Meta: # pylint: disable=too-few-public-methods, missing-class-docstring model = Manifest - import_id_fields = ('id',) + import_id_fields = ("id",) fields = ( - 'id', 'pid', 'label', 'summary', 'author', - 'published_city', 'published_date', 'publisher', 'image_server_link' - 'pdf', 'metadata', 'attribution', 'logo', 'license', 'viewingdirection', 'collection_id' + "id", + "pid", + "label", + "summary", + "author", + "searchable", + "published_city", + "published_date", + "publisher", + "image_server_link", + "pdf", + "metadata", + "attribution", + "logo", + "logo_url", + "license", + "viewingdirection", + "collection_id", ) -class ManifestAdmin(ImportExportModelAdmin, SummernoteModelAdmin, admin.ModelAdmin): + +class RelatedLinksInline(admin.TabularInline): + model = RelatedLink + exclude = ("id",) + fields = ( + "link", + "is_structured_data", + "format", + ) + extra = 1 + min_num = 0 + + +class SummernoteMixin(SummernoteModelAdmin): + class Media: + # NOTE: have to include these js and css dependencies for summernote when not using iframe + js = ( + "//code.jquery.com/jquery-3.7.1.min.js", + "//cdn.jsdelivr.net/npm/jquery.ui.widget@1.10.3/jquery.ui.widget.min.js", + ) + css = { + "all": [ + "//cdn.jsdelivr.net/npm/summernote@0.8.20/dist/summernote-lite.min.css" + ], + } + + +class ManifestAdmin(ImportExportModelAdmin, SummernoteMixin, admin.ModelAdmin): """Django admin configuration for manifests""" + resource_class = ManifestResource - exclude = ('id',) - filter_horizontal = ('collections',) - list_display = ('id', 'pid', 'label', 'created_at', 'author', 'published_date', 'published_city', 'publisher') - search_fields = ('id', 'label', 'author', 'published_date') - summernote_fields = ('summary',) + exclude = ("id",) + filter_horizontal = ("collections",) + list_display = ( + "id", + "pid", + "label", + "created_at", + "author", + "published_date", + "published_city", + "publisher", + ) + search_fields = ("id", "pid", "label", "author", "published_date") + summernote_fields = ("summary",) form = ManifestAdminForm - actions = ['add_to_collections_action'] + actions = ["add_to_collections_action"] + inlines = [RelatedLinksInline] + change_list_template = "admin/change_list_override.html" def add_to_collections_action(self, request, queryset): """Action choose manifests to add to collections""" - selected = queryset.values_list('pk', flat=True) - selected_ids = ','.join(str(pk) for pk in selected) - return HttpResponseRedirect(f'add_to_collections/?ids={selected_ids}') - add_to_collections_action.short_description = 'Add selected manifests to collection(s)' + selected = queryset.values_list("pk", flat=True) + selected_ids = ",".join(str(pk) for pk in selected) + return HttpResponseRedirect(f"add_to_collections/?ids={selected_ids}") + + add_to_collections_action.short_description = ( + "Add selected manifests to collection(s)" + ) def get_urls(self): urls = super().get_urls() my_urls = [ path( - 'add_to_collections/', + "add_to_collections/", self.admin_site.admin_view(AddToCollectionsView.as_view()), - {'model_admin': self, }, + { + "model_admin": self, + }, name="AddManifestsToCollections", - ) + ), + path( + "manifest_metadata_import/", + self.admin_site.admin_view(MetadataImportView.as_view()), + { + "model_admin": self, + }, + name="MultiManifestMetadataImport", + ), ] return my_urls + urls + class NoteAdmin(admin.ModelAdmin): """Django admin configuration for a note.""" - class Meta: # pylint: disable=too-few-public-methods, missing-class-docstring + + class Meta: # pylint: disable=too-few-public-methods, missing-class-docstring model = Note + class ImageServerResource(resources.ModelResource): """Django admin ImageServer resource.""" - class Meta: # pylint: disable=too-few-public-methods, missing-class-docstring + + class Meta: # pylint: disable=too-few-public-methods, missing-class-docstring model = ImageServer - fields = ('id', 'server_base', 'storage_service', 'storage_path') + fields = ("id", "server_base", "storage_service", "storage_path") + class ImageServerAdmin(ImportExportModelAdmin, admin.ModelAdmin): """Django admin settings for ImageServer.""" + resource_class = ImageServerResource - list_display = ('server_base',) + list_display = ("server_base",) + + +class LanguageResource(resources.ModelResource): + """Django admin Language resource.""" + + class Meta: + model = Language + fields = ("code", "name") + + +class LanguageAdmin(ImportExportModelAdmin, admin.ModelAdmin): + """Django admin settings for Language.""" + + resource_class = LanguageResource + list_display = ("name", "code") + admin.site.register(Manifest, ManifestAdmin) admin.site.register(Note, NoteAdmin) admin.site.register(ImageServer, ImageServerAdmin) +admin.site.register(Language, LanguageAdmin) diff --git a/apps/ingest/celery.py b/apps/iiif/manifests/celery.py similarity index 79% rename from apps/ingest/celery.py rename to apps/iiif/manifests/celery.py index 0b506b526..bcc7b4864 100644 --- a/apps/ingest/celery.py +++ b/apps/iiif/manifests/celery.py @@ -8,9 +8,9 @@ # set the default Django settings module for the 'celery' program. os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.local') -app = Celery('apps.ingest', result_extended=True) +app = Celery('apps.iiif.manifest', result_extended=True) # Using a string here means the worker will not have to # pickle the object when using Windows. app.config_from_object('django.conf:settings') -app.autodiscover_tasks(lambda: settings.INSTALLED_APPS) +app.autodiscover_tasks(lambda: settings.INSTALLED_APPS) \ No newline at end of file diff --git a/apps/iiif/manifests/documents.py b/apps/iiif/manifests/documents.py index f6f94e7cd..b452cc502 100644 --- a/apps/iiif/manifests/documents.py +++ b/apps/iiif/manifests/documents.py @@ -1,14 +1,20 @@ """Elasticsearch indexing rules for IIIF manifests""" from html import unescape + +from django.conf import settings +from django.db.models.query import Prefetch +from django.utils.html import strip_tags + from django_elasticsearch_dsl import Document, fields from django_elasticsearch_dsl.registries import registry -from elasticsearch_dsl import analyzer -from django.utils.html import strip_tags + +from elasticsearch_dsl import MetaField, Keyword, analyzer from unidecode import unidecode -from apps.iiif.kollections.models import Collection -from .models import Manifest +from apps.iiif.annotations.models import Annotation +from apps.iiif.canvases.models import Canvas +from apps.iiif.manifests.models import Manifest # TODO: Better English stemming (e.g. Rome to match Roman), multilingual stemming. stemmer = analyzer( @@ -23,41 +29,76 @@ class ManifestDocument(Document): """Elasticsearch Document class for IIIF Manifest""" # fields to map explicitly in Elasticsearch + attribution = fields.TextField() authors = fields.KeywordField(multi=True) # only used for faceting/filtering author = fields.TextField() # only used for searching - collections = fields.NestedField(properties={ - "label": fields.KeywordField(), - }) + canvas_set = fields.NestedField( + properties={ + "result": fields.TextField(analyzer=stemmer), + "position": fields.IntegerField(), + "pid": fields.KeywordField(), + } + ) # canvas_set.result = OCR annotation text on each canvas + collections = fields.NestedField(properties={"label": fields.KeywordField()}) date_earliest = fields.DateField() date_latest = fields.DateField() has_pdf = fields.BooleanField() label = fields.TextField(analyzer=stemmer) label_alphabetical = fields.KeywordField() languages = fields.KeywordField(multi=True) + license = fields.TextField() + metadata = fields.NestedField() + pid = fields.KeywordField() + published_city = fields.TextField() + publisher = fields.TextField() summary = fields.TextField(analyzer=stemmer) class Index: """Settings for Elasticsearch""" - name = "manifests" + + name = f"{settings.INDEX_PREFIX}_manifests" class Django: """Settings for automatically pulling data from Django""" + model = Manifest + ignore_signals = True # fields to map dynamically in Elasticsearch fields = [ - "attribution", "created_at", "date_sort_ascending", "date_sort_descending", - "license", - "pid", - "published_city", "published_date", - "publisher", "viewingdirection", ] - related_models = [Collection] + related_models = [Canvas] + + class Meta: + # make Keyword type default for strings, for custom dynamically-mapped facet fields + dynamic_templates = MetaField( + [ + { + "strings": { + "match_mapping_type": "string", + "mapping": Keyword().to_dict(), + } + } + ] + ) + + def should_index_object(self, obj): + """ + Overwriting parent method. + Only index volumes marked 'searchable' + """ + return obj.searchable + + # def get_queryset(self): + # """ + # Overwrite parent method to only include searchable volumes. + # """ + # return self.django.model._default_manager.filter(searchable=True) def prepare_authors(self, instance): """convert authors string into list""" @@ -82,18 +123,70 @@ def prepare_languages(self, instance): return [lang.name for lang in instance.languages.all()] return ["[no language]"] + def prepare_metadata(self, instance): + """use custom metadata settings to prepare metadata field""" + custom_metadata = {} + + if ( + settings + and hasattr(settings, "CUSTOM_METADATA") + and isinstance(settings.CUSTOM_METADATA, dict) + ): + # should be a dict like {meta_key: {"multi": bool, "separator": str}} + for key, opts in filter( + lambda item: item[1].get( + "faceted", False + ), # filter to only faceted fields + settings.CUSTOM_METADATA.items(), + ): + val = None + # each key in CUSTOM_METADATA dict should be a metadata key. + # however, instance.metadata will generally be a list rather than a dict: it's a + # jsonfield that maps to the IIIF manifest metadata field, which is a list + # consisting of dicts like { label: str, value: str } + if isinstance(instance.metadata, list): + # find matching value by "label" == key + for obj in instance.metadata: + if "label" in obj and obj["label"] == key and "value" in obj: + val = obj["value"] + break + elif isinstance(instance.metadata, dict): + # in some cases it may be just a dict, so in that case, use get() + val = instance.metadata.get(key, None) + # should have "multi" bool and if multi is True, "separator" string + if val and opts.get("multi", False) == True: + val = val.split(opts.get("separator", ";")) + custom_metadata[key] = val + + return custom_metadata + def prepare_summary(self, instance): """Strip HTML tags from summary""" return unescape(strip_tags(instance.summary)) def get_queryset(self): """prefetch related to improve performance""" - return super().get_queryset().prefetch_related( - "collections" + return ( + super() + .get_queryset() + .prefetch_related( + "collections", + "image_server", + "languages", + Prefetch( + "canvas_set", + queryset=Canvas.objects.prefetch_related( + Prefetch( + "annotation_set", + queryset=Annotation.objects.select_related("owner"), + ), + ), + ), + ) ) def get_instances_from_related(self, related_instance): - """Retrieving item to index from related collections""" - if isinstance(related_instance, Collection): + """Retrieving item to index from related objects""" + if isinstance(related_instance, Canvas): # many to many relationship - return related_instance.manifests.all() + return related_instance.manifest diff --git a/apps/iiif/manifests/fixtures/metadata-update-language.csv b/apps/iiif/manifests/fixtures/metadata-update-language.csv new file mode 100644 index 000000000..b1c04dc0a --- /dev/null +++ b/apps/iiif/manifests/fixtures/metadata-update-language.csv @@ -0,0 +1,2 @@ +pid,language +pid2,chr \ No newline at end of file diff --git a/apps/iiif/manifests/fixtures/metadata-update-languages.csv b/apps/iiif/manifests/fixtures/metadata-update-languages.csv new file mode 100644 index 000000000..9ab9c65b3 --- /dev/null +++ b/apps/iiif/manifests/fixtures/metadata-update-languages.csv @@ -0,0 +1,2 @@ +pid,languages +pid1,chr;en \ No newline at end of file diff --git a/apps/iiif/manifests/fixtures/metadata-update.csv b/apps/iiif/manifests/fixtures/metadata-update.csv new file mode 100644 index 000000000..9c8ad8796 --- /dev/null +++ b/apps/iiif/manifests/fixtures/metadata-update.csv @@ -0,0 +1,3 @@ +pid,Label,Summary,Author,Published city,Published date,Publisher,Related,ssdl:shortTitle,ssdl:titleOnSource,ssdl:spatialCoverageFast,ssdl:spatialCoverageFastUri +1925-Temple-EMU,The Temple Star,This is a test!,"Unseld, B. C. (Bradley Carl), 1843-1923;Kieffer, Aldine S., 1840-1904","Singer's Glen, VA",1930,Ruebush & Kieffer (Firm),http://sounding-spirit-sftp-upload.s3-website-us-east-1.amazonaws.com/PDF/pilot/1925-Temple-EMU.pdf,The Temple Star,"THE TEMPLE STAR: | —FOR— | SINGING SCHOOLS, CONVENTIONS, CHOIRS, DAY SCHOOLS, | AND MUSICAL SOCIETIES. | —CONTAINING— | THEORETICAL STATEMENTS OF THE PRINCIPLES OF VOCAL MUSIC, | BY B. C. UNSELD, OF THE VIRGINIA NORMAL MUSIC SCHOOL, | GLEES AND SONGS FOR THE SINGING SCHOOL---HYMN TUNES, SABBATH SCHOOL | MUSIC, ANTHEMS AND CHANTS. | Edited by ALDINE S. KIEFFER, Author of ""The Starry Crown,"" Schoolday Singer,"" ""Glad Hosannas,"" etc. | SINGER'S GLEN, VA: | Published BY RUEBUSH, KIEFFER & CO. | (1877.) | Entered according to Act of Congress, in the year 1877, by RUEBUSH, KIEFFER & CO., in the Office of the Librarian of Congress at Washington.",Virginia--Singers Glen,http://id.worldcat.org/fast/1278794 +1829-Chahta-UTL,Chahta Uba Isht Taloa,This is a test!,,"Atlanta, GA",1981,Crocker & Brewster,,,,, \ No newline at end of file diff --git a/apps/iiif/manifests/forms.py b/apps/iiif/manifests/forms.py index 9e2170e63..a6bd07532 100644 --- a/apps/iiif/manifests/forms.py +++ b/apps/iiif/manifests/forms.py @@ -1,9 +1,15 @@ """Django Forms for export.""" + +import csv +from io import StringIO import logging from django import forms from django.contrib.admin import site as admin_site, widgets -from .models import Language, Manifest -from ..canvases.models import Canvas +from django.core.validators import FileExtensionValidator + +from apps.iiif.manifests.models import Manifest +from apps.iiif.canvases.models import Canvas +from .services import normalize_header LOGGER = logging.getLogger(__name__) @@ -12,40 +18,103 @@ # http://stackoverflow.com/questions/3927018/django-how-to-check-if-field-widget-is-checkbox-in-the-template setattr( forms.Field, - 'is_checkbox', - lambda self: isinstance(self.widget, forms.CheckboxInput) + "is_checkbox", + lambda self: isinstance(self.widget, forms.CheckboxInput), ) + class ManifestAdminForm(forms.ModelForm): """Form for adding or changing a manifest""" + class Meta: model = Manifest fields = ( - 'id', 'pid', 'label', 'summary', 'author', - 'published_city', 'published_date_edtf', 'published_date', 'publisher', 'languages', - 'pdf', 'metadata', 'viewingdirection', 'collections', - 'image_server', 'start_canvas', 'attribution', 'logo', 'license', 'scanned_by', 'identifier', 'identifier_uri' + "id", + "pid", + "label", + "summary", + "author", + "published_city", + "published_date_edtf", + "published_date", + "publisher", + "languages", + "pdf", + "metadata", + "viewingdirection", + "collections", + "image_server", + "start_canvas", + "attribution", + "logo", + "logo_url", + "license", + "scanned_by", + "identifier", + "identifier_uri", ) + def __init__(self, *args, **kwargs): super(ManifestAdminForm, self).__init__(*args, **kwargs) if ( - 'instance' in kwargs and - hasattr(kwargs['instance'], 'canvas_set') and kwargs['instance'].canvas_set.exists() + "instance" in kwargs + and hasattr(kwargs["instance"], "canvas_set") + and kwargs["instance"].canvas_set.exists() ): - self.fields['start_canvas'].queryset = kwargs['instance'].canvas_set.all() + self.fields["start_canvas"].queryset = kwargs["instance"].canvas_set.all() else: - self.fields['start_canvas'].queryset = Canvas.objects.none() + self.fields["start_canvas"].queryset = Canvas.objects.none() + class ManifestsCollectionsForm(forms.ModelForm): """Form to add manifests to collections.""" + class Meta: model = Manifest - fields=('collections',) + fields = ("collections",) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.fields['collections'].widget = widgets.RelatedFieldWidgetWrapper( - self.fields['collections'].widget, - self.instance._meta.get_field('collections').remote_field, - admin_site, + self.fields["collections"].widget = widgets.RelatedFieldWidgetWrapper( + self.fields["collections"].widget, + self.instance._meta.get_field("collections").remote_field, + admin_site, ) + + +class ManifestCSVImportForm(forms.Form): + """Form to import a CSV and update the metadata for multiple manifests""" + + csv_file = forms.FileField( + required=True, + validators=[FileExtensionValidator(allowed_extensions=["csv"])], + label="CSV File", + help_text=""" +

Provide a CSV with a pid column, whose value in each row must match + the PID of an existing volume. Additional columns will be used to update the volume's + metadata.

+

Columns matching Manifest model field names will update those + fields directly, and any additional columns will be used to populate the volume's + metadata JSON field.

+ """, + ) + + def clean(self): + # check csv has pid column + super().clean() + csv_file = self.cleaned_data.get("csv_file") + if csv_file: + reader = csv.DictReader( + normalize_header(StringIO(csv_file.read().decode("utf-8"))), + ) + if "pid" not in reader.fieldnames: + self.add_error( + "metadata_spreadsheet", + forms.ValidationError( + """Spreadsheet must have pid column. Check to ensure there + are no stray characters in the header row.""" + ), + ) + # return back to start of file so we can read again + csv_file.seek(0, 0) + return self.cleaned_data diff --git a/apps/iiif/manifests/migrations/0053_auto_20220607_1453.py b/apps/iiif/manifests/migrations/0053_auto_20220607_1453.py new file mode 100644 index 000000000..3ee44bddc --- /dev/null +++ b/apps/iiif/manifests/migrations/0053_auto_20220607_1453.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.13 on 2022-06-07 14:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('manifests', '0052_alter_imageserver_storage_path'), + ] + + operations = [ + migrations.AddField( + model_name='manifest', + name='modified_at', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AlterField( + model_name='manifest', + name='label', + field=models.CharField(default='', max_length=1000), + ), + ] diff --git a/apps/iiif/manifests/migrations/0054_manifest_logo_url.py b/apps/iiif/manifests/migrations/0054_manifest_logo_url.py new file mode 100644 index 000000000..684570f42 --- /dev/null +++ b/apps/iiif/manifests/migrations/0054_manifest_logo_url.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.17 on 2023-02-16 20:43 + +from django.db import migrations, models +from django.conf import settings + +class Migration(migrations.Migration): + + dependencies = [ + ('manifests', '0053_auto_20220607_1453'), + ] + + operations = [ + migrations.AddField( + model_name='manifest', + name='logo_url', + field=models.URLField(blank=True, help_text="URL for thumbnail of holding institution's logo"), + ), + ] diff --git a/apps/iiif/manifests/migrations/0055_alter_manifest_logo_url.py b/apps/iiif/manifests/migrations/0055_alter_manifest_logo_url.py new file mode 100644 index 000000000..10f717df5 --- /dev/null +++ b/apps/iiif/manifests/migrations/0055_alter_manifest_logo_url.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.12 on 2023-04-05 21:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('manifests', '0054_manifest_logo_url'), + ] + + operations = [ + migrations.AlterField( + model_name='manifest', + name='logo_url', + field=models.URLField(blank=True, help_text='A URL in this field will take precedence over any logo file uploaded in the file field. Clear this field (if populated) to use an uploaded logo file instead.'), + ), + ] diff --git a/apps/iiif/manifests/migrations/0056_alter_manifest_metadata.py b/apps/iiif/manifests/migrations/0056_alter_manifest_metadata.py new file mode 100644 index 000000000..3a4d104dd --- /dev/null +++ b/apps/iiif/manifests/migrations/0056_alter_manifest_metadata.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.15 on 2023-09-25 20:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('manifests', '0055_alter_manifest_logo_url'), + ] + + operations = [ + migrations.AlterField( + model_name='manifest', + name='metadata', + field=models.JSONField(blank=True, default=dict), + ), + ] diff --git a/apps/iiif/manifests/migrations/0057_alter_manifest_languages.py b/apps/iiif/manifests/migrations/0057_alter_manifest_languages.py new file mode 100644 index 000000000..275faba85 --- /dev/null +++ b/apps/iiif/manifests/migrations/0057_alter_manifest_languages.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.15 on 2023-09-25 21:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('manifests', '0056_alter_manifest_metadata'), + ] + + operations = [ + migrations.AlterField( + model_name='manifest', + name='languages', + field=models.ManyToManyField(blank=True, help_text='Languages present in the manifest.', to='manifests.Language'), + ), + ] diff --git a/apps/iiif/manifests/migrations/0058_alter_relatedlink.py b/apps/iiif/manifests/migrations/0058_alter_relatedlink.py new file mode 100644 index 000000000..cdb82b69d --- /dev/null +++ b/apps/iiif/manifests/migrations/0058_alter_relatedlink.py @@ -0,0 +1,59 @@ +# Generated by Django 3.2.12 on 2023-12-05 16:16 + +from django.db import migrations, models +import uuid + + +def populate_is_structured_data(apps, schema_editor): + # Data migration to populate RelatedLink.is_structured_data for existing data + RelatedLink = apps.get_model("manifests", "RelatedLink") + rl_set = RelatedLink.objects.all() + for rl in rl_set: + # Assume all existing RelatedLinks are structured data, since they were previously only + # being added in Remote ingests when there was an existing manifest (which is structured + # data) + rl.is_structured_data = True + RelatedLink.objects.bulk_update(rl_set, ["is_structured_data"], batch_size=1000) + + +class Migration(migrations.Migration): + dependencies = [ + ("manifests", "0057_alter_manifest_languages"), + ] + + operations = [ + migrations.AlterField( + model_name="relatedlink", + name="id", + field=models.UUIDField( + default=uuid.uuid4, editable=False, primary_key=True, serialize=False + ), + ), + migrations.AlterField( + model_name="relatedlink", + name="format", + field=models.CharField( + blank=True, + choices=[ + ("text/html", "HTML or web page"), + ("application/json", "JSON"), + ("application/ld+json", "JSON-LD"), + ("application/pdf", "PDF"), + ("text/plain", "Text"), + ("application/xml", "XML"), + ("application/octet-stream", "Other"), + ], + max_length=255, + null=True, + ), + ), + migrations.AddField( + model_name="relatedlink", + name="is_structured_data", + field=models.BooleanField( + default=False, + help_text="True if this link is structured data that should appear in the manifest's 'seeAlso' field; if false, the link will appear in the 'related' field instead. Leave unchecked if unsure.", + ), + ), + migrations.RunPython(populate_is_structured_data, migrations.RunPython.noop), + ] diff --git a/apps/iiif/manifests/migrations/0059_auto_20250113_1401.py b/apps/iiif/manifests/migrations/0059_auto_20250113_1401.py new file mode 100644 index 000000000..0749f6cce --- /dev/null +++ b/apps/iiif/manifests/migrations/0059_auto_20250113_1401.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.23 on 2025-01-13 14:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('manifests', '0058_alter_relatedlink'), + ] + + operations = [ + migrations.AddField( + model_name='manifest', + name='searchable', + field=models.BooleanField(default=True), + ), + migrations.AlterField( + model_name='imageserver', + name='storage_service', + field=models.CharField(choices=[('sftp', 'SFTP'), ('s3', 'S3'), ('remote', 'Remote'), ('local', 'Local')], default='sftp', max_length=10), + ), + ] diff --git a/apps/iiif/manifests/models.py b/apps/iiif/manifests/models.py index 2b817558b..378ddaca6 100644 --- a/apps/iiif/manifests/models.py +++ b/apps/iiif/manifests/models.py @@ -1,10 +1,12 @@ """Django models for IIIF manifests""" -from boto3 import resource + +from os import environ from uuid import uuid4, UUID from json import JSONEncoder +from boto3 import resource +from urllib.parse import urlparse from django.apps import apps from django.db import models -from django.contrib.postgres.fields import JSONField from django.contrib.postgres.search import SearchVectorField, SearchVector from django.contrib.postgres.aggregates import StringAgg from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation @@ -12,42 +14,49 @@ from django.conf import settings from edtf.fields import EDTFField from apps.iiif.manifests.validators import validate_edtf -import config.settings.local as settings from ..choices import Choices from ..kollections.models import Collection -from..models import IiifBase -JSONEncoder_olddefault = JSONEncoder.default # pylint: disable = invalid-name +from ..models import IiifBase +from .tasks import index_manifest_task + +JSONEncoder_olddefault = JSONEncoder.default # pylint: disable = invalid-name + -def JSONEncoder_newdefault(self, o): # pylint: disable = invalid-name +def JSONEncoder_newdefault(self, o): # pylint: disable = invalid-name """This JSONEncoder makes Wagtail autocomplete run - do not delete.""" if isinstance(o, UUID): return str(o) return JSONEncoder_olddefault(self, o) + + JSONEncoder.default = JSONEncoder_newdefault + class ImageServer(models.Model): """Django model for IIIF image server info. Each canvas has one ImageServer""" STORAGE_SERVICES = ( - ('sftp', 'SFTP'), - ('s3', 'S3'), - ('remote', 'Remote'), + ("sftp", "SFTP"), + ("s3", "S3"), + ("remote", "Remote"), + ("local", "Local"), ) id = models.UUIDField(primary_key=True, default=uuid4) server_base = models.CharField( - max_length=255, - default=settings.IIIF_IMAGE_SERVER_BASE + max_length=255, default=settings.IIIF_IMAGE_SERVER_BASE + ) + storage_service = models.CharField( + max_length=10, choices=STORAGE_SERVICES, default="sftp" ) - storage_service = models.CharField(max_length=10, choices=STORAGE_SERVICES, default='sftp') - storage_path = models.CharField(max_length=255, default='') + storage_path = models.CharField(max_length=255, default="") sftp_user = models.CharField(max_length=100, null=True, blank=True) sftp_port = models.IntegerField(default=22) - private_key_path = models.CharField(max_length=500, default='~/.ssh/id_rsa.pem') - path_delineator = models.CharField(max_length=10, default='/') + private_key_path = models.CharField(max_length=500, default="~/.ssh/id_rsa.pem") + path_delineator = models.CharField(max_length=10, default="/") def __str__(self): - return f'{self.server_base}' + return f"{self.server_base}" @property def bucket(self): @@ -57,8 +66,8 @@ def bucket(self): :return: S3 bucket :rtype: boto3.resources.factory.s3.Bucket """ - if self.storage_service == 's3': - s3 = resource('s3') + if self.storage_service == "s3": + s3 = resource("s3") return s3.Bucket(self.storage_path) return None @@ -71,24 +80,30 @@ def sftp_connection(self): :rtype: dict """ return { - 'host': self.server_base, - 'username': self.sftp_user, - 'private_key': self.private_key_path, - 'port': self.sftp_port, - 'default_path': self.storage_path + "host": urlparse(self.server_base).netloc, + "username": self.sftp_user, + "private_key": self.private_key_path, + "port": self.sftp_port, + "default_path": self.storage_path, } + class ValueByLanguage(models.Model): - """ Labels by language. """ + """Labels by language.""" + id = models.UUIDField(primary_key=True, default=uuid4) - language = models.CharField(max_length=16, choices=Choices.LANGUAGES, default=settings.DEFAULT_LANGUAGE) + language = models.CharField( + max_length=16, choices=Choices.LANGUAGES, default=settings.DEFAULT_LANGUAGE + ) content = models.TextField() content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) object_id = models.UUIDField() - content_object = GenericForeignKey('content_type', 'object_id') + content_object = GenericForeignKey("content_type", "object_id") + class Language(models.Model): """Model to store language names and codes for multiple choice fields""" + code = models.CharField(max_length=16, unique=True) name = models.CharField(max_length=255) @@ -97,10 +112,12 @@ class Meta: def __str__(self): """String representation of the language""" - return self.name + return str(self.name) -class ManifestManager(models.Manager): # pylint: disable = too-few-public-methods + +class ManifestManager(models.Manager): # pylint: disable = too-few-public-methods """Model manager for searches.""" + def with_documents(self): """[summary] @@ -109,29 +126,34 @@ def with_documents(self): :return: [description] :rtype: django.db.models.QuerySet """ - vector = SearchVector(StringAgg('canvas__annotation__content', delimiter=' ')) + vector = SearchVector(StringAgg("canvas__annotation__content", delimiter=" ")) return self.get_queryset().annotate(document=vector) + class Manifest(IiifBase): """Model class for IIIF Manifest""" DIRECTIONS = ( - ('left-to-right', 'Left to Right'), - ('right-to-left', 'Right to Left') + ("left-to-right", "Left to Right"), + ("right-to-left", "Right to Left"), ) summary = models.TextField(null=True, blank=True) - author = models.TextField(null=True, blank=True, help_text="Enter multiple entities separated by a semicolon (;).") + author = models.TextField( + null=True, + blank=True, + help_text="Enter multiple entities separated by a semicolon (;).", + ) published_city = models.TextField( null=True, blank=True, - help_text="Enter multiple entities separated by a semicolon (;)." + help_text="Enter multiple entities separated by a semicolon (;).", ) published_date = models.CharField( "Published date (display)", max_length=255, null=True, blank=True, - help_text="Used for display only." + help_text="Used for display only.", ) published_date_edtf = models.CharField( # Character field editable in admin "Published date (EDTF)", @@ -145,11 +167,11 @@ class Manifest(IiifBase): ) date_edtf = EDTFField( # Read-only EDTF field that handles fuzzy date calculations "Date of publication (EDTF)", - natural_text_field='published_date_edtf', - lower_fuzzy_field='date_earliest', - upper_fuzzy_field='date_latest', - lower_strict_field='date_sort_ascending', - upper_strict_field='date_sort_descending', + natural_text_field="published_date_edtf", + lower_fuzzy_field="date_earliest", + upper_fuzzy_field="date_latest", + lower_strict_field="date_sort_ascending", + upper_strict_field="date_sort_descending", blank=True, null=True, ) @@ -159,49 +181,76 @@ class Manifest(IiifBase): # use for sorting date_sort_ascending = models.FloatField(blank=True, null=True) date_sort_descending = models.FloatField(blank=True, null=True) - publisher = models.TextField(null=True, blank=True, help_text="Enter multiple entities separated by a semicolon (;).") - languages = models.ManyToManyField(Language, help_text="Languages present in the manifest.", blank=True, null=True) + publisher = models.TextField( + null=True, + blank=True, + help_text="Enter multiple entities separated by a semicolon (;).", + ) + languages = models.ManyToManyField( + Language, help_text="Languages present in the manifest.", blank=True + ) attribution = models.CharField( max_length=255, null=True, blank=True, default="Emory University Libraries", - help_text="The institution holding the manifest" + help_text="The institution holding the manifest", ) logo = models.ImageField( - upload_to='logos/', - null=True, blank=True, + upload_to="logos/", + null=True, + blank=True, default="logos/lits-logo-web.png", - help_text="Upload the Logo of the institution holding the manifest." + help_text="Upload the Logo of the institution holding the manifest.", + ) + logo_url = models.URLField( + blank=True, + help_text="A URL in this field will take precedence over any logo file uploaded in the file field. Clear this field (if populated) to use an uploaded logo file instead.", ) license = models.CharField( max_length=255, null=True, blank=True, default="https://creativecommons.org/publicdomain/zero/1.0/", - help_text="Only enter a URI to a license statement." + help_text="Only enter a URI to a license statement.", ) scanned_by = models.CharField(max_length=255, null=True, blank=True) - identifier = models.CharField(max_length=255, null=True, blank=True, help_text="Call number or other unique id.") - identifier_uri = models.URLField(null=True, blank=True, help_text="Only enter a link to a catalog record.") - collections = models.ManyToManyField(Collection, blank=True, related_name='manifests') - pdf = models.URLField(null=True, blank=True, help_text="Enter a link to an online pdf.") - metadata = JSONField(default=dict, blank=True) - viewingdirection = models.CharField(max_length=13, choices=DIRECTIONS, default="left-to-right") + identifier = models.CharField( + max_length=255, + null=True, + blank=True, + help_text="Call number or other unique id.", + ) + identifier_uri = models.URLField( + null=True, blank=True, help_text="Only enter a link to a catalog record." + ) + collections = models.ManyToManyField( + Collection, blank=True, related_name="manifests" + ) + pdf = models.URLField( + null=True, blank=True, help_text="Enter a link to an online pdf." + ) + metadata = models.JSONField(default=dict, blank=True) + viewingdirection = models.CharField( + max_length=13, choices=DIRECTIONS, default="left-to-right" + ) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) - autocomplete_search_field = 'label' + autocomplete_search_field = "label" # TODO: This has to be removed/redone before we upgrade to Django 3 search_vector = SearchVectorField(null=True, editable=False) - image_server = models.ForeignKey(ImageServer, on_delete=models.DO_NOTHING, null=True) + image_server = models.ForeignKey( + ImageServer, on_delete=models.DO_NOTHING, null=True + ) # objects = ManifestManager() start_canvas = models.ForeignKey( - 'canvases.Canvas', + "canvases.Canvas", on_delete=models.SET_NULL, - related_name='start_canvas', + related_name="start_canvas", blank=True, - null=True + null=True, ) + searchable = models.BooleanField(default=True) def get_absolute_url(self): """Absolute URL for manifest @@ -209,7 +258,7 @@ def get_absolute_url(self): :return: Manifest's absolute URL :rtype: str """ - return '{h}/volume/{p}'.format(h=settings.HOSTNAME, p=self.pid) + return f"{settings.HOSTNAME}/volume/{self.pid}" def get_volume_url(self): """Convenience method for IIIF qualified URL. @@ -217,33 +266,35 @@ def get_volume_url(self): :return: IIIF manifest URL :rtype: str """ - return '{h}/volume/{p}/page/all'.format(h=settings.HOSTNAME, p=self.pid) + return f"{settings.HOSTNAME}/volume/{self.pid}/page/all" - class Meta: # pylint: disable = too-few-public-methods, missing-class-docstring - ordering = ['published_date'] + class Meta: # pylint: disable = too-few-public-methods, missing-class-docstring + ordering = ["published_date"] # indexes = [GinIndex(fields=['search_vector'])] @property - def publisher_bib(self): - """Concatenated property for bib citation. + def authors(self): + """Convert authors string into list - :rtype: str + :return: List of authors or ["[no author]"] if author field is empty + :rtype: list """ - return '{c} : {p}'.format(c=self.published_city, p=self.publisher) + if self.author: + return [s.strip() for s in self.author.split(";")] + return ["[no author]"] @property - def thumbnail_logo(self): - """Thumbnail of holding institution's logo + def publisher_bib(self): + """Concatenated property for bib citation. - :return: URL for logo thumbnail. :rtype: str """ - return '{h}/media/{l}'.format(h=settings.HOSTNAME, l=self.logo) + return f"{self.published_city} : {self.publisher}" @property def baseurl(self): """Convenience method to provide the base URL for a manifest.""" - return "%s/iiif/v2/%s" % (settings.HOSTNAME, self.pid) + return f"{settings.HOSTNAME}/iiif/v2/{self.pid}" @property def related_links(self): @@ -252,10 +303,58 @@ def related_links(self): :return: List of links related to Manifest :rtype: list """ - links = [link.link for link in self.relatedlink_set.all()] - links.append(self.get_volume_url()) + links = [ + ( + { + "@id": link.link, + "format": link.format, + } + if link.format + else link.link + ) + for link in self.relatedlink_set.all() + ] + links.append({"@id": self.get_volume_url(), "format": "text/html"}) return links + @property + def external_links(self): + """Dict of lists of external links for display on volume pages + + :return: Dict of external links ("related" and "seeAlso") + :rtype: dict + """ + # exclude internal links from related link set + related_links = self.relatedlink_set.exclude(link__icontains=settings.HOSTNAME) + # dict keys correspond to headings in sidebar + return { + "see_also": [ + link.link for link in related_links if link.is_structured_data + ], + "related": [ + link.link for link in related_links if not link.is_structured_data + ], + } + + @property + def see_also_links(self): + """List of links for IIIF v2 'seeAlso' field (structured data). + + :return: List of links to structured data describing Manifest + :rtype: list + """ + return [ + ( + { + "@id": link.link, + "format": link.format, + } + if link.format + else link.link + ) + for link in self.relatedlink_set.filter(is_structured_data=True) + ] + # TODO: Is this needed? It doesn't seem to be called anywhere. # Could we just use the label as is? def autocomplete_label(self): @@ -263,10 +362,10 @@ def autocomplete_label(self): :rtype: str """ - return self.label + return self.pid def __str__(self): - return self.label + return f"{self.pid} - title: {self.label}" # FIXME: This creates a circular dependency - Importing UserAnnotation here. # Furthermore, we shouldn't have any of the IIIF apps depend on Readux. Need @@ -275,34 +374,67 @@ def user_annotation_count(self, user=None): if user is None: return None from apps.readux.models import UserAnnotation - return UserAnnotation.objects.filter(owner=user).filter(canvas__manifest=self).count() - - #update search_vector every time the entry updates - def save(self, *args, **kwargs): # pylint: disable = arguments-differ - if not self._state.adding and 'pid' in self.get_dirty_fields() and self.image_server and self.image_server.storage_service == 's3': + return ( + UserAnnotation.objects.filter(owner=user) + .filter(canvas__manifest=self) + .count() + ) + + # update search_vector every time the entry updates + def save(self, *args, **kwargs): # pylint: disable = arguments-differ + + if ( + not self._state.adding + and "pid" in self.get_dirty_fields() + and self.image_server + and self.image_server.storage_service == "s3" + ): self.__rename_s3_objects() - super().save(*args, **kwargs) + # Remove the manifest from the index if searchable was changed to False. + # This only runs on update. Creating new manifests with searchable set + # to False throws an error because it has not been indexed yet. This + # is despite the search returning a result. + if self.searchable is False and self._state.adding is False: + from .documents import ManifestDocument + + response = ManifestDocument().search().query("match", pid=self.pid) + + if response.count() > 0: + ManifestDocument().update(self, action="delete") - Canvas = apps.get_model('canvases.canvas') try: - if self.start_canvas is None and hasattr(self, 'canvas_set') and self.canvas_set.exists(): - print([c.position] for c in self.canvas_set.all()) - self.start_canvas = self.canvas_set.all().order_by('position').first() - self.save() - except Canvas.DoesNotExist: + canvas_model = apps.get_model("canvases.canvas") + if ( + self.start_canvas is None + and hasattr(self, "canvas_set") + and self.canvas_set.exists() + ): + self.start_canvas = self.canvas_set.all().order_by("position").first() + canvas_model.objects.filter(manifest=self).update( + is_starting_page=False + ) + canvas_model.objects.filter(pk=self.start_canvas.id).update( + is_starting_page=True + ) + except canvas_model.DoesNotExist: self.start_canvas = None - # if 'update_fields' not in kwargs or 'search_vector' not in kwargs['update_fields']: - # instance = self._meta.default_manager.with_documents().get(pk=self.pk) - # instance.search_vector = instance.document - # instance.save(update_fields=['search_vector']) + super().save(*args, **kwargs) + + for collection in self.collections.all(): # pylint: disable = no-member + collection.modified_at = self.modified_at + collection.save() + if environ["DJANGO_ENV"] != "test": # pragma: no cover + index_manifest_task.apply_async(args=[str(self.id)]) + else: + index_manifest_task(str(self.id)) def delete(self, *args, **kwargs): """ - When a manifest is delted, the related canvas objects are deleted (`on_delete`=models.CASCADE). + When a manifest is deleted, the related canvas objects are deleted (`on_delete`=models.CASCADE). However, the `delete` method is not called on the canvas objects. We need to do that so the files can be cleaned up. https://docs.djangoproject.com/en/3.2/ref/models/fields/#django.db.models.CASCADE @@ -312,27 +444,50 @@ def delete(self, *args, **kwargs): super().delete(*args, **kwargs) + # if environ["DJANGO_ENV"] != 'test': # pragma: no cover + # de_index_manifest_task.apply_async(args=[str(self.id)]) + # else: + # de_index_manifest_task(str(self.id)) + def __rename_s3_objects(self): - original_pid = self.get_dirty_fields()['pid'] - keys = [f.key for f in self.image_server.bucket.objects.filter(Prefix=f'{original_pid}/')] + original_pid = self.get_dirty_fields()["pid"] + keys = [ + f.key + for f in self.image_server.bucket.objects.filter(Prefix=f"{original_pid}/") + ] for key in keys: obj = self.image_server.bucket.Object(key.replace(original_pid, self.pid)) - obj.copy({ 'Bucket': self.image_server.storage_path, 'Key': key }) + obj.copy({"Bucket": self.image_server.storage_path, "Key": key}) self.image_server.bucket.Object(key).delete() + # TODO: is this needed? class Note(models.Model): """Note for manifest""" + label = models.CharField(max_length=255) - language = models.CharField(max_length=10, default='en') + language = models.CharField(max_length=10, default="en") manifest = models.ForeignKey(Manifest, null=True, on_delete=models.CASCADE) + class RelatedLink(models.Model): - """ Links to related resources """ - id = models.UUIDField(primary_key=True, default=uuid4) + """Links to related resources""" + + id = models.UUIDField(primary_key=True, default=uuid4, editable=False) link = models.CharField(max_length=255) - data_type = models.CharField(max_length=255, default='Dataset') + data_type = models.CharField( + max_length=255, + default="Dataset", + ) + is_structured_data = models.BooleanField( + default=False, + help_text="True if this link is structured data that should appear in the manifest's " + + "'seeAlso' field; if false, the link will appear in the 'related' field instead. Leave " + + "unchecked if unsure.", + ) label = GenericRelation(ValueByLanguage) - format = models.CharField(max_length=255, choices=Choices.MIMETYPES, blank=True, null=True) + format = models.CharField( + max_length=255, choices=Choices.MIMETYPES, blank=True, null=True + ) profile = models.CharField(max_length=255, blank=True, null=True) manifest = models.ForeignKey(Manifest, on_delete=models.CASCADE) diff --git a/apps/iiif/manifests/services.py b/apps/iiif/manifests/services.py new file mode 100644 index 000000000..15a8ebb92 --- /dev/null +++ b/apps/iiif/manifests/services.py @@ -0,0 +1,326 @@ +"""Module of service classes and methods for ingest.""" + +import itertools +import re +import logging +from mimetypes import guess_type +from urllib.parse import unquote, urlparse + +from django.apps import apps +from tablib.core import Dataset + +from .models import Manifest, RelatedLink, Language + +LOGGER = logging.getLogger(__name__) + + +def clean_metadata(metadata): + """Normalize names of fields that align with Manifest fields. + + :param metadata: + :type metadata: tablib.Dataset + :return: Dictionary with keys matching Manifest fields + :rtype: dict + """ + fields = [f.name for f in Manifest._meta.get_fields()] + metadata = { + ( + key.casefold().replace(" ", "_") + if key.casefold().replace(" ", "_") in fields + else key + ): value + for key, value in metadata.items() + } + + for key in metadata.keys(): + if key != "metadata" and isinstance(metadata[key], list): + if isinstance(metadata[key][0], dict): + for meta_key in metadata[key][0].keys(): + if "value" in meta_key: + metadata[key] = metadata[key][0][meta_key] + else: + metadata[key] = ", ".join(metadata[key]) + + return metadata + + +def create_related_links(manifest, related_str): + """ + Create RelatedLink objects from supplied related links string and associate each with supplied + Manifest. String should consist of semicolon-separated URLs. + :param manifest: + :type manifest: iiif.manifest.models.Manifest + :param related_str: + :type related_str: str + :rtype: None + """ + for link in related_str.split(";"): + (link_format, _) = guess_type(link) + RelatedLink.objects.create( + manifest=manifest, + link=link, + format=link_format + or "text/html", # assume web page if MIME type cannot be determined + is_structured_data=False, # assume this is not meant for seeAlso + ) + + +def set_metadata(manifest, metadata): + """ + Update Manifest.metadata using supplied metadata dict + :param manifest: + :type manifest: iiif.manifest.models.Manifest + :param metadata: + :type metadata: dict + :rtype: None + """ + fields = [f.name for f in Manifest._meta.get_fields()] + for key, value in metadata.items(): + casefolded_key = key.casefold().replace(" ", "_") + if casefolded_key == "related": + # add RelatedLinks from metadata spreadsheet key "related" + create_related_links(manifest, value) + elif casefolded_key.startswith("language"): + for language in value.split(";"): + try: + manifest.languages.add(Language.objects.get(code=language)) + except Language.DoesNotExist: + LOGGER.warning(f"Language code {language} not found.") + elif casefolded_key in fields: + setattr(manifest, casefolded_key, value) + else: + # all other keys go into Manifest.metadata JSONField + if isinstance(manifest.metadata, list): + found_index = next( + ( + idx + for (idx, d) in enumerate(manifest.metadata) + if "label" in d and d["label"] == key + ), + None, + ) + if found_index: + # if value with this label exists, pop and re-insert + manifest.metadata.pop(found_index) + manifest.metadata.insert( + found_index, {"label": key, "value": value} + ) + else: + # if not, add label and value to end of list + manifest.metadata.append({"label": key, "value": value}) + elif isinstance(manifest.metadata, dict): + # convert to list of {label, value} as expected by iiif spec + manifest.metadata = [ + *[{"label": k, "value": v} for (k, v) in manifest.metadata.items()], + {"label": key, "value": value}, + ] + else: + # instantiate as list + manifest.metadata = [{"label": key, "value": value}] + manifest.save() + + +# def create_manifest(ingest): +# """ +# Create or update a Manifest from supplied metadata and images. +# :return: New or updated Manifest with supplied `pid` +# :rtype: iiif.manifest.models.Manifest +# """ +# manifest = None +# # Make a copy of the metadata so we don't extract it over and over. +# try: +# if not bool(ingest.manifest) or ingest.manifest is None: +# ingest.open_metadata() + +# metadata = dict(ingest.metadata) +# except TypeError: +# metadata = None +# if metadata: +# if "pid" in metadata: +# manifest, _ = Manifest.objects.get_or_create( +# pid=metadata["pid"].replace("_", "-") +# ) +# else: +# manifest = Manifest.objects.create() +# set_metadata(manifest, metadata) +# else: +# manifest = Manifest() + +# manifest.image_server = ingest.image_server + +# # This was giving me a 'django.core.exceptions.AppRegistryNotReady: Models aren't loaded yet' error. +# Remote = apps.get_model("ingest.remote") + +# # Ensure that manifest has an ID before updating the M2M relationship +# manifest.save() +# if not isinstance(ingest, Remote): +# manifest.refresh_from_db() +# manifest.collections.set(ingest.collections.all()) +# # Save again once relationship is set +# manifest.save() +# else: +# RelatedLink( +# manifest=manifest, +# link=ingest.remote_url, +# format="application/ld+json", +# is_structured_data=True, +# ).save() + +# return manifest + + +def extract_image_server(canvas): + """Determines the IIIF image server URL for a given IIIF Canvas + + :param canvas: IIIF Canvas + :type canvas: dict + :return: IIIF image server URL + :rtype: str + """ + url = urlparse(canvas["images"][0]["resource"]["service"]["@id"]) + parts = url.path.split("/") + parts.pop() + base_path = "/".join(parts) + host = url.hostname + if url.port is not None: + host = "{h}:{p}".format(h=url.hostname, p=url.port) + return "{s}://{h}{p}".format(s=url.scheme, h=host, p=base_path) + + +def parse_iiif_v2_manifest(data): + """Parse IIIF Manifest based on v2.1.1 or the presentation API. + https://iiif.io/api/presentation/2.1 + + :param data: IIIF Presentation v2.1.1 manifest + :type data: dict + :return: Extracted metadata + :rtype: dict + """ + properties = {} + manifest_data = [] + + if "metadata" in data: + manifest_data.append({"metadata": data["metadata"]}) + + for iiif_metadata in [ + {prop["label"]: prop["value"]} for prop in data["metadata"] + ]: + properties.update(iiif_metadata) + + # Sometimes, the label appears as a list. + if "label" in data.keys() and isinstance(data["label"], list): + data["label"] = " ".join(data["label"]) + + manifest_data.extend( + [{prop: data[prop]} for prop in data if isinstance(data[prop], str)] + ) + + for datum in manifest_data: + properties.update(datum) + + uri = urlparse(data["@id"]) + + if not uri.query: + properties["pid"] = uri.path.split("/")[-2] + else: + properties["pid"] = uri.query + + if "description" in data.keys(): + if isinstance(data["description"], list): + if isinstance(data["description"][0], dict): + en = [ + lang["@value"] + for lang in data["description"] + if lang["@language"] == "en" + ] + properties["summary"] = ( + data["description"][0]["@value"] if not en else en[0] + ) + else: + properties["summary"] = data["description"][0] + else: + properties["summary"] = data["description"] + + if "logo" in properties: + properties["logo_url"] = properties["logo"] + properties.pop("logo") + + manifest_metadata = clean_metadata(properties) + + return manifest_metadata + + +def parse_iiif_v2_canvas(canvas): + """ """ + canvas_id = canvas["@id"].split("/") + pid = canvas_id[-1] if canvas_id[-1] != "canvas" else canvas_id[-2] + + service = urlparse(canvas["images"][0]["resource"]["service"]["@id"]) + resource = unquote(service.path.split("/").pop()) + + summary = canvas["description"] if "description" in canvas.keys() else "" + label = canvas["label"] if "label" in canvas.keys() else "" + return { + "pid": pid, + "height": canvas["height"], + "width": canvas["width"], + "summary": summary, + "label": label, + "resource": resource, + } + + +def get_metadata_from(files): + """ + Find metadata file in uploaded files. + :return: If metadata file exists, returns the values. If no file, returns None. + :rtype: list or None + """ + metadata = None + for file in files: + if metadata is not None: + continue + if "zip" in guess_type(file.name)[0]: + continue + if "metadata" in file.name.casefold(): + stream = file.read() + if ( + "csv" in guess_type(file.name)[0] + or "tab-separated" in guess_type(file.name)[0] + ): + metadata = Dataset().load(stream.decode("utf-8-sig"), format="csv").dict + else: + metadata = Dataset().load(stream).dict + return metadata + + +def get_associated_meta(all_metadata, file): + """ + Associate metadata with filename. + :return: If a matching filename is found, returns the row as dict, + with generated pid. Otherwise, returns {}. + :rtype: dict + """ + file_meta = {} + extless_filename = file.name[0 : file.name.rindex(".")] + for meta_dict in all_metadata: + metadata_found_filename = None + for key, val in meta_dict.items(): + if key.casefold() == "filename": + metadata_found_filename = val + # Match filename column, case-sensitive, against filename + if metadata_found_filename and metadata_found_filename in ( + extless_filename, + file.name, + ): + file_meta = meta_dict + return file_meta + + +def normalize_header(iterator): + """Normalize the header row of a metadata CSV""" + # ignore unicode characters and strip whitespace + header_row = next(iterator).encode("ascii", "ignore").decode().strip() + # lowercase the word "pid" in this row so we can access it easily + header_row = re.sub(r"[Pp][Ii][Dd]", lambda m: m.group(0).casefold(), header_row) + return itertools.chain([header_row], iterator) diff --git a/apps/iiif/manifests/tasks.py b/apps/iiif/manifests/tasks.py new file mode 100644 index 000000000..b149050b8 --- /dev/null +++ b/apps/iiif/manifests/tasks.py @@ -0,0 +1,35 @@ +from celery import Celery +from django.conf import settings + +app = Celery('apps.iiif.manifests', result_extended=True) +app.config_from_object('django.conf:settings') +app.autodiscover_tasks(lambda: settings.INSTALLED_APPS) + +@app.task(name='index_manifest', autoretry_for=(Exception,), retry_backoff=True, max_retries=20) +def index_manifest_task(manifest_id): + """Background task index Manifest after save. + + :param manifest_id: Primary key for .models.Manifest object + :type manifest_id: UUID + + """ + from .models import Manifest + from .documents import ManifestDocument + index = ManifestDocument() + manifest = Manifest.objects.get(pk=manifest_id) + index.update(manifest, True, 'index') + + +@app.task(name='de-index_manifest', autoretry_for=(Exception,), retry_backoff=True, max_retries=20) +def de_index_manifest_task(manifest_id): + """Background task to de-index Manifest after delete. + + :param manifest_id: Primary key for .models.Manifest object + :type manifest_id: UUID + + """ + from .models import Manifest + from .documents import ManifestDocument + index = ManifestDocument() + manifest = Manifest.objects.get(pk=manifest_id) + index.update(manifest, True, 'delete') diff --git a/apps/iiif/manifests/templates/admin/change_list_override.html b/apps/iiif/manifests/templates/admin/change_list_override.html new file mode 100644 index 000000000..1f76d59dc --- /dev/null +++ b/apps/iiif/manifests/templates/admin/change_list_override.html @@ -0,0 +1,26 @@ +{% extends "admin/change_list.html" %} + +{% load i18n %} + +{# adapted from django-import-export #} +{% block object-tools %} + +{% endblock %} diff --git a/apps/iiif/manifests/templates/manifest_metadata_import.html b/apps/iiif/manifests/templates/manifest_metadata_import.html new file mode 100644 index 000000000..7191c6630 --- /dev/null +++ b/apps/iiif/manifests/templates/manifest_metadata_import.html @@ -0,0 +1,30 @@ +{% extends "admin/base_site.html" %} +{% load i18n admin_urls %} +{% load static %} + +{% block extrastyle %} + +{% endblock %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +
{% csrf_token %} +
+
+ {{ form }} +
+
+ {% block submit_buttons_bottom %} +
+ +
+ {% endblock %} +
+{% endblock %} diff --git a/apps/iiif/manifests/tests/factories.py b/apps/iiif/manifests/tests/factories.py index 4e395c839..26b0fd242 100644 --- a/apps/iiif/manifests/tests/factories.py +++ b/apps/iiif/manifests/tests/factories.py @@ -1,38 +1,54 @@ """Factory to create Manifests for Tests""" -from random import randrange + +from datetime import datetime from factory.django import DjangoModelFactory, ImageField from factory import Faker, RelatedFactory, SubFactory -from time import time from apps.utils.noid import encode_noid -from ..models import Manifest, ImageServer +from ..models import Manifest, ImageServer, Language from ...canvases.tests.factories import CanvasFactory + +class LanguageFactory(DjangoModelFactory): + """Factory for language objects.""" + + class Meta: + model = Language + + class ImageServerFactory(DjangoModelFactory): - server_base = 'http://images.ecds.emory.edu' - storage_service = 'sftp' - storage_path = 'readux' + server_base = "http://images.ecds.emory.edu" + storage_service = "sftp" + storage_path = "readux" - class Meta: # pylint: disable=too-few-public-methods, missing-class-docstring + class Meta: # pylint: disable=too-few-public-methods, missing-class-docstring model = ImageServer + class ManifestFactory(DjangoModelFactory): """Creates a Manifest object for testing.""" + pid = encode_noid() label = Faker("name") - canvase = RelatedFactory(CanvasFactory, 'manifest') - logo = ImageField(from_path='apps/iiif/canvases/tests/ecds.png') + author = Faker("name") + publisher = Faker("company") + published_city = Faker("city") + published_date = Faker("date") + canvases = RelatedFactory(CanvasFactory, "manifest") + logo = ImageField(from_path="apps/iiif/canvases/tests/ecds.png") image_server = SubFactory(ImageServerFactory) - summary = Faker('sentence') + summary = Faker("sentence") - class Meta: # pylint: disable=too-few-public-methods, missing-class-docstring + class Meta: # pylint: disable=too-few-public-methods, missing-class-docstring model = Manifest + class EmptyManifestFactory(DjangoModelFactory): """Creates a Manifest object for testing.""" + pid = encode_noid() label = Faker("name") - logo = ImageField(from_path='apps/iiif/canvases/tests/ecds.png') + logo = ImageField(from_path="apps/iiif/canvases/tests/ecds.png") image_server = SubFactory(ImageServerFactory) - class Meta: # pylint: disable=too-few-public-methods, missing-class-docstring - model = Manifest \ No newline at end of file + class Meta: # pylint: disable=too-few-public-methods, missing-class-docstring + model = Manifest diff --git a/apps/iiif/manifests/tests/test_documents.py b/apps/iiif/manifests/tests/test_documents.py index 7be4f88ed..e1965564b 100644 --- a/apps/iiif/manifests/tests/test_documents.py +++ b/apps/iiif/manifests/tests/test_documents.py @@ -8,11 +8,13 @@ from django_elasticsearch_dsl.test import ESTestCase from apps.iiif.kollections.models import Collection from apps.iiif.manifests.documents import ManifestDocument -from apps.iiif.manifests.models import Language +from apps.iiif.manifests.models import Language, Manifest from apps.iiif.manifests.tests.factories import ManifestFactory + class ManifestDocumentTest(ESTestCase, TestCase): """Tests for IIIF manifest indexing""" + def setUp(self): super().setUp() self.doc = ManifestDocument() @@ -22,7 +24,7 @@ def setUp(self): def test_prepare_authors(self): """Test authors returned as array instead of string""" # test no author - manifest = ManifestFactory.create() + manifest = ManifestFactory.create(author=None) assert self.doc.prepare_authors(instance=manifest) == ["[no author]"] # test empty string manifest.author = "" @@ -33,12 +35,16 @@ def test_prepare_authors(self): # test semicolon separation manifest.author = "test author;example author;ben" assert self.doc.prepare_authors(instance=manifest) == [ - "test author", "example author", "ben" + "test author", + "example author", + "ben", ] # test whitespace stripping manifest.author = "test author; example author; ben" assert self.doc.prepare_authors(instance=manifest) == [ - "test author", "example author", "ben" + "test author", + "example author", + "ben", ] def test_prepare_has_pdf(self): @@ -58,7 +64,7 @@ def test_prepare_label_alphabetical(self): manifest = ManifestFactory.create(label="éclair") assert self.doc.prepare_label_alphabetical(instance=manifest) == "eclair" # should only return the first 64 characters - random_100_chars = ''.join(random.choices(string.ascii_letters, k=100)) + random_100_chars = "".join(random.choices(string.ascii_letters, k=100)) manifest.label = random_100_chars label_alphabetical = self.doc.prepare_label_alphabetical(instance=manifest) assert label_alphabetical == random_100_chars[0:64] @@ -97,20 +103,50 @@ def test_get_queryset(self): manifest.collections.add(collection) # get prefetched objects cache for elastic queryset qs_manifest = self.doc.get_queryset().first() - prefetched = qs_manifest._prefetched_objects_cache # pylint: disable=protected-access + prefetched = ( + qs_manifest._prefetched_objects_cache + ) # pylint: disable=protected-access # should be a dict of prefetched relations assert isinstance(prefetched, dict) # should have one collection, which is the above collection - assert prefetched['collections'].count() == 1 - assert prefetched['collections'].first().pk == collection.pk + assert prefetched["collections"].count() == 1 + assert prefetched["collections"].first().pk == collection.pk - def test_get_instances_from_related(self): - """Should get manifests from related collections""" + def test_exclude_searchable_false(self): + """Do not index objects when searchable is False.""" + searchable_manifest = ManifestFactory.create() + excluded_manifest = ManifestFactory.create(searchable=False) + self.doc.update(searchable_manifest) + self.doc.update(excluded_manifest) + response = self.doc.search() + results = response.to_queryset() + assert searchable_manifest.pid in [m.pid for m in results] + assert excluded_manifest.pid not in [m.pid for m in results] + + def test_remove_manifest_from_index_searchable_false(self): + """A manifest should be removed when searchable changes to false""" + searchable_manifest = ManifestFactory.create() manifest = ManifestFactory.create() - # connect a collection and manifest - collection = Collection(label="test collection") - collection.save() - manifest.collections.add(collection) - instances = self.doc.get_instances_from_related(related_instance=collection) - # should get the manifest related to this collection - self.assertQuerysetEqual(instances, [manifest]) + self.doc.update(manifest) + self.doc.update(searchable_manifest) + response = self.doc.search() + results = response.to_queryset() + assert manifest.pid in [m.pid for m in results] + manifest.searchable = False + manifest.save() + manifest.refresh_from_db() + response = self.doc.search() + results = response.to_queryset() + assert manifest.pid not in [m.pid for m in results] + + # Removed test because we don't want to trigger a reindex when a collection is saved. + # def test_get_instances_from_related(self): + # """Should get manifests from related collections""" + # manifest = ManifestFactory.create() + # # connect a collection and manifest + # collection = Collection(label="test collection") + # collection.save() + # manifest.collections.add(collection) + # instances = self.doc.get_instances_from_related(related_instance=collection) + # # should get the manifest related to this collection + # self.assertQuerysetEqual(instances, [manifest]) diff --git a/apps/iiif/manifests/tests/test_forms.py b/apps/iiif/manifests/tests/test_forms.py index e6143a052..52dda9b86 100644 --- a/apps/iiif/manifests/tests/test_forms.py +++ b/apps/iiif/manifests/tests/test_forms.py @@ -1,6 +1,11 @@ +import os from django.test import TestCase +from django.test.client import RequestFactory +from django.conf import settings +from django.core import files +from django.core.files.uploadedfile import SimpleUploadedFile from .factories import ManifestFactory -from ..forms import ManifestAdminForm +from ..forms import ManifestAdminForm, ManifestCSVImportForm class TestManifestForms(TestCase): def test_form_with_no_canvases(self): @@ -17,3 +22,22 @@ def test_form_with_canvases(self): self.assertTrue(manifest.canvas_set.exists()) form = ManifestAdminForm(instance=manifest) self.assertEqual(form.fields['start_canvas'].queryset.count(), manifest.canvas_set.count()) + + def test_bulk_metadata_form(self): + file_path = os.path.join( + settings.APPS_DIR, + 'iiif', + 'manifests', + 'fixtures', + 'metadata-update.csv' + ) + with open(file_path, 'rb') as metadata_file: + content = SimpleUploadedFile( + name='metadata-update.csv', + content=metadata_file.read() + ) + + csv_form = ManifestCSVImportForm(files={'csv_file': content}) + csv_form.is_valid() + csv_form.clean() + self.assertTrue(csv_form.is_valid()) diff --git a/apps/iiif/manifests/tests/test_views.py b/apps/iiif/manifests/tests/test_views.py index 7924bacee..85dcc388a 100644 --- a/apps/iiif/manifests/tests/test_views.py +++ b/apps/iiif/manifests/tests/test_views.py @@ -1,20 +1,25 @@ -''' -''' import json -from datetime import datetime +import os +from datetime import datetime, timezone from django.test import TestCase, Client from django.test import RequestFactory +from django.core.files.uploadedfile import SimpleUploadedFile + from django.conf import settings from django.contrib.admin.sites import AdminSite from django.contrib.auth import get_user_model from django.urls import reverse from django.core.serializers import serialize -from allauth.socialaccount.models import SocialAccount from ..admin import ManifestAdmin -from ..views import AddToCollectionsView, ManifestSitemap, ManifestRis +from ..views import ( + AddToCollectionsView, + ManifestSitemap, + ManifestRis, + MetadataImportView, +) from ..models import Manifest -from ..forms import ManifestsCollectionsForm +from ..forms import ManifestsCollectionsForm, ManifestCSVImportForm from .factories import ManifestFactory, EmptyManifestFactory from ...canvases.models import Canvas from ...canvases.tests.factories import CanvasFactory @@ -23,71 +28,80 @@ USER = get_user_model() + class ManifestTests(TestCase): fixtures = [ - 'users.json', - 'kollections.json', - 'manifests.json', - 'canvases.json', - 'annotations.json' + "users.json", + "kollections.json", + "manifests.json", + "canvases.json", + "annotations.json", ] def setUp(self): self.user = get_user_model().objects.get(pk=111) self.factory = RequestFactory() self.client = Client() - self.volume = ManifestFactory.create( - publisher='ECDS', - published_city='Atlanta' - ) + self.volume = ManifestFactory.create(publisher="ECDS", published_city="Atlanta") for num in range(0, 3): - CanvasFactory.create( - manifest=self.volume, - position=num - ) + CanvasFactory.create(manifest=self.volume, position=num) self.start_canvas = self.volume.canvas_set.filter(manifest=self.volume).first() - self.default_start_canvas = self.volume.canvas_set.filter(is_starting_page=False).first() - self.assumed_label = ' Descrizione del Palazzo Apostolico Vaticano ' + self.default_start_canvas = self.volume.canvas_set.filter( + is_starting_page=False + ).first() + self.assumed_label = " Descrizione del Palazzo Apostolico Vaticano " self.assumed_pid = self.volume.pid self.volume.save() def test_properties(self): - assert self.volume.publisher_bib == 'Atlanta : ECDS' - assert self.volume.thumbnail_logo.endswith("/media/logos/ecds.png") - assert self.volume.baseurl.endswith("/iiif/v2/%s" % (self.volume.pid)) - assert self.volume.start_canvas.identifier.endswith("/iiif/%s/canvas/%s" % (self.volume.pid, self.volume.start_canvas.pid)) - - def test_default_start_canvas(self): - self.start_canvas.is_starting_page = False - self.start_canvas.save() - self.volume.start_canvas = None - self.volume.save() - self.volume.refresh_from_db() - assert self.volume.start_canvas.identifier.endswith("/iiif/%s/canvas/%s" % (self.volume.pid, self.default_start_canvas.pid)) + assert self.volume.publisher_bib == "Atlanta : ECDS" + assert self.volume.logo.url.endswith("/media/logos/ecds.png") + assert self.volume.baseurl.endswith(f"/iiif/v2/{self.volume.pid}") + assert self.volume.start_canvas.identifier.endswith( + f"/iiif/{self.volume.pid}/canvas/{self.volume.start_canvas.pid}" + ) + + # def test_default_start_canvas(self): + # self.start_canvas.is_starting_page = False + # self.start_canvas.save() + # self.volume.start_canvas = None + # self.volume.save() + # self.volume.refresh_from_db() + # assert self.volume.start_canvas.identifier.endswith( + # f'/iiif/{self.volume.pid}/canvas/{self.default_start_canvas.pid}' + # ) def test_meta(self): - assert str(self.volume) == self.volume.label + assert str(self.volume) == f"{self.volume.pid} - title: {self.volume.label}" def test_sitemap(self): site_map = ManifestSitemap() assert len(site_map.items()) == Manifest.objects.all().count() - assert site_map.location(self.volume) == "/iiif/v2/%s/manifest" % (self.volume.pid) + assert site_map.location(self.volume) == "/iiif/v2/%s/manifest" % ( + self.volume.pid + ) def test_ris_view(self): ris = ManifestRis() - assert ris.get_context_data(volume=self.assumed_pid)['volume'] == self.volume + assert ris.get_context_data(volume=self.assumed_pid)["volume"] == self.volume def test_plain_export_view(self): - kwargs = { 'pid': self.volume.pid, 'version': 'v2' } - url = reverse('PlainExport', kwargs=kwargs) + kwargs = {"pid": self.volume.pid, "version": "v2"} + url = reverse("PlainExport", kwargs=kwargs) response = self.client.get(url) assert response.status_code == 200 def test_autocomplete_label(self): - assert Manifest.objects.all().first().autocomplete_label() == Manifest.objects.all().first().label + assert ( + Manifest.objects.all().first().autocomplete_label() + == Manifest.objects.all().first().pid + ) def test_absolute_url(self): - assert Manifest.objects.all().first().get_absolute_url() == "%s/volume/%s" % (settings.HOSTNAME, Manifest.objects.all().first().pid) + assert Manifest.objects.all().first().get_absolute_url() == "%s/volume/%s" % ( + settings.HOSTNAME, + Manifest.objects.all().first().pid, + ) # def test_manifest_search_vector_exists(self): # volume = ManifestFactory.create() @@ -100,43 +114,48 @@ def test_multiple_starting_canvases(self): volume = EmptyManifestFactory.create(canvas=None) assert volume.canvas_set.exists() is False for index, _ in enumerate(range(4)): - CanvasFactory.create(manifest=volume, is_starting_page=True, position=index+1) + CanvasFactory.create( + manifest=volume, is_starting_page=True, position=index + 1 + ) + volume.save() + volume.refresh_from_db() manifest = json.loads( serialize( - 'manifest', + "manifest", [volume], - version='v2', - annotators='Tom', - exportdate=datetime.utcnow() + version="v2", + annotators="Tom", + exportdate=datetime.now(timezone.utc), ) ) first_canvas = volume.canvas_set.all().first() assert volume.start_canvas.position <= 1 assert first_canvas.position <= 1 - assert first_canvas.pid in manifest['thumbnail']['@id'] + assert first_canvas.pid in manifest["thumbnail"]["@id"] def test_no_starting_canvases(self): + """_summary_""" manifest = ManifestFactory.create() try: manifest.canvas_set.all().get(is_starting_page=True) except Canvas.DoesNotExist as error: - assert str(error) == 'Canvas matching query does not exist.' - serialized_manifest = json.loads( - serialize( - 'manifest', - [manifest] - ) + assert str(error) == "Canvas matching query does not exist." + serialized_manifest = json.loads(serialize("manifest", [manifest])) + assert ( + manifest.canvas_set.all().first().pid + in serialized_manifest["thumbnail"]["@id"] ) - assert manifest.canvas_set.all().first().pid in serialized_manifest['thumbnail']['@id'] # TODO: Test with 0 manifests, 1 manifests, non-manifest obj def test_add_to_collections_view_context(self): """it should add the passed manifests to view context""" manifest1 = ManifestFactory.create() manifest2 = ManifestFactory.create() - ids = ','.join([str(manifest1.pk), str(manifest2.pk)]) + ids = ",".join([str(manifest1.pk), str(manifest2.pk)]) request = self.factory.get( - reverse('admin:manifests_manifest_changelist') + 'add_to_collections/?ids=' + ids + reverse("admin:manifests_manifest_changelist") + + "add_to_collections/?ids=" + + ids ) request.user = self.user view = AddToCollectionsView() @@ -144,9 +163,9 @@ def test_add_to_collections_view_context(self): view.setup(request, model_admin=model_admin) context = view.get_context_data() - self.assertIn('manifests', context) - self.assertIn(manifest1, context['manifests']) - self.assertIn(manifest2, context['manifests']) + self.assertIn("manifests", context) + self.assertIn(manifest1, context["manifests"]) + self.assertIn(manifest2, context["manifests"]) def test_add_to_collections_view_function(self): """it should add the passed manifests to the selected collections""" @@ -154,9 +173,11 @@ def test_add_to_collections_view_function(self): manifest2 = ManifestFactory.create() collection1 = CollectionFactory.create() collection2 = CollectionFactory.create() - ids = ','.join([str(manifest1.pk), str(manifest2.pk)]) + ids = ",".join([str(manifest1.pk), str(manifest2.pk)]) request = self.factory.get( - reverse('admin:manifests_manifest_changelist') + 'add_to_collections/?ids=' + ids + reverse("admin:manifests_manifest_changelist") + + "add_to_collections/?ids=" + + ids ) request.user = self.user view = AddToCollectionsView() @@ -170,3 +191,95 @@ def test_add_to_collections_view_function(self): view.add_manifests_to_collections(form) assert len(manifest1.collections.all()) == 2 assert len(manifest2.collections.all()) == 2 + + def test_bulk_metadata_update(self): + """ + Should update manifest metadata from CSV file. + """ + + manifests = [ + ManifestFactory.create(pid="1925-Temple-EMU"), + ManifestFactory.create(pid="1829-Chahta-UTL"), + ] + + self.assertNotEqual(manifests[0].label, "The Temple Star") + self.assertNotEqual(manifests[1].label, "Chahta Uba Isht Taloa") + + file_path = os.path.join( + settings.APPS_DIR, "iiif", "manifests", "fixtures", "metadata-update.csv" + ) + with open(file_path, "rb") as metadata_file: + content = SimpleUploadedFile( + name="metadata-update.csv", content=metadata_file.read() + ) + + csv_form = ManifestCSVImportForm(files={"csv_file": content}) + view = MetadataImportView() + view.request = self.factory.get("/") + view.form_valid(csv_form) + + manifests[0].refresh_from_db() + manifests[1].refresh_from_db() + self.assertEqual(manifests[0].label, "The Temple Star") + self.assertEqual(manifests[1].label, "Chahta Uba Isht Taloa") + + def test_bulk_metadata_update_with_languages(self): + """ + Should update manifest languages (plural) from CSV file. + """ + + manifest = ManifestFactory.create(pid="pid1") + + self.assertEqual(manifest.languages.count(), 0) + + file_path = os.path.join( + settings.APPS_DIR, + "iiif", + "manifests", + "fixtures", + "metadata-update-languages.csv", + ) + with open(file_path, "rb") as metadata_file: + content = SimpleUploadedFile( + name="metadata-update.csv", content=metadata_file.read() + ) + + csv_form = ManifestCSVImportForm(files={"csv_file": content}) + view = MetadataImportView() + view.request = self.factory.get("/") + view.form_valid(csv_form) + + manifest.refresh_from_db() + self.assertEqual(manifest.languages.count(), 2) + self.assertIn("Cherokee", [lang.name for lang in manifest.languages.all()]) + self.assertIn("English", [lang.name for lang in manifest.languages.all()]) + + def test_bulk_metadata_update_with_language(self): + """ + Should update manifest languages (singular) from CSV file. + """ + + manifest = ManifestFactory.create(pid="pid2") + + self.assertEqual(manifest.languages.count(), 0) + + file_path = os.path.join( + settings.APPS_DIR, + "iiif", + "manifests", + "fixtures", + "metadata-update-language.csv", + ) + with open(file_path, "rb") as metadata_file: + content = SimpleUploadedFile( + name="metadata-update.csv", content=metadata_file.read() + ) + + csv_form = ManifestCSVImportForm(files={"csv_file": content}) + view = MetadataImportView() + view.request = self.factory.get("/") + view.form_valid(csv_form) + + manifest.refresh_from_db() + self.assertEqual(manifest.languages.count(), 1) + self.assertIn("Cherokee", [lang.name for lang in manifest.languages.all()]) diff --git a/apps/iiif/manifests/tests/tests.py b/apps/iiif/manifests/tests/tests.py index d2831a303..1ec4f5a78 100644 --- a/apps/iiif/manifests/tests/tests.py +++ b/apps/iiif/manifests/tests/tests.py @@ -1,10 +1,11 @@ """ Test class for IIIF Manifests """ + import json import random import boto3 -from moto import mock_s3 +from moto import mock_aws from datetime import datetime from time import sleep from django.test import TestCase, Client @@ -24,15 +25,17 @@ USER = get_user_model() + # FIXME: Extend `TestCase` to mock all HTTP requests. class ManifestTests(TestCase): """Tests for IIIF Manifests""" + fixtures = [ - 'users.json', - 'kollections.json', - 'manifests.json', - 'canvases.json', - 'annotations.json' + "users.json", + "kollections.json", + "manifests.json", + "canvases.json", + "annotations.json", ] def setUp(self): @@ -40,54 +43,78 @@ def setUp(self): self.user = get_user_model().objects.get(pk=111) self.factory = RequestFactory() self.client = Client() - self.volume = ManifestFactory.create( - publisher='ECDS', - published_city='Atlanta' - ) + self.volume = ManifestFactory.create(publisher="ECDS", published_city="Atlanta") for num in [1, 2, 3]: CanvasFactory.create( pid=str(random.randrange(2000, 5000)), manifest=self.volume, - position=num + position=num, ) self.volume.save() self.start_canvas = self.volume.start_canvas - self.default_start_canvas = self.volume.canvas_set.filter(is_starting_page=False).first() + self.default_start_canvas = self.volume.canvas_set.filter( + is_starting_page=False + ).first() self.assumed_label = self.volume.label - self.assumed_pid = self.volume.pid - - def test_validate_iiif(self): - # volume = Manifest.objects.all().first() - manifest = json.loads( - serialize( - 'manifest', - [self.volume], - version='v2', - annotators='Tom', - exportdate=datetime.utcnow() - ) - ) - reader = ManifestReader(json.dumps(manifest), version='2.1') - try: - manifest_reader = reader.read() - assert manifest_reader.toJSON() - except Exception as error: - raise Exception(error) - - assert manifest['@id'] == "%s/manifest" % (self.volume.baseurl) - assert manifest['label'] == self.volume.label - assert manifest['description'] == self.volume.summary - assert manifest['thumbnail']['@id'] == '{h}/{c}/full/600,/0/default.jpg'.format( - h=self.volume.image_server.server_base, - c=self.start_canvas.resource - ) - assert manifest['sequences'][0]['startCanvas'] == self.volume.start_canvas.identifier + self.assumed_pid = self.volume + + # TODO: disabled until we move to proper IIIF 3 + # def test_validate_iiif(self): + # # volume = Manifest.objects.all().first() + # manifest = json.loads( + # serialize( + # 'manifest', + # [self.volume], + # version='v2', + # annotators='Tom', + # exportdate=datetime.utcnow() + # ) + # ) + # reader = ManifestReader(json.dumps(manifest), version='2.1') + # try: + # manifest_reader = reader.read() + # assert manifest_reader.toJSON() + # except Exception as error: + # raise Exception(error) + + # assert manifest['@id'] == "%s/manifest" % (self.volume.baseurl) + # assert manifest['label'] == self.volume.label + # assert manifest['description'] == self.volume.summary + # assert manifest['thumbnail']['@id'] == '{h}/{c}/full/600,/0/default.jpg'.format( + # h=self.volume.image_server.server_base, + # c=self.start_canvas.resource + # ) + # assert manifest['sequences'][0]['startCanvas'] == self.volume.start_canvas.identifier def test_properties(self): assert self.volume.publisher_bib == "Atlanta : ECDS" - assert self.volume.thumbnail_logo.endswith('/media/logos/ecds.png') + assert self.volume.logo.url.endswith("/media/logos/ecds.png") assert self.volume.baseurl.endswith("/iiif/v2/%s" % (self.volume.pid)) - assert self.volume.start_canvas.identifier.endswith("/iiif/%s/canvas/%s" % (self.volume.pid, self.start_canvas.pid)) + assert self.volume.start_canvas.identifier.endswith( + "/iiif/%s/canvas/%s" % (self.volume.pid, self.start_canvas.pid) + ) + + def test_authors_property(self): + """Test the authors property returns a list of authors from the author field.""" + # Test with no author + manifest = Manifest(author=None) + assert manifest.authors == ["[no author]"] + + # Test with empty string + manifest = Manifest(author="") + assert manifest.authors == ["[no author]"] + + # Test with single author + manifest = Manifest(author="John Doe") + assert manifest.authors == ["John Doe"] + + # Test with multiple authors separated by semicolon + manifest = Manifest(author="John Doe; Jane Smith") + assert manifest.authors == ["John Doe", "Jane Smith"] + + # Test with multiple authors with extra whitespace + manifest = Manifest(author="John Doe ; Jane Smith ; Bob Jones") + assert manifest.authors == ["John Doe", "Jane Smith", "Bob Jones"] def test_default_start_canvas(self): image_server = ImageServerFactory.create(server_base="https://fake.info") @@ -96,31 +123,41 @@ def test_default_start_canvas(self): assert manifest.start_canvas is None canvas = CanvasFactory.create(manifest=manifest) canvas.save() + manifest.save() + manifest.refresh_from_db() assert manifest.start_canvas == canvas def test_meta(self): - assert str(self.volume) == self.assumed_label + assert str(self.volume) == f"{self.volume.pid} - title: {self.volume.label}" def test_sitemap(self): sm = ManifestSitemap() assert len(sm.items()) == Manifest.objects.all().count() - assert sm.location(self.volume) == "/iiif/v2/%s/manifest" % (self.assumed_pid) + assert sm.location(self.volume) == "/iiif/v2/%s/manifest" % (self.volume.pid) def test_ris_view(self): ris = ManifestRis() - assert ris.get_context_data(volume=self.assumed_pid)['volume'] == self.volume + assert ( + ris.get_context_data(volume=self.assumed_pid.pid)["volume"] == self.volume + ) def test_plain_export_view(self): - kwargs = { 'pid': self.volume.pid, 'version': 'v2' } - url = reverse('PlainExport', kwargs=kwargs) + kwargs = {"pid": self.volume.pid, "version": "v2"} + url = reverse("PlainExport", kwargs=kwargs) response = self.client.get(url) assert response.status_code == 200 def test_autocomplete_label(self): - assert Manifest.objects.all().first().autocomplete_label() == Manifest.objects.all().first().label + assert ( + Manifest.objects.all().first().autocomplete_label() + == Manifest.objects.all().first().pid + ) def test_absolute_url(self): - assert Manifest.objects.all().first().get_absolute_url() == "%s/volume/%s" % (settings.HOSTNAME, Manifest.objects.all().first().pid) + assert Manifest.objects.all().first().get_absolute_url() == "%s/volume/%s" % ( + settings.HOSTNAME, + Manifest.objects.all().first().pid, + ) # def test_manifest_search_vector_exists(self): # assert self.volume.search_vector is None @@ -131,84 +168,82 @@ def test_absolute_url(self): def test_multiple_starting_canvases(self): volume = ManifestFactory.create() for index, _ in enumerate(range(4)): - CanvasFactory.create(manifest=volume, is_starting_page=True, position=index+1) + CanvasFactory.create( + manifest=volume, is_starting_page=True, position=index + 1 + ) sleep(2) # volume.refresh_from_db() manifest = json.loads( serialize( - 'manifest', + "manifest", [volume], - version='v2', - annotators='Tom', - exportdate=datetime.utcnow() + version="v2", + annotators="Tom", + exportdate=datetime.utcnow(), ) ) - first_canvas = volume.canvas_set.all().order_by('position').first() - assert volume.start_canvas.pid in manifest['thumbnail']['@id'] + first_canvas = volume.canvas_set.all().order_by("position").first() + assert volume.start_canvas.pid in manifest["thumbnail"]["@id"] def test_no_starting_canvases(self): manifest = ManifestFactory.create() try: manifest.canvas_set.all().get(is_starting_page=True) except Canvas.DoesNotExist as error: - assert str(error) == 'Canvas matching query does not exist.' + assert str(error) == "Canvas matching query does not exist." manifest.refresh_from_db() - serialized_manifest = json.loads( - serialize( - 'manifest', - [manifest] - ) + serialized_manifest = json.loads(serialize("manifest", [manifest])) + assert ( + manifest.canvas_set.all().first().pid + in serialized_manifest["thumbnail"]["@id"] ) - print('*') - print(manifest.canvas_set.count()) - print([c.position for c in manifest.canvas_set.all()]) - print('*') - assert manifest.canvas_set.all().first().pid in serialized_manifest['thumbnail']['@id'] def test_default_iiif_image_server_url(self): image_server = ImageServer() assert image_server.server_base == settings.IIIF_IMAGE_SERVER_BASE def test_serialized_related_links(self): - """ It should add a list of links for the "seeAlso" key. """ + """It should add a list of links for the "seeAlso" key.""" manifest = ManifestFactory.create() - no_links = json.loads( - serialize( - 'manifest', - [manifest] - ) - ) - assert 'seeAlso' not in no_links.keys() + no_links = json.loads(serialize("manifest", [manifest])) + assert not no_links["seeAlso"] - link = RelatedLink(link='images.org', manifest=manifest) + link = RelatedLink( + link="images.org", manifest=manifest, is_structured_data=True + ) link.save() manifest.refresh_from_db() - with_links = json.loads( - serialize( - 'manifest', - [manifest] - ) - ) + with_links = json.loads(serialize("manifest", [manifest])) - assert 'seeAlso' in with_links.keys() - assert isinstance(with_links['seeAlso'], list) - assert len(with_links['seeAlso']) == 1 - assert with_links['seeAlso'][0] == 'images.org' + assert "seeAlso" in with_links.keys() + assert isinstance(with_links["seeAlso"], list) + assert len(with_links["seeAlso"]) == 1 + assert with_links["seeAlso"][0] == "images.org" - @mock_s3 + @mock_aws def test_renameing_pid_when_images_are_in_s3(self): - """ It should copy the canvas files to a folder with new pid and delete old pid. """ - image_server = ImageServerFactory.create(storage_path='earthgang', storage_service='s3') + """It should copy the canvas files to a folder with new pid and delete old pid.""" + image_server = ImageServerFactory.create( + storage_path="earthgang", storage_service="s3" + ) manifest = ManifestFactory(image_server=image_server) - conn = boto3.resource('s3', region_name='us-east-1') + conn = boto3.resource("s3", region_name="us-east-1") conn.create_bucket(Bucket=image_server.storage_path) original_pid = manifest.pid - image_server.bucket.upload_file('apps/iiif/canvases/fixtures/00000002.jpg', f'{manifest.pid}/00000002.jpg') - assert f'{manifest.pid}/00000002.jpg' in [f.key for f in image_server.bucket.objects.all()] + image_server.bucket.upload_file( + "apps/iiif/canvases/fixtures/00000002.jpg", f"{manifest.pid}/00000002.jpg" + ) + assert f"{manifest.pid}/00000002.jpg" in [ + f.key for f in image_server.bucket.objects.all() + ] manifest.pid = encode_noid(0) manifest.save() manifest.refresh_from_db() - assert f'{original_pid}/00000002.jpg' not in [f.key for f in image_server.bucket.objects.all()] + assert f"{original_pid}/00000002.jpg" not in [ + f.key for f in image_server.bucket.objects.all() + ] assert original_pid not in [f.key for f in image_server.bucket.objects.all()] - assert f'{manifest.pid}/00000002.jpg' in [f.key for f in image_server.bucket.objects.all()] + assert f"{manifest.pid}/00000002.jpg" in [ + f.key for f in image_server.bucket.objects.all() + ] diff --git a/apps/iiif/manifests/urls.py b/apps/iiif/manifests/urls.py index fe204f706..e8ac4b22d 100644 --- a/apps/iiif/manifests/urls.py +++ b/apps/iiif/manifests/urls.py @@ -4,5 +4,6 @@ urlpatterns = [ path('iiif///manifest', views.ManifestDetail.as_view(), name="ManifestRender"), - path('volume//citation.ris', views.ManifestRis.as_view(), name='ris' ) + path('volume//citation.ris', views.ManifestRis.as_view(), name='ris' ), + path('iiif/v2/all-volumes-manifest', views.AllVolumesCollection.as_view(), name="AllVolumesManifest") ] diff --git a/apps/iiif/manifests/views.py b/apps/iiif/manifests/views.py index f4dd28d15..6957804b7 100644 --- a/apps/iiif/manifests/views.py +++ b/apps/iiif/manifests/views.py @@ -1,4 +1,8 @@ """Django views for manifests""" + +import os +import csv +from io import StringIO import json import logging from datetime import datetime @@ -10,22 +14,26 @@ from django.core.serializers import serialize from django.contrib.sitemaps import Sitemap from django.urls import reverse + +from .services import normalize_header, set_metadata from .models import Manifest -from .forms import ManifestsCollectionsForm +from .forms import ManifestCSVImportForm, ManifestsCollectionsForm LOGGER = logging.getLogger(__name__) + class ManifestDetail(View): """Endpoint for a specific IIIF manifest.""" + def get_queryset(self): """Get requested manifest object. :return: Manifest object :rtype: django.db.models.QuerySet """ - return Manifest.objects.filter(pid=self.kwargs['pid']) + return Manifest.objects.filter(pid=self.kwargs["pid"]) - def get(self, request, *args, **kwargs): # pylint: disable = unused-argument + def get(self, request, *args, **kwargs): # pylint: disable = unused-argument """Responds to HTTP GET request for specific manifest. :return: IIIF representation of a manifest/volume @@ -36,32 +44,77 @@ def get(self, request, *args, **kwargs): # pylint: disable = unused-argument annotators = [] if request.user.is_authenticated: annotators.append(request.user) - annotators_string = ', '.join([str(i.name) for i in annotators]) - return JsonResponse( - json.loads( - serialize( - 'manifest', - self.get_queryset(), - version=kwargs['version'], - annotators=annotators_string, - exportdate=datetime.utcnow(), - current_user=request.user - ) + annotators_string = ", ".join([str(i.name) for i in annotators]) + if "2" in kwargs["version"]: + return JsonResponse( + json.loads( + serialize( + "manifest", + self.get_queryset(), + version=kwargs["version"], + annotators=annotators_string, + exportdate=datetime.utcnow(), + current_user=request.user, + ) + ), + safe=False, + ) + elif "3" in kwargs["version"]: + return JsonResponse( + json.loads( + serialize( + "manifest_v3", self.get_queryset(), current_user=request.user + ) + ), + safe=False, + ) + + +class AllVolumesCollection(View): + """Endpoint for all volumes collection.""" + + def get_queryset(self): + """Get all manifest objects. + + :return: All manifest objects + :rtype: django.db.models.QuerySet + """ + return Manifest.objects.all() + + def get(self, request, *args, **kwargs): # pylint: disable = unused-argument + """Responds to HTTP GET request for all manifests. + + :return: IIIF representation of all volumes collection + :rtype: JSON + """ + collection = { + "@id": request.build_absolute_uri(), + "@type": "sc:Collection", + "@context": "http://iiif.io/api/presentation/2/context.json", + "label": "All Readux volumes", + "manifests": json.loads( + serialize("all_volumes_manifest", self.get_queryset(), is_list=True) ), - safe=False) + } + + return JsonResponse(collection, safe=False) + class ManifestSitemap(Sitemap): - """Django site map for mainfests""" + """Django site map for manifests""" + # priority unknown def items(self): return Manifest.objects.all() def location(self, item): - return reverse('ManifestRender', kwargs={'version': 'v2', 'pid': item.pid}) + return reverse("ManifestRender", kwargs={"version": "v2", "pid": item.pid}) + class ManifestRis(TemplateView): """Manifest Ris""" - content_type = 'application/x-research-info-systems; charset=UTF-8' + + content_type = "application/x-research-info-systems; charset=UTF-8" template_name = "citation.ris" def get_context_data(self, **kwargs): @@ -71,23 +124,24 @@ def get_context_data(self, **kwargs): :rtype: [type] """ context = super().get_context_data(**kwargs) - context['volume'] = Manifest.objects.filter(pid=kwargs['volume']).first() + context["volume"] = Manifest.objects.filter(pid=kwargs["volume"]).first() return context + class AddToCollectionsView(FormView): """Intermediate page to choose collections to which you are adding manifests""" - template_name = 'add_manifests_to_collections.html' + template_name = "add_manifests_to_collections.html" form_class = ManifestsCollectionsForm def get_context_data(self, **kwargs): - ids = self.request.GET.get('ids', '').split(',') + ids = self.request.GET.get("ids", "").split(",") manifests = Manifest.objects.filter(pk__in=ids) - model_admin = self.kwargs['model_admin'] + model_admin = self.kwargs["model_admin"] context = super().get_context_data(**kwargs) - context['model_admin'] = model_admin.admin_site.each_context(self.request) - context['manifests'] = manifests - context['title'] = 'Add selected manifests to collection(s)' + context["model_admin"] = model_admin.admin_site.each_context(self.request) + context["manifests"] = manifests + context["title"] = "Add selected manifests to collection(s)" return context def form_valid(self, form): @@ -97,15 +151,60 @@ def form_valid(self, form): def add_manifests_to_collections(self, form): """Adds selected manifests to selected collections from form""" context = self.get_context_data() - manifests = context['manifests'] + manifests = context["manifests"] if form.is_valid(): - collections = form.cleaned_data['collections'] + collections = form.cleaned_data["collections"] for manifest in manifests: - manifest.collections.add(*collections) + if collections: + manifest.collections.add(*collections) manifest.save() def get_success_url(self): messages.add_message( - self.request, messages.SUCCESS, 'Successfully added manifests to collections' + self.request, + messages.SUCCESS, + "Successfully added manifests to collections", ) - return reverse('admin:manifests_manifest_changelist') + return reverse("admin:manifests_manifest_changelist") + + +class MetadataImportView(FormView): + """Admin page to import a CSV and update multiple Manifests' metadata""" + + template_name = "manifest_metadata_import.html" + form_class = ManifestCSVImportForm + + def get_context_data(self, **kwargs): + """Set page title on context data""" + context = super().get_context_data(**kwargs) + context["title"] = "Manifest metadata bulk update (CSV import)" + return context + + def form_valid(self, form): + """Read the CSV file and, find associated manifests, and update metadata""" + form.is_valid() + csv_file = form.cleaned_data.get("csv_file") + csv_io = StringIO(csv_file.read().decode("utf-8")) + reader = csv.DictReader(normalize_header(csv_io)) + for row in reader: + try: + # try to find manifest + manifest = Manifest.objects.get(pid=row["pid"]) + # use ingest set_metadata function + set_metadata(manifest, metadata=row) + except Manifest.DoesNotExist: + messages.add_message( + self.request, + messages.ERROR, + f'Manifest with pid {row["pid"]} not found', + ) + return super().form_valid(form) + + def get_success_url(self): + """Return to the manifest change list with a success message""" + # https://stackoverflow.com/questions/11938164/why-dont-my-django-unittests-know-that-messagemiddleware-is-installed + if os.environ["DJANGO_ENV"] != "test": + messages.add_message( + self.request, messages.SUCCESS, "Successfully updated manifests" + ) + return reverse("admin:manifests_manifest_changelist") diff --git a/apps/iiif/models.py b/apps/iiif/models.py index 8b8749f96..cdc4a231a 100644 --- a/apps/iiif/models.py +++ b/apps/iiif/models.py @@ -1,11 +1,14 @@ -from time import time -from uuid import uuid4, UUID +""" Abstract model class for IIIF models """ +from uuid import uuid4 from dirtyfields import DirtyFieldsMixin -from django.db import models, IntegrityError +from django.db import models +from django.utils import timezone +import config.settings.local as settings from modelcluster.models import ClusterableModel from apps.utils.noid import encode_noid class IiifBase(DirtyFieldsMixin, ClusterableModel): + """ Abstract model class for IIIF models """ id = models.UUIDField(primary_key=True, default=uuid4, editable=True) pid = models.CharField( max_length=255, @@ -13,7 +16,37 @@ class IiifBase(DirtyFieldsMixin, ClusterableModel): blank=False, help_text="Unique ID. Do not use _'s or spaces in the pid." ) - label = models.CharField(max_length=1000) + label = models.CharField(max_length=1000, default='') + created_at = models.DateTimeField(auto_now_add=True, blank=True, null=True) + modified_at = models.DateTimeField(auto_now=True, blank=True, null=True) + + dup_pids = None + + @property + def created_at_iso(self): + """ + :return: Date object was created formatted like JavaScript's ISO date. + :rtype: str + """ + return self.__js_isoformat(self.created_at) + + @property + def modified_at_iso(self): + """ + :return: Date object was modified formatted like JavaScript's ISO date. + :rtype: str + """ + return self.__js_isoformat(self.modified_at) + + @property + def v2_baseurl(self): + """Convenience method to provide the base URL for a manifest.""" + return f'{settings.HOSTNAME}/iiif/v2/{self.pid}' + + @property + def v3_baseurl(self): + """Convenience method to provide the base URL for a manifest.""" + return f'{settings.HOSTNAME}/iiif/v3/{self.pid}' def save(self, *args, **kwargs): # pylint: disable = arguments-differ self.clean_pid() @@ -27,6 +60,12 @@ def save(self, *args, **kwargs): # pylint: disable = arguments-differ super().save(*args, **kwargs) + @staticmethod + def __js_isoformat(date): + return date.astimezone( + timezone.utc).isoformat( + timespec="milliseconds").replace("+00:00", "Z") + def clean_pid(self): """ Cantaloupe is generally configured substitute a slash (/) with an underscore (_) for the file path of the images. """ diff --git a/apps/iiif/serializers/all_volumes_manifest.py b/apps/iiif/serializers/all_volumes_manifest.py new file mode 100644 index 000000000..2b85fcccb --- /dev/null +++ b/apps/iiif/serializers/all_volumes_manifest.py @@ -0,0 +1,42 @@ +# pylint: disable = attribute-defined-outside-init, too-few-public-methods +"""Module for serializing all volumes manifest""" +from django.core.serializers.base import SerializerDoesNotExist +import config.settings.local as settings +from apps.iiif.serializers.base import Serializer as JSONSerializer + + +class Serializer(JSONSerializer): + """Serializer for a single IIIF manifest within the Readux all-volumes collection""" + + def get_dump_object(self, obj): + if (self.version == "v2") or (self.version is None): + data = { + "@id": "{h}/iiif/v2/{p}/manifest".format( + h=settings.HOSTNAME, p=obj.pid + ), + "@type": "sc:Manifest", + "label": obj.label, + "modified": obj.updated_at.isoformat(), + "within": [ + { + "@id": "{h}/iiif/v2/{p}/collection".format( + h=settings.HOSTNAME, p=collection.pid + ), + "@type": "sc:Collection", + "label": collection.label, + } + for collection in obj.collections.all() + ], + } + return data + return None + + +def deserializer(): + """Deserialize IIIF all volumes manifest + + :raises SerializerDoesNotExist: Not yet implemented. + """ + raise SerializerDoesNotExist( + "all_volumes_manifest is a serialization-only serializer" + ) diff --git a/apps/iiif/serializers/annotation.py b/apps/iiif/serializers/annotation.py index 3ffaacfcb..bb4738900 100644 --- a/apps/iiif/serializers/annotation.py +++ b/apps/iiif/serializers/annotation.py @@ -1,15 +1,14 @@ # pylint: disable = attribute-defined-outside-init, too-few-public-methods """Module for serializing IIIF Annotation""" import json -from django.core.serializers.base import SerializerDoesNotExist +from django.core.serializers import deserialize from django.contrib.auth import get_user_model from apps.iiif.serializers.base import Serializer as JSONSerializer -from apps.iiif.annotations.models import Annotation -from apps.iiif.canvases.models import Canvas import config.settings.local as settings USER = get_user_model() + class Serializer(JSONSerializer): """ Serialize a :class:`apps.iiif.annotation.models.Annotation` @@ -17,9 +16,10 @@ class Serializer(JSONSerializer): IIIF V2 Annotation List https://iiif.io/api/presentation/2.1/#annotation-list """ + def _init_options(self): super()._init_options() - self.owners = self.json_kwargs.pop('owners', 0) + self.owners = self.json_kwargs.pop("owners", 0) def get_dump_object(self, obj): """ @@ -28,72 +28,62 @@ def get_dump_object(self, obj): :param obj: Annotation to be serialized. :type obj: :class:`apps.iiif.annotation.models.Annotation` - :return: Serialzed annotation. + :return: Serialized annotation. :rtype: dict """ # TODO: Add more validation checks before trying to serialize. - if ((self.version == 'v2') or (self.version is None)): - name = 'OCR' + if (self.version == "v2") or (self.version is None): + name = "OCR" if obj.owner_id: - name = obj.owner.username if obj.owner.name == '' else obj.owner.name + name = obj.owner.username if obj.owner.name == "" else obj.owner.name data = { "@context": "http://iiif.io/api/presentation/2/context.json", "@id": str(obj.pk), "@type": "oa:Annotation", "motivation": obj.motivation, - "annotatedBy": { - "name": name - }, + "annotatedBy": {"name": name}, "resource": { "@type": obj.resource_type, "format": "text/html", "chars": obj.content, - "language": obj.language + "language": obj.language, }, "on": { - "full": '{h}/iiif/{v}/{m}/canvas/{c}'.format( + "full": "{h}/iiif/{v}/{m}/canvas/{c}".format( h=settings.HOSTNAME, v=self.version, m=obj.canvas.manifest.pid, - c=obj.canvas.pid + c=obj.canvas.pid, ), "@type": "oa:SpecificResource", "within": { - "@id": '{h}/iiif/{v}/{c}/manifest'.format( + "@id": "{h}/iiif/{v}/{c}/manifest".format( h=settings.HOSTNAME, v=self.version, - c=obj.canvas.manifest.pid + c=obj.canvas.manifest.pid, ), - "@type": "sc:Manifest" + "@type": "sc:Manifest", }, "selector": { "@type": "oa:FragmentSelector", - "value": 'xywh={x},{y},{w},{h}'.format( - x=str(obj.x), - y=str(obj.y), - w=str(obj.w), - h=str(obj.h) - ) - } - } + "value": f"xywh={obj.x},{obj.y},{obj.w},{obj.h}", + }, + }, } - if hasattr(obj, 'style') and obj.style is not None: - data['stylesheet'] = self.__serialize_style(obj) + if hasattr(obj, "style") and obj.style is not None: + data["stylesheet"] = self.__serialize_style(obj) if obj.item is not None: - data['on']['selector']['item'] = self.__serialize_item(obj) + data["on"]["selector"]["item"] = self.__serialize_item(obj) else: - data['on']['selector']['item'] = {'@type': 'oa:FragmentSelector'} + data["on"]["selector"]["item"] = {"@type": "oa:FragmentSelector"} - if hasattr(obj, 'tags') and obj.tags.exists(): - data['motivation'] = data['motivation'].split(',') - data['resource'] = [data['resource']] + if hasattr(obj, "tags") and obj.tags.exists(): + data["motivation"] = data["motivation"].split(",") + data["resource"] = [data["resource"]] for tag in obj.tags.all(): - wa_tag = { - "@type": "oa:Tag", - "chars": tag.name - } - data['resource'].append(wa_tag) # pylint: disable= no-member + wa_tag = {"@type": "oa:Tag", "chars": tag.name} + data["resource"].append(wa_tag) # pylint: disable= no-member return data return None @@ -117,24 +107,115 @@ def __serialize_style(cls, obj): :return: Stylesheet data compliant with the web annotation standard. :rtype: dict """ - return { - "type": "CssStylesheet", - "value": obj.style - } + return {"type": "CssStylesheet", "value": obj.style} + def Deserializer(data): - # data = json.loads(stream_or_string) - annotation = Annotation() - if data['@type'] == 'oa:Annotation': - if data['annotatedBy']['name'] == 'OCR': - data['annotatedBy']['name'] = 'ocr' - annotation.owner = USER.objects.get(username='ocr', name='OCR') - annotation.oa_annotation = data - annotation.resource_type = annotation.OCR - annotation.motivation = data['motivation'] - annotation.content = data['resource']['chars'] - annotation.language = data['resource']['language'] - annotation.canvas = Canvas.objects.get(pid=data['on']['full'].split('/')[-1]) - dimensions = data['on']['selector']['value'].split('=')[-1].split(',') - annotation.x, annotation.y, annotation.w, annotation.h = [int(d) for d in dimensions] - return annotation + """Deserialize IIIF Annotation""" + + if isinstance(data, str): + data = json.loads(data) + + if "@context" in data and "2/context.json" in data["@context"]: + return deserialize("annotation_v2", data) + + return deserialize("annotation_v3", data) + + +# def Deserializer(data): +# if isinstance(data, str): +# data = json.loads(data) + +# annotation = Annotation() +# if "@type" in data and data["@type"] == "oa:Annotation": +# if data["annotatedBy"]["name"] == "OCR": +# data["annotatedBy"]["name"] = "ocr" +# annotation.owner = USER.objects.get(username="ocr", name="OCR") +# annotation.oa_annotation = data +# annotation.resource_type = annotation.OCR +# annotation.motivation = data["motivation"] +# annotation.content = data["resource"]["chars"] +# annotation.language = data["resource"]["language"] +# annotation.canvas = Canvas.objects.get(pid=data["on"]["full"].split("/")[-1]) +# dimensions = data["on"]["selector"]["value"].split("=")[-1].split(",") +# annotation.x, annotation.y, annotation.w, annotation.h = [ +# int(d) for d in dimensions +# ] + +# elif "type" in data and data["type"] == "Annotation": +# annotation.owner = USER.objects.get(username=data["body"][0]["creator"]["id"]) +# if annotation.owner.username == "ocr": +# pass +# else: +# annotation = UserAnnotation(owner=annotation.owner) +# annotation.canvas = Canvas.objects.get( +# pid=data["target"]["source"].split("/")[-1] +# ) + +# for body in data["body"]: +# if body["purpose"] == "commenting": +# annotation.content = body["value"] +# if body["purpose"] == "tagging": +# annotation.save() +# annotation.tags.add(body["value"]) + +# if data["target"]["selector"]["type"] == "SvgSelector": +# annotation.svg = data["target"]["selector"]["value"] + +# if ( +# "refinedBy" in data["target"]["selector"] +# and data["target"]["selector"]["refinedBy"]["type"] +# == "FragmentSelector" +# ): +# annotation.x, annotation.y, annotation.w, annotation.h = [ +# float(n) +# for n in data["target"]["selector"]["refinedBy"]["value"] +# .split("=")[-1] +# .split(",") +# ] + +# if data["target"]["selector"]["type"] == "FragmentSelector": +# annotation.x, annotation.y, annotation.w, annotation.h = [ +# float(n) +# for n in data["target"]["selector"]["value"] +# .split(":")[-1] +# .split(",") +# ] + +# if data["target"]["selector"]["type"] == "RangeSelector": +# annotation.start_selector = Annotation.objects.get( +# id=findall( +# r"([A-Za-z0-9\-]+)", +# data["target"]["selector"]["startSelector"]["value"], +# )[-1] +# ) +# annotation.end_selector = Annotation.objects.get( +# id=findall( +# r"([A-Za-z0-9\-]+)", +# data["target"]["selector"]["endSelector"]["value"], +# )[-1] +# ) +# annotation.start_offset = data["target"]["selector"]["startSelector"][ +# "refinedBy" +# ]["start"] +# annotation.end_offset = data["target"]["selector"]["endSelector"][ +# "refinedBy" +# ]["end"] + +# start_position = annotation.start_selector.order +# end_position = annotation.end_selector.order +# text = Annotation.objects.filter( +# canvas=annotation.canvas, +# order__lt=end_position, +# order__gte=start_position, +# ).order_by("order") + +# try: +# annotation["x"] = min(text.values_list("x", flat=True)) +# annotation["y"] = max(text.values_list("y", flat=True)) +# annotation["h"] = max(text.values_list("h", flat=True)) +# annotation["w"] = text.last().x + text.last().w - annotation["x"] +# except ValueError: +# pass + +# return annotation diff --git a/apps/iiif/serializers/annotation_list.py b/apps/iiif/serializers/annotation_list.py index ce512afce..cafd5bf63 100644 --- a/apps/iiif/serializers/annotation_list.py +++ b/apps/iiif/serializers/annotation_list.py @@ -1,8 +1,7 @@ # pylint: disable = attribute-defined-outside-init, too-few-public-methods """Module for serializing IIIF Annotation Lists""" import json -from django.core.serializers import serialize -from django.core.serializers.base import SerializerDoesNotExist +from django.core.serializers import serialize, deserialize from .base import Serializer as JSONSerializer from django.contrib.auth import get_user_model from django.db.models import Q @@ -10,42 +9,48 @@ USER = get_user_model() + class Serializer(JSONSerializer): """ IIIF V2 Annotation List https://iiif.io/api/presentation/2.1/#annotation-list """ + def _init_options(self): super()._init_options() - self.owners = self.json_kwargs.pop('owners', 0) + self.owners = self.json_kwargs.pop("owners", 0) def get_dump_object(self, obj): # TODO: Add more validation checks before trying to serialize. - if self.version == 'v2' or self.version is None: + if self.version == "v2" or self.version is None: data = { "@context": "http://iiif.io/api/presentation/2/context.json", - "@id": '{h}/iiif/v2/{m}/list/{c}'.format( - h=settings.HOSTNAME, - m=obj.manifest.pid, - c=obj.pid + "@id": "{h}/iiif/v2/{m}/list/{c}".format( + h=settings.HOSTNAME, m=obj.manifest.pid, c=obj.pid ), "@type": "sc:AnnotationList", "resources": json.loads( serialize( - 'annotation', + "annotation", obj.annotation_set.filter( - Q(owner=USER.objects.get(username='ocr')) | - Q(owner__in=self.owners) + Q(owner=USER.objects.get(username="ocr")) + | Q(owner__in=self.owners) ), - is_list=True) + is_list=True, ) + ), } return data return None -class Deserializer: - """Deserialize IIIF Annotation List - :raises SerializerDoesNotExist: Not yet implemented. - """ - def __init__(self, *args, **kwargs): - raise SerializerDoesNotExist("annotation_list is a serialization-only serializer") +def Deserializer(data): + """Deserialize IIIF V2 Annotation List""" + if isinstance(data, str): + data = json.loads(data) + + if "@context" in data.keys() and "2/context.json" in data["@context"]: + return [ + deserialize("annotation", annotation) for annotation in data["resources"] + ] + + return [deserialize("annotation", annotation) for annotation in data["items"]] diff --git a/apps/iiif/serializers/base.py b/apps/iiif/serializers/base.py index 1b088b13c..33e63f77e 100644 --- a/apps/iiif/serializers/base.py +++ b/apps/iiif/serializers/base.py @@ -1,4 +1,6 @@ +""" Base serializer """ from django.core.serializers.json import Serializer as JSONSerializer +from django.core.serializers.base import SerializerDoesNotExist class Serializer(JSONSerializer): """Base Serializer Class""" @@ -18,4 +20,11 @@ def end_serialization(self): if self.is_list: self.stream.write(']') else: - self.stream.write('') \ No newline at end of file + self.stream.write('') + +def Deserializer(object): + """Deserialize IIIF Annotation List + + :raises SerializerDoesNotExist: Not yet implemented. + """ + raise SerializerDoesNotExist("manifest is a serialization-only serializer") diff --git a/apps/iiif/serializers/canvas.py b/apps/iiif/serializers/canvas.py index 460e54cef..8ba355800 100644 --- a/apps/iiif/serializers/canvas.py +++ b/apps/iiif/serializers/canvas.py @@ -1,51 +1,41 @@ # pylint: disable = attribute-defined-outside-init, too-few-public-methods """Module for serializing IIIF Canvas""" -from django.core.serializers.base import SerializerDoesNotExist +import json from django.urls import reverse from django.contrib.auth import get_user_model +from django.core.serializers import deserialize import config.settings.local as settings from apps.iiif.serializers.base import Serializer as JSONSerializer USER = get_user_model() + class Serializer(JSONSerializer): """ Convert a queryset to IIIF Canvas """ + def _init_options(self): super()._init_options() - self.current_user = self.json_kwargs.pop('current_user', None) + self.current_user = self.json_kwargs.pop("current_user", None) def get_dump_object(self, obj): obj.label = str(obj.position) - if ((self.version == 'v2') or (self.version is None)): - otherContent = [ # pylint: disable=invalid-name + if (self.version == "v2") or (self.version is None): + + ocr_web_annotation_path = reverse( + "web_annotation_ocr", + kwargs={"volume": obj.manifest.pid, "canvas": obj.pid}, + ) + + otherContent = [ # pylint: disable=invalid-name { - "@id": '{m}/list/{c}'.format(m=obj.manifest.baseurl, c=obj.pid), + "@id": "{m}/list/{c}".format(m=obj.manifest.baseurl, c=obj.pid), "@type": "sc:AnnotationList", - "label": "OCR Text" + "label": "OCR Text", } ] - # - # Only list annotation lists for current user. I'm going to leave the code below - # for when eventually implement groups. - # - # for user in USER.objects.filter(userannotation__canvas=obj).distinct(): - # kwargs = { - # 'username': user.username, - # 'volume': obj.manifest.pid, - # 'canvas': obj.pid - # } - # url = "{h}{k}".format( - # h=settings.HOSTNAME, - # k=reverse('user_annotations', kwargs=kwargs) - # ) - # user_endpoint = { - # "label": 'Annotations by {u}'.format(u=user.username), - # "@type": "sc:AnnotationList", - # "@id": url - # } - # otherContent.append(user_endpoint) + current_user_has_annotations = ( self.current_user and self.current_user.is_authenticated @@ -53,21 +43,22 @@ def get_dump_object(self, obj): ) if current_user_has_annotations: kwargs = { - 'username': self.current_user.username, - 'volume': obj.manifest.pid, - 'canvas': obj.pid + "username": self.current_user.username, + "volume": obj.manifest.pid, + "canvas": obj.pid, } - url = "{h}{k}".format( - h=settings.HOSTNAME, - k=reverse('user_annotations', kwargs=kwargs) + annotation_list_url = "{h}{k}".format( + h=settings.HOSTNAME, k=reverse("user_annotations", kwargs=kwargs) ) + otherContent.append( { - "label": 'Annotations by {u}'.format(u=self.current_user.username), + "label": f"Annotations by {self.current_user.username}", "@type": "sc:AnnotationList", - "@id": url + "@id": annotation_list_url, } ) + data = { "@context": "http://iiif.io/api/presentation/2/context.json", "@id": obj.identifier, @@ -82,7 +73,9 @@ def get_dump_object(self, obj): "@type": "oa:Annotation", "motivation": "sc:painting", "resource": { - "@id": '{id}/full/full/0/default.jpg'.format(id=obj.resource_id), + "@id": "{id}/full/full/0/default.jpg".format( + id=obj.resource_id + ), "@type": "dctypes:Image", "format": "image/jpeg", "height": obj.height, @@ -90,27 +83,27 @@ def get_dump_object(self, obj): "service": { "@context": "https://iiif.io/api/image/2/context.json", "@id": obj.resource_id, - "profile": "https://iiif.io/api/image/2/level2.json" - } + "profile": "https://iiif.io/api/image/2/level2.json", + }, }, "on": obj.identifier, } ], - "thumbnail" : { - "@id" : obj.thumbnail, - "height": 250, - "width": 200 - }, - "otherContent" : otherContent + "thumbnail": {"@id": obj.thumbnail, "height": 250, "width": 200}, + "otherContent": otherContent, } return data # TODO: Should probably return a helpful error. return None -class Deserializer: - """Deserialize IIIF Annotation List - :raises SerializerDoesNotExist: Not yet implemented. - """ - def __init__(self, *args, **kwargs): - raise SerializerDoesNotExist("canvas is a serialization-only serializer") +def Deserializer(data): + """Deserialize IIIF Canvas""" + + if isinstance(data, str): + data = json.loads(data) + + if "@context" in data and "2/context.json" in data["@context"]: + return deserialize("canvas_v2", data) + + return deserialize("canvas_v3", data) diff --git a/apps/iiif/serializers/collection_manifest.py b/apps/iiif/serializers/collection_manifest.py index c95e683b5..1a4d3d055 100644 --- a/apps/iiif/serializers/collection_manifest.py +++ b/apps/iiif/serializers/collection_manifest.py @@ -4,22 +4,28 @@ import config.settings.local as settings from apps.iiif.serializers.base import Serializer as JSONSerializer + class Serializer(JSONSerializer): """IIIF Collection""" + def get_dump_object(self, obj): - if ((self.version == 'v2') or (self.version is None)): + if (self.version == "v2") or (self.version is None): data = { - "@id": '{h}/iiif/{p}/manifest'.format(h=settings.HOSTNAME, p=obj.pid), + "@id": "{h}/iiif/{p}/manifest".format(h=settings.HOSTNAME, p=obj.pid), "@type": "sc:Manifest", "label": obj.label, } return data return None + class Deserializer: """Deserialize IIIF Annotation List :raises SerializerDoesNotExist: Not yet implemented. """ + def __init__(self, *args, **kwargs): - raise SerializerDoesNotExist("collection_manifest is a serialization-only serializer") + raise SerializerDoesNotExist( + "collection_manifest is a serialization-only serializer" + ) diff --git a/apps/iiif/serializers/kollection.py b/apps/iiif/serializers/kollection.py index 74ada99c1..f129fdfbb 100644 --- a/apps/iiif/serializers/kollection.py +++ b/apps/iiif/serializers/kollection.py @@ -6,18 +6,18 @@ import config.settings.local as settings from apps.iiif.serializers.base import Serializer as JSONSerializer + class Serializer(JSONSerializer): """ IIIF Collection """ + def get_dump_object(self, obj): - if ((self.version == 'v2') or (self.version is None)): + if (self.version == "v2") or (self.version is None): data = { "@context": "http://iiif.io/api/presentation/2/context.json", - "@id": '{h}/iiif/{v}/{p}/collection'.format( - h=settings.HOSTNAME, - v=self.version, - p=obj.pid + "@id": "{h}/iiif/{v}/{p}/collection".format( + h=settings.HOSTNAME, v=self.version, p=obj.pid ), "@type": "sc:Collection", "label": obj.label, @@ -25,20 +25,18 @@ def get_dump_object(self, obj): "description": obj.summary, "attribution": obj.attribution, "manifests": json.loads( - serialize( - 'collection_manifest', - obj.manifests.all(), - is_list=True - ) - ) + serialize("collection_manifest", obj.manifests.all(), is_list=True) + ), } return data return None + class Deserializer: """Deserialize IIIF Annotation List :raises SerializerDoesNotExist: Not yet implemented. """ + def __init__(self, *args, **kwargs): raise SerializerDoesNotExist("kollection is a serialization-only serializer") diff --git a/apps/iiif/serializers/manifest.py b/apps/iiif/serializers/manifest.py index 348aefc1b..4cff15701 100644 --- a/apps/iiif/serializers/manifest.py +++ b/apps/iiif/serializers/manifest.py @@ -2,46 +2,57 @@ """Module for serializing IIIF Annotation Lists""" import json from datetime import datetime -from django.core.serializers.base import SerializerDoesNotExist -from django.core.serializers import serialize +from django.core.serializers import serialize, deserialize from apps.iiif.canvases.models import Canvas from apps.iiif.serializers.base import Serializer as JSONSerializer + class Serializer(JSONSerializer): """ Convert a queryset to IIIF Manifest """ + def _init_options(self): super()._init_options() - self.annotators = self.json_kwargs.pop('annotators', 0) + self.annotators = self.json_kwargs.pop("annotators", 0) # if 'exportdate' in self.json_kwargs: - self.exportdate = self.json_kwargs.pop('exportdate', datetime.utcnow()) - self.current_user = self.json_kwargs.pop('current_user', None) + self.exportdate = self.json_kwargs.pop("exportdate", datetime.utcnow()) + self.current_user = self.json_kwargs.pop("current_user", None) # else: # self.exportdate = def start_serialization(self): self._init_options() - self.stream.write('') + self.stream.write("") def end_serialization(self): - self.stream.write('') + self.stream.write("") + + def serialize_metadata(self, obj): + """Convert metadata on object into list of {label, value} dicts""" + if isinstance(obj.metadata, list): + # most common case: metadata is already a list of {label, value} dicts + return obj.metadata + elif isinstance(obj.metadata, dict): + # convert dict into list of label/value pair dicts + return [{"label": key, "value": val} for (key, val) in obj.metadata.items()] + else: + return [] def get_dump_object(self, obj): # TODO: Raise error if version is not v2 or v3 - if self.version == 'v2' or self.version is None: + if self.version == "v2" or self.version is None: within = [] for col in obj.collections.all(): within.append(col.get_absolute_url()) try: - thumbnail = '{h}/{p}'.format( - h=obj.image_server.server_base, - p=obj.start_canvas.resource + thumbnail = "{h}/{p}".format( + h=obj.image_server.server_base, p=obj.start_canvas.resource ) except (Canvas.MultipleObjectsReturned, Canvas.DoesNotExist): - thumbnail = '{h}/{p}'.format( + thumbnail = "{h}/{p}".format( h=obj.image_server.server_base, - p=obj.canvas_set.all().first().resource + p=obj.canvas_set.all().first().resource, ) if obj.metadata == {}: @@ -49,50 +60,24 @@ def get_dump_object(self, obj): data = { "@context": "http://iiif.io/api/presentation/2/context.json", - "@id": "%s/manifest" % (obj.baseurl), + "@id": f"{obj.v2_baseurl}/manifest", "@type": "sc:Manifest", "label": obj.label, "metadata": [ - { - "label": "Author", - "value": obj.author - }, - { - "label": "Publisher", - "value": obj.publisher - }, - { - "label": "Place of Publication", - "value": obj.published_city - }, - { - "label": "Publication Date", - "value": obj.published_date - }, - { - "label": "Notes", - "value": obj.metadata - }, - { - "label": "Record Created", - "value": obj.created_at - }, - { - "label": "Edition Type", - "value": "Readux IIIF Exported Edition" - }, + {"label": "Author", "value": obj.author}, + {"label": "Publisher", "value": obj.publisher}, + {"label": "Place of Publication", "value": obj.published_city}, + {"label": "Publication Date", "value": obj.published_date}, + {"label": "Record Created", "value": obj.created_at}, + {"label": "Edition Type", "value": "Readux IIIF Exported Edition"}, { "label": "About Readux", - "value": "https://readux.ecdsdev.org/about/" + "value": "https://readux.ecdsdev.org/about/", }, - { - "label": "Annotators", - "value": self.annotators - }, - { - "label": "Export Date", - "value": self.exportdate - } + {"label": "Annotators", "value": self.annotators}, + {"label": "Export Date", "value": self.exportdate}, + # unpack serialized metadata (list of label, value pairs) + *self.serialize_metadata(obj), ], "description": obj.summary, "related": obj.related_links, @@ -102,11 +87,11 @@ def get_dump_object(self, obj): "service": { "@context": "http://iiif.io/api/image/2/context.json", "@id": thumbnail, - "profile": "http://iiif.io/api/image/2/level1.json" - } + "profile": "http://iiif.io/api/image/2/level1.json", + }, }, "attribution": obj.attribution, - "logo": obj.thumbnail_logo, + "logo": obj.logo_url or (obj.logo.url if obj.logo else ""), "license": obj.license, "viewingDirection": obj.viewingdirection, "viewingHint": "paged", @@ -118,24 +103,27 @@ def get_dump_object(self, obj): "startCanvas": obj.start_canvas.identifier, "canvases": json.loads( serialize( - 'canvas', + "canvas", obj.canvas_set.all(), is_list=True, - current_user=self.current_user + current_user=self.current_user, ) - ) + ), } - ] + ], + "seeAlso": obj.see_also_links, } - if obj.relatedlink_set.exists(): - data["seeAlso"] = [related.link for related in obj.relatedlink_set.all()] return data return None -class Deserializer: - """Deserialize IIIF Annotation List - :raises SerializerDoesNotExist: Not yet implemented. - """ - def __init__(self, *args, **kwargs): - raise SerializerDoesNotExist("manifest is a serialization-only serializer") +def Deserializer(data): + """Deserialize IIIF Manifest""" + + if isinstance(data, str): + data = json.loads(data) + + if "@context" in data and "2/context.json" in data["@context"]: + return deserialize("manifest_v2", data) + + return deserialize("manifest_v3", data) diff --git a/apps/iiif/serializers/tests.py b/apps/iiif/serializers/tests.py index 85692c403..1806b2713 100644 --- a/apps/iiif/serializers/tests.py +++ b/apps/iiif/serializers/tests.py @@ -1,39 +1,198 @@ """Test Module for IIIF Serializers""" + from django.test import TestCase -from django.core.serializers import serialize, deserialize, SerializerDoesNotExist +from django.core.serializers import serialize, deserialize +from apps.iiif.annotations.tests.factories import AnnotationFactory from apps.iiif.canvases.models import Canvas +from apps.iiif.canvases.tests.factories import CanvasFactory +from apps.iiif.manifests.tests.factories import ManifestFactory +from apps.readux.models import UserAnnotation +from apps.users.tests.factories import UserFactory + class SerializerTests(TestCase): serializers = [ - 'annotation_list', 'annotation', 'canvas', - 'collection_manifest', 'kollection', - 'manifest', 'user_annotation_list' + "annotation_list", + "annotation", + "canvas", + "collection_manifest", + "kollection", + "manifest", + "user_annotation_list", ] fixtures = [ - 'users.json', - 'kollections.json', - 'manifests.json', - 'canvases.json', - 'annotations.json' + "users.json", + "kollections.json", + "manifests.json", + "canvases.json", + "annotations.json", ] - - def test_deserialization(self): - """Deserialization should raise for serialization only error.""" - for serializer in self.serializers: - if serializer != 'annotation': - try: - deserialize(serializer, {}) - except SerializerDoesNotExist as error: - assert str(error) == "'{s} is a serialization-only serializer'".format(s=serializer) + # def test_no_deserialization(self): + # """Deserialization should raise for serialization only error.""" + # for serializer in self.serializers: + # if serializer != "annotation": + # try: + # deserialize(serializer, {}) + # except SerializerDoesNotExist as error: + # assert str( + # error + # ) == "'{s} is a serialization-only serializer'".format(s=serializer) def test_empty_object(self): """If specified version is not implemented, serializer returns an empty dict.""" for serializer in self.serializers: obj = serialize( - serializer, - Canvas.objects.all(), - version='Some Random Version' + serializer, Canvas.objects.all(), version="Some Random Version" ) - assert 'null' in obj + assert "null" in obj + + def test_web_annotation_comment_fragment_deserialization(self): + user = UserFactory.create() + canvas = CanvasFactory.create(manifest=ManifestFactory.create()) + web_annotation = { + "type": "Annotation", + "motivation": "commenting", + "body": [ + { + "purpose": "commenting", + "type": "TextualBody", + "created": "2022-06-06T20:29:08.108Z", + "format": "text/html", + "value": "

Really smart annotation

", + "creator": {"id": user.username, "name": user.name}, + "modified": "2022-06-06T20:29:08.108Z", + } + ], + "target": { + "source": canvas.resource_id, + "selector": { + "type": "FragmentSelector", + "conformsTo": "http://www.w3.org/TR/media-frags/", + "value": "xywh=pixel:575.7898559570312,1263.1917724609375,677.0464477539062,737.7884521484375", + }, + }, + "@context": "http://www.w3.org/ns/anno.jsonld", + "id": "#51602663-36ee-4692-a327-2f438daf48a9", + } + + deserialized_annotation, _ = deserialize("annotation", web_annotation) + annotation = UserAnnotation(**deserialized_annotation) + assert annotation.owner == user + assert annotation.canvas == canvas + assert annotation.content == web_annotation["body"][0]["value"] + assert annotation.x == 575.7898559570312 + assert annotation.y == 1263.1917724609375 + assert annotation.w == 677.0464477539062 + assert annotation.h == 737.7884521484375 + + def test_web_annotation_comment_svg_deserialization(self): + user = UserFactory.create() + canvas = CanvasFactory.create(manifest=ManifestFactory.create()) + web_annotation = { + "type": "Annotation", + "motivation": "commenting", + "body": [ + { + "purpose": "commenting", + "type": "TextualBody", + "created": "2022-06-06T20:38:37.123Z", + "value": "

Even smarter annotation

", + "creator": {"id": user.username, "name": user.name}, + "modified": "2022-06-06T20:38:37.123Z", + }, + { + "type": "TextualBody", + "value": "Tag One", + "purpose": "tagging", + "creator": {"id": user.username, "name": user.name}, + "created": "2022-06-06T20:38:43.052Z", + "modified": "2022-06-06T20:38:43.720Z", + }, + ], + "target": { + "source": canvas.resource_id, + "selector": { + "type": "SvgSelector", + "value": '', + "refinedBy": { + "type": "FragmentSelector", + "value": "xywh=448.96453857421875,1384.190185546875,1224.1728515625,1224.173095703125", + }, + }, + }, + "@context": "http://www.w3.org/ns/anno.jsonld", + "id": "#db7cd136-cdb7-4b1e-b33c-f0afd66c1aee", + } + + deserialized_annotation, tags = deserialize("annotation", web_annotation) + annotation = UserAnnotation(**deserialized_annotation) + annotation.save() + for tag in tags: + annotation.tags.add(tag) + assert annotation.owner == user + assert annotation.canvas == canvas + assert annotation.content == web_annotation["body"][0]["value"] + assert "Tag One" in annotation.tag_list + assert annotation.svg + assert annotation.x == 448.96453857421875 + assert annotation.y == 1384.190185546875 + assert annotation.w == 1224.1728515625 + assert annotation.h == 1224.173095703125 + + def test_web_annotation_comment_range_deserialization(self): + user = UserFactory.create() + canvas = CanvasFactory.create(manifest=ManifestFactory.create()) + start = AnnotationFactory.create(canvas=canvas, order=3) + end = AnnotationFactory.create(canvas=canvas, order=13) + web_annotation = { + "type": "Annotation", + "motivation": "commenting", + "body": [ + { + "type": "TextualBody", + "value": "

Yet another annotation

", + "purpose": "commenting", + "creator": {"id": user.username, "name": user.name}, + } + ], + "target": { + "source": canvas.resource_id, + "selector": { + "type": "RangeSelector", + "startSelector": { + "@type": "XPathSelector", + "value": f"//*[@id='{start.pk}']", + "refinedBy": {"@type": "TextPositionSelector", "start": 1}, + }, + "endSelector": { + "type": "XPathSelector", + "value": f"//*[@id='{end.pk}']", + "refinedBy": {"@type": "TextPositionSelector", "end": 5}, + }, + }, + }, + "@context": "http://www.w3.org/ns/anno.jsonld", + "id": "#da324841-beaa-4710-858e-f128580c6f2d", + } + deserialized_annotation, tags = deserialize("annotation", web_annotation) + annotation = UserAnnotation(**deserialized_annotation) + annotation.save() + for tag in tags: + annotation.tags.add(tag) + assert annotation.owner == user + assert annotation.canvas == canvas + assert annotation.content == web_annotation["body"][0]["value"] + assert annotation.start_selector == start + assert ( + annotation.start_offset + == web_annotation["target"]["selector"]["startSelector"]["refinedBy"][ + "start" + ] + ) + assert annotation.end_selector == end + assert ( + annotation.end_offset + == web_annotation["target"]["selector"]["endSelector"]["refinedBy"]["end"] + ) diff --git a/apps/iiif/serializers/user_annotation_list.py b/apps/iiif/serializers/user_annotation_list.py index 6073726b8..2bb435aba 100644 --- a/apps/iiif/serializers/user_annotation_list.py +++ b/apps/iiif/serializers/user_annotation_list.py @@ -3,42 +3,47 @@ import json from django.core.serializers.base import SerializerDoesNotExist from django.core.serializers import serialize -from apps.iiif.serializers.annotation_list import Serializer as IIIFAnnotationListSerializer +from apps.iiif.serializers.annotation_list import ( + Serializer as IIIFAnnotationListSerializer, +) import config.settings.local as settings + class Serializer(IIIFAnnotationListSerializer): """ IIIF V2 Annotation List https://iiif.io/api/presentation/2.1/#annotation-list """ def get_dump_object(self, obj): - if ((self.version == 'v2') or (self.version is None)): + if (self.version == "v2") or (self.version is None): data = { "@context": "http://iiif.io/api/presentation/2/context.json", - "@id": '{h}/annotations/{u}/{m}/list/{c}'.format( + "@id": "{h}/annotations/{u}/{m}/list/{c}".format( h=settings.HOSTNAME, u=self.owners[0].username, m=obj.manifest.pid, - c=obj.pid + c=obj.pid, ), "@type": "sc:AnnotationList", "resources": json.loads( serialize( - 'annotation', - obj.userannotation_set.filter( - owner__in=[self.owners[0].id] - ), - is_list=True + "annotation", + obj.userannotation_set.filter(owner__in=[self.owners[0].id]), + is_list=True, ) - ) + ), } return data return None + class Deserializer: """Deserialize IIIF Annotation List :raises SerializerDoesNotExist: Not yet implemented. """ + def __init__(self, *args, **kwargs): - raise SerializerDoesNotExist("user_annotation_list is a serialization-only serializer") + raise SerializerDoesNotExist( + "user_annotation_list is a serialization-only serializer" + ) diff --git a/apps/iiif/serializers/v2/annotation.py b/apps/iiif/serializers/v2/annotation.py new file mode 100644 index 000000000..e880a531b --- /dev/null +++ b/apps/iiif/serializers/v2/annotation.py @@ -0,0 +1,101 @@ +"""Module for serializing IIIF V2 Annotation Lists""" + +import json +from re import findall +from bs4 import BeautifulSoup +from django.core.serializers import deserialize +from django.contrib.auth import get_user_model +from apps.iiif.serializers.annotation import Serializer as AnnotationSerializer +from apps.iiif.annotations.models import Annotation +from apps.iiif.annotations.choices import AnnotationPurpose, AnnotationSelector +from apps.iiif.canvases.models import Canvas + +USER = get_user_model() + + +class Serializer(AnnotationSerializer): + """Convert a queryset to IIIF Annotation""" + + +def Deserializer(data): # pylint: disable=invalid-name + """Deserialize V2 Annotation. + + Args: + data (dict): V2 IIIF Annotation + + Returns: + annotation: annotation dict + """ + + if isinstance(data, str): + data = json.loads(data) + + annotation = { + "id": data["@id"], + "owner": USER.objects.get(name=data["annotatedBy"]["name"]), + } + tags = [] + + if data["motivation"] == "oa:commenting": + annotation["motivation"] = Annotation.OA_COMMENTING + annotation["purpose"] = AnnotationPurpose("CM") + + if data["motivation"] == "oa:painting": + annotation["motivation"] = Annotation.SC_PAINTING + annotation["purpose"] = AnnotationPurpose("SP") + + annotation["primary_selector"] = AnnotationSelector("FR") + source_parts = data["on"]["full"].split("/") + canvas_pid = source_parts[-1] if source_parts[-1] != "canvas" else source_parts[-2] + annotation["canvas"] = Canvas.objects.get(pid=canvas_pid) + + resources = ( + data["resource"] if isinstance(data["resource"], list) else [data["resource"]] + ) + + for resource in resources: + if resource["@type"] == "cnt:ContentAsText": + annotation["resource_type"] = Annotation.OCR + if resource["@type"] == "dctypes:Text": + annotation["resource_type"] = Annotation.TEXT + + if resource["@type"] == "oa:Tag": + tags.append(resource["chars"]) + else: + annotation["content"] = resource["chars"] + soup = BeautifulSoup(resource["chars"], "html.parser") + annotation["raw_content"] = soup.get_text(separator=" ", strip=True) + + annotation["x"], annotation["y"], annotation["w"], annotation["h"] = [ + float(n) for n in data["on"]["selector"]["value"].split("=")[-1].split(",") + ] + + if data["on"]["selector"]["item"]["@type"] == "oa:SvgSelector": + annotation["svg"] = data["on"]["selector"]["item"]["value"] + + if data["on"]["selector"]["item"]["@type"] == "RangeSelector": + annotation["primary_selector"] = AnnotationSelector("RG") + annotation["start_selector"], _ = Annotation.objects.get_or_create( + pk=findall( + r"([A-Za-z0-9\-]+)", + data["on"]["selector"]["item"]["startSelector"]["value"], + )[-1] + ) + annotation["end_selector"], _ = Annotation.objects.get_or_create( + pk=findall( + r"([A-Za-z0-9\-]+)", + data["on"]["selector"]["item"]["endSelector"]["value"], + )[-1] + ) + annotation["start_offset"] = data["on"]["selector"]["item"]["startSelector"][ + "refinedBy" + ]["start"] + + annotation["end_offset"] = data["on"]["selector"]["item"]["endSelector"][ + "refinedBy" + ]["end"] + + return ( + annotation, + tags, + ) diff --git a/apps/iiif/serializers/v2/canvas.py b/apps/iiif/serializers/v2/canvas.py new file mode 100644 index 000000000..41eb3d5ed --- /dev/null +++ b/apps/iiif/serializers/v2/canvas.py @@ -0,0 +1,26 @@ +"""Module for serializing IIIF V2 Canvas Lists""" + +import re +from apps.iiif.serializers.canvas import Serializer as CanvasSerializer +from apps.iiif.manifests.models import Manifest + + +class Serializer(CanvasSerializer): + """Convert a queryset to IIIF Canvas""" + + +def Deserializer(data): # pylint: disable=invalid-name + """Deserialize V2 Canvas. + + Args: + data (dict): V2 IIIF Canvas + + Returns: + dict: canvas dict + """ + return { + "pid": data["@id"].split("/")[-1], + "resource": data["images"][0]["resource"]["service"]["@id"].split("/")[-1], + "width": data["images"][0]["resource"]["width"], + "height": data["images"][0]["resource"]["height"], + } diff --git a/apps/iiif/serializers/v2/fixtures/v2_manifest.json b/apps/iiif/serializers/v2/fixtures/v2_manifest.json new file mode 100644 index 000000000..59dd53d4e --- /dev/null +++ b/apps/iiif/serializers/v2/fixtures/v2_manifest.json @@ -0,0 +1,120 @@ +{ + "@context": "http://iiif.io/api/presentation/2/context.json", + "@id": "https://readux.io/iiif/v2/spb1b/manifest", + "@type": "sc:Manifest", + "label": "CSM Entry Checklist (S/N 1001)", + "metadata": [ + { + "label": "Author", + "value": "NASA" + }, + { + "label": "Publisher", + "value": "NASA" + }, + { + "label": "Place of Publication", + "value": "Place of publication" + }, + { + "label": "Publication Date", + "value": "1971" + }, + { + "label": "Notes", + "value": null + }, + { + "label": "Record Created", + "value": "2020-05-26T18:15:14.606Z" + }, + { + "label": "Edition Type", + "value": "Readux IIIF Exported Edition" + }, + { + "label": "About Readux", + "value": "https://readux.ecdsdev.org/about/" + }, + { + "label": "Annotators", + "value": "Jay Varner" + }, + { + "label": "Export Date", + "value": "2025-07-03T13:32:36.782" + } + ], + "description": "CSM Entry Checklist (S/N 1001) from the Apollo 15 Flight Data File (FDF). \"There are two CSM Launch Checklists, two LM Activation Checklists, and two CSM Entry Checklists - one each for the CDR [commander] and for either the CMP [command module pilot] or LMP [lunar module pilot]. We each had one to expedite the timeline and not need to pass a checklist back and forth.\" - Col. Scott", + "related": ["https://readux.io/volume/spb1b/page/all"], + "within": ["https://readux.io/collection/apollo-15"], + "thumbnail": { + "@id": "https://iip.readux.io/iiif/2/spb1b_000.tif/full/600,/0/default.jpg", + "service": { + "@context": "http://iiif.io/api/image/2/context.json", + "@id": "https://iip.readux.io/iiif/2/spb1b_000.tif", + "profile": "http://iiif.io/api/image/2/level1.json" + } + }, + "attribution": "Emory University Libraries", + "logo": "https://readux.io/media/logos/lits-logo-web.png", + "license": "https://creativecommons.org/publicdomain/zero/1.0/", + "viewingDirection": "left-to-right", + "viewingHint": "paged", + "sequences": [ + { + "@id": "https://readux.io/iiif/v2/spb1b/sequence/normal", + "@type": "sc:Sequence", + "label": "Current Page Order", + "startCanvas": "https://readux.io/iiif/spb1b/canvas/spb1b_000.tif", + "canvases": [ + { + "@context": "http://iiif.io/api/presentation/2/context.json", + "@id": "https://readux.io/iiif/spb1b/canvas/spb1b_000.tif", + "@type": "sc:Canvas", + "label": "1", + "height": 3600, + "width": 2814, + "images": [ + { + "@context": "http://iiif.io/api/presentation/2/context.json", + "@id": "https://readux.io/iiif/spb1b/annotation/spb1b_000.tif", + "@type": "oa:Annotation", + "motivation": "sc:painting", + "resource": { + "@id": "https://iip.readux.io/iiif/2/spb1b_000.tif/full/full/0/default.jpg", + "@type": "dctypes:Image", + "format": "image/jpeg", + "height": 3600, + "width": 2814, + "service": { + "@context": "https://iiif.io/api/image/2/context.json", + "@id": "https://iip.readux.io/iiif/2/spb1b_000.tif", + "profile": "https://iiif.io/api/image/2/level2.json" + } + }, + "on": "https://readux.io/iiif/spb1b/canvas/spb1b_000.tif" + } + ], + "thumbnail": { + "@id": "https://iip.readux.io/iiif/2/spb1b_000.tif/full/200,/0/default.jpg", + "height": 250, + "width": 200 + }, + "otherContent": [ + { + "@id": "https://readux.io/iiif/v2/spb1b/list/spb1b_000.tif", + "@type": "sc:AnnotationList", + "label": "OCR Text" + }, + { + "label": "Annotations by jay", + "@type": "sc:AnnotationList", + "@id": "https://readux.io/annotations/jay/spb1b/list/spb1b_000.tif" + } + ] + } + ] + } + ] +} diff --git a/apps/iiif/serializers/v2/fixtures/v2_ocr.json b/apps/iiif/serializers/v2/fixtures/v2_ocr.json new file mode 100644 index 000000000..deee6fec7 --- /dev/null +++ b/apps/iiif/serializers/v2/fixtures/v2_ocr.json @@ -0,0 +1,449 @@ +{ + "@context": "http://iiif.io/api/presentation/2/context.json", + "@id": "https://readux.io/iiif/v2/spb1b/list/spb1b_000.tif", + "@type": "sc:AnnotationList", + "resources": [ + { + "@context": "http://iiif.io/api/presentation/2/context.json", + "@id": "fc3320b8-951f-414f-8bf2-dca64555c8be", + "@type": "oa:Annotation", + "motivation": "sc:painting", + "annotatedBy": { + "name": "OCR" + }, + "resource": { + "@type": "cnt:ContentAsText", + "format": "text/html", + "chars": "APOLLO", + "language": "en" + }, + "on": { + "full": "https://readux.io/iiif/v2/spb1b/canvas/spb1b_000.tif", + "@type": "oa:SpecificResource", + "within": { + "@id": "https://readux.io/iiif/v2/spb1b/manifest", + "@type": "sc:Manifest" + }, + "selector": { + "@type": "oa:FragmentSelector", + "value": "xywh=1256,806,340,69", + "item": { + "@type": "oa:FragmentSelector" + } + } + }, + "stylesheet": { + "type": "CssStylesheet", + "value": ".anno-fc3320b8-951f-414f-8bf2-dca64555c8be: { height: 69px; width: 340px; font-size: 43.125px; }" + } + }, + { + "@context": "http://iiif.io/api/presentation/2/context.json", + "@id": "e90bfe36-572b-47d7-8743-4d881d82ee3c", + "@type": "oa:Annotation", + "motivation": "sc:painting", + "annotatedBy": { + "name": "OCR" + }, + "resource": { + "@type": "cnt:ContentAsText", + "format": "text/html", + "chars": "15", + "language": "en" + }, + "on": { + "full": "https://readux.io/iiif/v2/spb1b/canvas/spb1b_000.tif", + "@type": "oa:SpecificResource", + "within": { + "@id": "https://readux.io/iiif/v2/spb1b/manifest", + "@type": "sc:Manifest" + }, + "selector": { + "@type": "oa:FragmentSelector", + "value": "xywh=1661,808,36,67", + "item": { + "@type": "oa:FragmentSelector" + } + } + }, + "stylesheet": { + "type": "CssStylesheet", + "value": ".anno-e90bfe36-572b-47d7-8743-4d881d82ee3c: { height: 67px; width: 36px; font-size: 41.875px; }" + } + }, + { + "@context": "http://iiif.io/api/presentation/2/context.json", + "@id": "5a1b56bb-b32d-419a-969a-0465982c8a0f", + "@type": "oa:Annotation", + "motivation": "sc:painting", + "annotatedBy": { + "name": "OCR" + }, + "resource": { + "@type": "cnt:ContentAsText", + "format": "text/html", + "chars": "CSM", + "language": "en" + }, + "on": { + "full": "https://readux.io/iiif/v2/spb1b/canvas/spb1b_000.tif", + "@type": "oa:SpecificResource", + "within": { + "@id": "https://readux.io/iiif/v2/spb1b/manifest", + "@type": "sc:Manifest" + }, + "selector": { + "@type": "oa:FragmentSelector", + "value": "xywh=1069,982,229,85", + "item": { + "@type": "oa:FragmentSelector" + } + } + }, + "stylesheet": { + "type": "CssStylesheet", + "value": ".anno-5a1b56bb-b32d-419a-969a-0465982c8a0f: { height: 85px; width: 229px; font-size: 53.125px; }" + } + }, + { + "@context": "http://iiif.io/api/presentation/2/context.json", + "@id": "1c112e0a-f070-44ac-b49b-4f037ee7d2b9", + "@type": "oa:Annotation", + "motivation": "sc:painting", + "annotatedBy": { + "name": "OCR" + }, + "resource": { + "@type": "cnt:ContentAsText", + "format": "text/html", + "chars": "ENTRY", + "language": "en" + }, + "on": { + "full": "https://readux.io/iiif/v2/spb1b/canvas/spb1b_000.tif", + "@type": "oa:SpecificResource", + "within": { + "@id": "https://readux.io/iiif/v2/spb1b/manifest", + "@type": "sc:Manifest" + }, + "selector": { + "@type": "oa:FragmentSelector", + "value": "xywh=1461,984,384,87", + "item": { + "@type": "oa:FragmentSelector" + } + } + }, + "stylesheet": { + "type": "CssStylesheet", + "value": ".anno-1c112e0a-f070-44ac-b49b-4f037ee7d2b9: { height: 87px; width: 384px; font-size: 54.375px; }" + } + }, + { + "@context": "http://iiif.io/api/presentation/2/context.json", + "@id": "ece5a9ff-ee53-4106-8177-5b18fe3e1da8", + "@type": "oa:Annotation", + "motivation": "sc:painting", + "annotatedBy": { + "name": "OCR" + }, + "resource": { + "@type": "cnt:ContentAsText", + "format": "text/html", + "chars": "CHECKLIST", + "language": "en" + }, + "on": { + "full": "https://readux.io/iiif/v2/spb1b/canvas/spb1b_000.tif", + "@type": "oa:SpecificResource", + "within": { + "@id": "https://readux.io/iiif/v2/spb1b/manifest", + "@type": "sc:Manifest" + }, + "selector": { + "@type": "oa:FragmentSelector", + "value": "xywh=1164,1108,650,94", + "item": { + "@type": "oa:FragmentSelector" + } + } + }, + "stylesheet": { + "type": "CssStylesheet", + "value": ".anno-ece5a9ff-ee53-4106-8177-5b18fe3e1da8: { height: 94px; width: 650px; font-size: 58.75px; }" + } + }, + { + "@context": "http://iiif.io/api/presentation/2/context.json", + "@id": "2e31ae5a-d662-401b-9385-9eed5e370cfe", + "@type": "oa:Annotation", + "motivation": "sc:painting", + "annotatedBy": { + "name": "OCR" + }, + "resource": { + "@type": "cnt:ContentAsText", + "format": "text/html", + "chars": "PART", + "language": "en" + }, + "on": { + "full": "https://readux.io/iiif/v2/spb1b/canvas/spb1b_000.tif", + "@type": "oa:SpecificResource", + "within": { + "@id": "https://readux.io/iiif/v2/spb1b/manifest", + "@type": "sc:Manifest" + }, + "selector": { + "@type": "oa:FragmentSelector", + "value": "xywh=1038,1316,290,71", + "item": { + "@type": "oa:FragmentSelector" + } + } + }, + "stylesheet": { + "type": "CssStylesheet", + "value": ".anno-2e31ae5a-d662-401b-9385-9eed5e370cfe: { height: 71px; width: 290px; font-size: 44.375px; }" + } + }, + { + "@context": "http://iiif.io/api/presentation/2/context.json", + "@id": "d26b5984-e2d0-40ed-99f4-069ca8e8d2c3", + "@type": "oa:Annotation", + "motivation": "sc:painting", + "annotatedBy": { + "name": "OCR" + }, + "resource": { + "@type": "cnt:ContentAsText", + "format": "text/html", + "chars": "NO", + "language": "en" + }, + "on": { + "full": "https://readux.io/iiif/v2/spb1b/canvas/spb1b_000.tif", + "@type": "oa:SpecificResource", + "within": { + "@id": "https://readux.io/iiif/v2/spb1b/manifest", + "@type": "sc:Manifest" + }, + "selector": { + "@type": "oa:FragmentSelector", + "value": "xywh=1367,1319,136,70", + "item": { + "@type": "oa:FragmentSelector" + } + } + }, + "stylesheet": { + "type": "CssStylesheet", + "value": ".anno-d26b5984-e2d0-40ed-99f4-069ca8e8d2c3: { height: 70px; width: 136px; font-size: 43.75px; }" + } + }, + { + "@context": "http://iiif.io/api/presentation/2/context.json", + "@id": "5263432e-ea5e-433a-a3b3-7346d7b85a54", + "@type": "oa:Annotation", + "motivation": "sc:painting", + "annotatedBy": { + "name": "OCR" + }, + "resource": { + "@type": "cnt:ContentAsText", + "format": "text/html", + "chars": ".", + "language": "en" + }, + "on": { + "full": "https://readux.io/iiif/v2/spb1b/canvas/spb1b_000.tif", + "@type": "oa:SpecificResource", + "within": { + "@id": "https://readux.io/iiif/v2/spb1b/manifest", + "@type": "sc:Manifest" + }, + "selector": { + "@type": "oa:FragmentSelector", + "value": "xywh=1512,1320,18,68", + "item": { + "@type": "oa:FragmentSelector" + } + } + }, + "stylesheet": { + "type": "CssStylesheet", + "value": ".anno-5263432e-ea5e-433a-a3b3-7346d7b85a54: { height: 68px; width: 18px; font-size: 42.5px; }" + } + }, + { + "@context": "http://iiif.io/api/presentation/2/context.json", + "@id": "59f98d81-e33e-4502-8b10-dec7ca4d36a9", + "@type": "oa:Annotation", + "motivation": "sc:painting", + "annotatedBy": { + "name": "OCR" + }, + "resource": { + "@type": "cnt:ContentAsText", + "format": "text/html", + "chars": "S", + "language": "en" + }, + "on": { + "full": "https://readux.io/iiif/v2/spb1b/canvas/spb1b_000.tif", + "@type": "oa:SpecificResource", + "within": { + "@id": "https://readux.io/iiif/v2/spb1b/manifest", + "@type": "sc:Manifest" + }, + "selector": { + "@type": "oa:FragmentSelector", + "value": "xywh=2033,1322,19,67", + "item": { + "@type": "oa:FragmentSelector" + } + } + }, + "stylesheet": { + "type": "CssStylesheet", + "value": ".anno-59f98d81-e33e-4502-8b10-dec7ca4d36a9: { height: 67px; width: 19px; font-size: 41.875px; }" + } + }, + { + "@context": "http://iiif.io/api/presentation/2/context.json", + "@id": "2e22fc7e-b99c-4af7-9a8b-79f069a2dc3b", + "@type": "oa:Annotation", + "motivation": "sc:painting", + "annotatedBy": { + "name": "OCR" + }, + "resource": { + "@type": "cnt:ContentAsText", + "format": "text/html", + "chars": "/", + "language": "en" + }, + "on": { + "full": "https://readux.io/iiif/v2/spb1b/canvas/spb1b_000.tif", + "@type": "oa:SpecificResource", + "within": { + "@id": "https://readux.io/iiif/v2/spb1b/manifest", + "@type": "sc:Manifest" + }, + "selector": { + "@type": "oa:FragmentSelector", + "value": "xywh=2088,1323,19,67", + "item": { + "@type": "oa:FragmentSelector" + } + } + }, + "stylesheet": { + "type": "CssStylesheet", + "value": ".anno-2e22fc7e-b99c-4af7-9a8b-79f069a2dc3b: { height: 67px; width: 19px; font-size: 41.875px; }" + } + }, + { + "@context": "http://iiif.io/api/presentation/2/context.json", + "@id": "cdd6c9d9-f542-45c9-8613-bf4844109f28", + "@type": "oa:Annotation", + "motivation": "sc:painting", + "annotatedBy": { + "name": "OCR" + }, + "resource": { + "@type": "cnt:ContentAsText", + "format": "text/html", + "chars": "N", + "language": "en" + }, + "on": { + "full": "https://readux.io/iiif/v2/spb1b/canvas/spb1b_000.tif", + "@type": "oa:SpecificResource", + "within": { + "@id": "https://readux.io/iiif/v2/spb1b/manifest", + "@type": "sc:Manifest" + }, + "selector": { + "@type": "oa:FragmentSelector", + "value": "xywh=2135,1323,19,67", + "item": { + "@type": "oa:FragmentSelector" + } + } + }, + "stylesheet": { + "type": "CssStylesheet", + "value": ".anno-cdd6c9d9-f542-45c9-8613-bf4844109f28: { height: 67px; width: 19px; font-size: 41.875px; }" + } + }, + { + "@context": "http://iiif.io/api/presentation/2/context.json", + "@id": "0ceb5275-3ad8-4a69-80d6-378a50308f7c", + "@type": "oa:Annotation", + "motivation": "sc:painting", + "annotatedBy": { + "name": "OCR" + }, + "resource": { + "@type": "cnt:ContentAsText", + "format": "text/html", + "chars": "SKB32100115-305", + "language": "en" + }, + "on": { + "full": "https://readux.io/iiif/v2/spb1b/canvas/spb1b_000.tif", + "@type": "oa:SpecificResource", + "within": { + "@id": "https://readux.io/iiif/v2/spb1b/manifest", + "@type": "sc:Manifest" + }, + "selector": { + "@type": "oa:FragmentSelector", + "value": "xywh=877,1470,733,69", + "item": { + "@type": "oa:FragmentSelector" + } + } + }, + "stylesheet": { + "type": "CssStylesheet", + "value": ".anno-0ceb5275-3ad8-4a69-80d6-378a50308f7c: { height: 69px; width: 733px; font-size: 43.125px; }" + } + }, + { + "@context": "http://iiif.io/api/presentation/2/context.json", + "@id": "0754507c-562d-4b5e-9046-88227d6c0b7d", + "@type": "oa:Annotation", + "motivation": "sc:painting", + "annotatedBy": { + "name": "OCR" + }, + "resource": { + "@type": "cnt:ContentAsText", + "format": "text/html", + "chars": "1001", + "language": "en" + }, + "on": { + "full": "https://readux.io/iiif/v2/spb1b/canvas/spb1b_000.tif", + "@type": "oa:SpecificResource", + "within": { + "@id": "https://readux.io/iiif/v2/spb1b/manifest", + "@type": "sc:Manifest" + }, + "selector": { + "@type": "oa:FragmentSelector", + "value": "xywh=2005,1476,193,67", + "item": { + "@type": "oa:FragmentSelector" + } + } + }, + "stylesheet": { + "type": "CssStylesheet", + "value": ".anno-0754507c-562d-4b5e-9046-88227d6c0b7d: { height: 67px; width: 193px; font-size: 41.875px; }" + } + } + ] +} diff --git a/apps/iiif/serializers/v2/fixtures/v2_user_annotation_list.json b/apps/iiif/serializers/v2/fixtures/v2_user_annotation_list.json new file mode 100644 index 000000000..197e3a4b2 --- /dev/null +++ b/apps/iiif/serializers/v2/fixtures/v2_user_annotation_list.json @@ -0,0 +1,98 @@ +{ + "@context": "http://iiif.io/api/presentation/2/context.json", + "@id": "https://readux.io/annotations/jay/spb1b/list/spb1b_000.tif", + "@type": "sc:AnnotationList", + "resources": [ + { + "@context": "http://iiif.io/api/presentation/2/context.json", + "@id": "28e92e3c-0407-482e-98d9-39ecc1ee0070", + "@type": "oa:Annotation", + "motivation": "oa:commenting", + "annotatedBy": { + "name": "Dominique Wilkins" + }, + "resource": { + "@type": "dctypes:Text", + "format": "text/html", + "chars": "

some annotation

", + "language": "en" + }, + "on": { + "full": "https://readux.io/iiif/v2/spb1b/canvas/spb1b_000.tif", + "@type": "oa:SpecificResource", + "within": { + "@id": "https://readux.io/iiif/v2/spb1b/manifest", + "@type": "sc:Manifest" + }, + "selector": { + "@type": "oa:FragmentSelector", + "value": "xywh=1069,984,776,87", + "item": { + "@type": "RangeSelector", + "startSelector": { + "@type": "XPathSelector", + "value": "//*[@id='5a1b56bb-b32d-419a-969a-0465982c8a0f']", + "refinedBy": { + "@type": "TextPositionSelector", + "start": 0 + } + }, + "endSelector": { + "@type": "XPathSelector", + "value": "//*[@id='ece5a9ff-ee53-4106-8177-5b18fe3e1da8']", + "refinedBy": { + "@type": "TextPositionSelector", + "end": 2 + } + } + } + } + }, + "stylesheet": { + "type": "CssStylesheet", + "value": ".anno-28e92e3c-0407-482e-98d9-39ecc1ee0070 { background: rgba(0, 191, 255, 0.5); }" + } + }, + { + "@context": "http://iiif.io/api/presentation/2/context.json", + "@id": "6c1c7843-832b-46b3-aca9-da957b07be6d", + "@type": "oa:Annotation", + "motivation": ["oa:commenting", "oa:tagging"], + "annotatedBy": { + "name": "Dominique Wilkins" + }, + "resource": [ + { + "@type": "dctypes:Text", + "format": "text/html", + "chars": "

box

", + "language": "en" + }, + { + "@type": "oa:Tag", + "chars": "example" + } + ], + "on": { + "full": "https://readux.io/iiif/v2/spb1b/canvas/spb1b_000.tif", + "@type": "oa:SpecificResource", + "within": { + "@id": "https://readux.io/iiif/v2/spb1b/manifest", + "@type": "sc:Manifest" + }, + "selector": { + "@type": "oa:FragmentSelector", + "value": "xywh=813,1920,527,553", + "item": { + "@type": "oa:Choice", + "value": "", + "default": { + "@type": "oa:FragmentSelector", + "value": "xywh=813,1920,527,553" + } + } + } + } + } + ] +} diff --git a/apps/iiif/serializers/v2/manifest.py b/apps/iiif/serializers/v2/manifest.py new file mode 100644 index 000000000..d6c3ab393 --- /dev/null +++ b/apps/iiif/serializers/v2/manifest.py @@ -0,0 +1,69 @@ +"""Module for serializing IIIF V2 Manifest Lists""" + +import re +from apps.iiif.serializers.manifest import Serializer as ManifestSerializer +from apps.iiif.manifests.models import Manifest + + +class Serializer(ManifestSerializer): + """Convert a queryset to IIIF Manifest""" + + +def parse_fields(attribute): + """Get attributes from V2 Manifest + + Args: + attribute (tuple): _description_ + """ + key, value = attribute + fields = [field.name for field in Manifest._meta.get_fields()] + field = re.sub(r"\([^)]*\)", "", key).strip() + field = field.replace(" ", "_") + field = field.lower() + if field == "publication_date": + return {"published_date": value} + if field == "place_of_publication": + return {"published_city": value} + if field in fields: + return {field: value} + return None + + +def Deserializer(data): # pylint: disable=invalid-name + """Deserialize V2 Manifest. + + Args: + data (dict): V2 IIIF Manifest + + Returns: + tuple: manifest dict and relationships + """ + relations = { + "collections": data["within"], + "related_links": data["seeAlso"], + "canvases": [ + canvas["@id"].split("/")[-1] for canvas in data["sequences"][0]["canvases"] + ], + } + + manifest = { + "pid": data["@id"].split("/")[-2], + "summary": data["description"], + } + + try: + metadata = data.pop("metadata") + for metadatum in metadata: + attribute = parse_fields((metadatum["label"], metadatum["value"])) + if attribute is not None: + manifest = {**manifest, **attribute} + except KeyError: + # Maybe no metadata + pass + + for key, value in data.items(): + attribute = parse_fields((key, value)) + if attribute is not None: + data = {**data, **attribute} + + return (manifest, relations) diff --git a/apps/iiif/serializers/v2/tests/test_annotation.py b/apps/iiif/serializers/v2/tests/test_annotation.py new file mode 100644 index 000000000..cd1ec9cfa --- /dev/null +++ b/apps/iiif/serializers/v2/tests/test_annotation.py @@ -0,0 +1,222 @@ +"""Test Module for IIIF Serializers""" + +import os +import json +from django.test import TestCase +from django.core.serializers import serialize, deserialize +from django.contrib.auth import get_user_model +from django.conf import settings +from apps.iiif.annotations.tests.factories import AnnotationFactory +from apps.iiif.canvases.tests.factories import CanvasFactory +from apps.iiif.manifests.tests.factories import ManifestFactory +from apps.iiif.annotations.models import Annotation +from apps.readux.tests.factories import UserAnnotationFactory +from apps.users.tests.factories import UserFactory + +User = get_user_model() + + +class AnnotationSerializerTests(TestCase): + def setUp(self): + """_summary_""" + self.annotation = Annotation() + + def test_user_annotation_svg_comment_fragment_deserialization(self): + user = UserFactory.create() + canvas = CanvasFactory.create(manifest=ManifestFactory.create()) + user_annotation = UserAnnotationFactory.create( + owner=user, + canvas=canvas, + svg='', + x=1.8, + y=2.7, + w=3.6, + h=4.5, + content="my smart annotation", + ) + web_annotation = serialize("annotation_v2", [user_annotation]) + print(web_annotation) + + deserialized_annotation, _ = deserialize("annotation_v2", web_annotation) + assert deserialized_annotation["owner"] == user + assert deserialized_annotation["canvas"] == canvas + assert deserialized_annotation["svg"] == user_annotation.svg + assert deserialized_annotation["content"] == "my smart annotation" + assert deserialized_annotation["x"] == 1.8 + assert deserialized_annotation["y"] == 2.7 + assert deserialized_annotation["w"] == 3.6 + assert deserialized_annotation["h"] == 4.5 + + def test_user_annotation_text_highlight_deserialization(self): + user = UserFactory.create() + manifest = ManifestFactory.create() + user_annotation = { + "@context": "http://iiif.io/api/presentation/2/context.json", + "@id": "4e268523-7898-4344-a2c9-4036ea3bf965", + "@type": "oa:Annotation", + "motivation": ["oa:commenting", "oa:tagging"], + "annotatedBy": {"name": user.name}, + "resource": [ + { + "@type": "dctypes:Text", + "format": "text/html", + "chars": "

text anno

", + "language": "en", + }, + {"@type": "oa:Tag", "chars": "Amazing"}, + ], + "on": { + "full": f"https://readux.io/iiif/v2/{manifest.pid}/canvas/{manifest.canvas_set.all()[0].pid}", + "@type": "oa:SpecificResource", + "within": { + "@id": f"https://readux.io/iiif/v2/{manifest.pid}/manifest", + "@type": "sc:Manifest", + }, + "selector": { + "@type": "oa:FragmentSelector", + "value": "xywh=1378,4130,602,94", + "item": { + "@type": "RangeSelector", + "startSelector": { + "@type": "XPathSelector", + "value": "//*[@id='a7293772-7218-4a68-a011-86551ac84ed2']", + "refinedBy": {"@type": "TextPositionSelector", "start": 3}, + }, + "endSelector": { + "@type": "XPathSelector", + "value": "//*[@id='a0a585e2-c30f-437f-b18c-36986cfa6e9f']", + "refinedBy": {"@type": "TextPositionSelector", "end": 9}, + }, + }, + }, + }, + "stylesheet": { + "type": "CssStylesheet", + "value": ".anno-4e268523-7898-4344-a2c9-4036ea3bf965 { background: rgba(0, 191, 255, 0.5); }", + }, + } + + deserialized_annotation, _ = deserialize("annotation_v2", user_annotation) + assert deserialized_annotation["id"] == user_annotation["@id"] + assert deserialized_annotation["owner"] == user + assert deserialized_annotation["canvas"] == manifest.canvas_set.all()[0] + assert deserialized_annotation["resource_type"] == self.annotation.TEXT + assert deserialized_annotation["content"] == "

text anno

" + assert deserialized_annotation["raw_content"] == "text anno" + assert deserialized_annotation["x"] == 1378.0 + assert deserialized_annotation["y"] == 4130.0 + assert deserialized_annotation["w"] == 602.0 + assert deserialized_annotation["h"] == 94.0 + assert str(deserialized_annotation["start_selector"].id) == str( + Annotation.objects.get(id="a7293772-7218-4a68-a011-86551ac84ed2").id + ) + assert str(deserialized_annotation["end_selector"].id) == str( + Annotation.objects.get(id="a0a585e2-c30f-437f-b18c-36986cfa6e9f").id + ) + assert deserialized_annotation["start_offset"] == 3 + assert deserialized_annotation["end_offset"] == 9 + + def test_v2_user_annotation_deserialization(self): + """It should deserialize v2 user annotations.""" + user = UserFactory.create(name="Dominique Wilkins", username="wilkins") + CanvasFactory.create(pid="spb1b_000.tif", manifest=ManifestFactory.create()) + with open( + os.path.join( + settings.APPS_DIR, + "iiif", + "serializers", + "v2", + "fixtures", + "v2_user_annotation_list.json", + ) + ) as file: + data = file.read() + deserialized_annotation, tags = deserialize("annotation_list", data) + print(deserialized_annotation) + print(tags) + assert True + + def test_v2_and_v3_create_identical_user_annotations(self): + """Both deserializer versions should return identical objects.""" + manifest = ManifestFactory.create() + canvas = manifest.canvas_set.all()[0] + annotation = UserAnnotationFactory.create(canvas=canvas) + v2_serialized_annotation = serialize("annotation_v2", [annotation]) + v3_serialized_annotation = serialize("annotation_v3", [annotation]) + v2_deserialized_annotation, _ = deserialize( + "annotation", v2_serialized_annotation + ) + v3_deserialized_annotation, _ = deserialize( + "annotation", v3_serialized_annotation + ) + print(v2_deserialized_annotation) + print(v3_deserialized_annotation) + for key in v3_deserialized_annotation.keys(): + assert v3_deserialized_annotation[key] == v2_deserialized_annotation[key] + for key in v2_deserialized_annotation.keys(): + assert v2_deserialized_annotation[key] == v3_deserialized_annotation[key] + + def test_v2_and_v3_create_identical_user_text_annotations(self): + """Both deserializer versions should return identical objects.""" + UserFactory.create(username="wilkins", name="Dominique Wilkins") + manifest = ManifestFactory.create() + canvas = CanvasFactory.create(pid="spb1b_000.tif", manifest=manifest) + AnnotationFactory.create( + id="5a1b56bb-b32d-419a-969a-0465982c8a0f", order=1, canvas=canvas + ) + AnnotationFactory.create( + id="ece5a9ff-ee53-4106-8177-5b18fe3e1da8", order=4, canvas=canvas + ) + for n in range(2, 4): + AnnotationFactory.create(order=n, canvas=canvas) + v2_anno = None + v3_anno = None + with open( + os.path.join( + settings.APPS_DIR, + "iiif", + "serializers", + "v2", + "fixtures", + "v2_user_annotation_list.json", + ), + "r", + encoding="utf8", + ) as file: + v2_anno = json.load(file)["resources"][0] + with open( + os.path.join( + settings.APPS_DIR, + "iiif", + "serializers", + "v3", + "fixtures", + "v3_user_annotation_list.json", + ), + "r", + encoding="utf8", + ) as file: + v3_anno = json.load(file)["items"][0] + + v2_deserialized_annotation, _ = deserialize("annotation", v2_anno) + v3_deserialized_annotation, _ = deserialize("annotation", v3_anno) + print(v2_deserialized_annotation) + print(v3_deserialized_annotation) + for key in v3_deserialized_annotation.keys(): + if key == "start_selector" or key == "end_selector": + assert str(v3_deserialized_annotation[key].id) == str( + v2_deserialized_annotation[key].id + ) + else: + assert ( + v3_deserialized_annotation[key] == v2_deserialized_annotation[key] + ) + for key in v2_deserialized_annotation.keys(): + if key == "start_selector" or key == "end_selector": + assert str(v2_deserialized_annotation[key].id) == str( + v3_deserialized_annotation[key].id + ) + else: + assert ( + v2_deserialized_annotation[key] == v3_deserialized_annotation[key] + ) diff --git a/apps/iiif/serializers/v2/tests/test_manifest.py b/apps/iiif/serializers/v2/tests/test_manifest.py new file mode 100644 index 000000000..f92840520 --- /dev/null +++ b/apps/iiif/serializers/v2/tests/test_manifest.py @@ -0,0 +1,70 @@ +"""Test Module for IIIF Serializers""" + +import json +from random import randint +from django.test import TestCase +from django.core.serializers import serialize, deserialize +from django.contrib.auth import get_user_model +from apps.iiif.annotations.tests.factories import AnnotationFactory +from apps.iiif.canvases.tests.factories import CanvasFactory +from apps.iiif.kollections.tests.factories import CollectionFactory +from apps.iiif.manifests.tests.factories import ManifestFactory +from apps.readux.tests.factories import UserAnnotationFactory +from apps.users.tests.factories import UserFactory + +User = get_user_model() + + +class SerializerTests(TestCase): + def setUp(self): + """_summary_""" + self.manifest = ManifestFactory.create() + self.user = UserFactory.create() + for _ in range(0, 10): + canvas = CanvasFactory.create(manifest=self.manifest) + for _ in range(0, randint(3, 5)): + AnnotationFactory.create(canvas=canvas) + for _ in range(0, randint(3, 5)): + UserAnnotationFactory.create(canvas=canvas, owner=self.user) + for _ in range(0, 2): + self.manifest.collections.add(CollectionFactory.create()) + + self.manifest.refresh_from_db() + + self.serialized_manifest = json.loads( + serialize("manifest_v2", [self.manifest], current_user=self.user) + ) + + def test_manifest(self): + """It should serialize a volume with the correct number of canvases.""" + assert ( + len(self.serialized_manifest["sequences"][0]["canvases"]) + == self.manifest.canvas_set.count() + ) + + def test_deserializer(self): + """It should deserialize an manifest.""" + manifest, relations = deserialize("manifest_v2", self.serialized_manifest) + assert manifest["pid"] == self.manifest.pid + assert manifest["author"] == self.manifest.author + assert manifest["publisher"] == self.manifest.publisher + assert manifest["published_city"] == self.manifest.published_city + assert manifest["published_date"] == self.manifest.published_date + assert len(relations["collections"]) == 2 + + # def test_serializing_complex_metadata(self): + # """It should serialize extra metadata""" + # self.manifest.metadata = complex_metadata() + # self.manifest.save() + # self.manifest.refresh_from_db() + # updated_serialized_manifest = json.loads( + # serialize("manifest_v3", [self.manifest], current_user=self.user) + # ) + # for item in complex_metadata(): + # assert item in updated_serialized_manifest["metadata"] + # assert "Collections" in [ + # md["label"] for md in updated_serialized_manifest["metadata"] + # ] + + def test_deserializing_v2_manifest(self): + """It should serialize extra metadata""" diff --git a/apps/ingest/__init__.py b/apps/iiif/serializers/v3/__init__.py similarity index 100% rename from apps/ingest/__init__.py rename to apps/iiif/serializers/v3/__init__.py diff --git a/apps/iiif/serializers/v3/annotation.py b/apps/iiif/serializers/v3/annotation.py new file mode 100644 index 000000000..634945a69 --- /dev/null +++ b/apps/iiif/serializers/v3/annotation.py @@ -0,0 +1,284 @@ +# pylint: disable = attribute-defined-outside-init, too-few-public-methods +"""Module for serializing IIIF Annotation""" +from bs4 import BeautifulSoup +from re import findall +from django.contrib.auth import get_user_model +from apps.iiif.serializers.base import Serializer as JSONSerializer +from apps.iiif.annotations.models import Annotation +from apps.iiif.annotations.choices import AnnotationPurpose, AnnotationSelector +from apps.iiif.canvases.models import Canvas +from apps.readux.models import UserAnnotation + +USER = get_user_model() + + +class Serializer(JSONSerializer): + """ + Serialize objects based on :class:`apps.iiif.annotation.models.AnnotationAbstract` + Serialization conforms to the Web Annotation Data Model + https://www.w3.org/TR/annotation-model/#annotations + """ + + # def _init_options(self): + # super()._init_options() + + def get_dump_object(self, obj): + """ + Serialize an :class:`apps.iiif.annotation.models.Annotation` + Serialization conforms to the Web Annotation Data Model + https://www.w3.org/TR/annotation-model/#annotations + + { + "type": "Annotation", + "motivation": "commenting" + "body": [ + { + "purpose": "commenting", + "type": "TextualBody", + "created": "2022-06-06T20:38:37.123Z", + "value": "

Super smart annotation

", + "format": "text/html", + "creator": { + "id": "jay", + "name": "Jay S. Varner" + }, + "modified": "2022-06-06T20:38:37.123Z" + }, + { + "type": "TextualBody", + "value": "aaaaa", + "purpose": "tagging", + "formate": "text/plain", + "creator": { + "id": "jay", + "name": "Jay S. Varner" + }, + "created": "2022-06-06T20:38:43.052Z", + "modified": "2022-06-06T20:38:43.720Z" + } + ], + "target": { + "source": "https://iip.readux.io/iiif/2/t4vc6_00000009.tif", + "selector": { + "type": "SvgSelector", + "value": "" + } + }, + "@context": "http://www.w3.org/ns/anno.jsonld", + "id": "#db7cd136-cdb7-4b1e-b33c-f0afd66c1aee" + } + + :param obj: Annotation to be serialized. + :type obj: :class:`apps.iiif.annotation.models.Annotation` + :return: Serialized annotation. + :rtype: dict + """ + + creator = {"id": obj.owner.username, "name": obj.owner.name} + + base_body = { + "creator": creator, + "created": obj.created_at_iso, + "modified": obj.modified_at_iso, + } + + if obj.motivation == Annotation.SC_PAINTING or obj.owner.username == "OCR": + obj.motivation = Annotation.SUP + + if obj.motivation is None: + obj.motivation = Annotation.OA_COMMENTING + + if obj.purpose is None: + obj.purpose = ( + AnnotationPurpose("CM") + if obj.motivation == Annotation.OA_COMMENTING + else Annotation.SUP + ) + + data = { + "@context": "http://www.w3.org/ns/anno.jsonld", + "id": f"#{obj.pk}", + "type": "Annotation", + "motivation": obj.motivation.replace("oa:", ""), + "body": [], + "target": { + "source": obj.canvas.resource_id, + "selector": {"type": AnnotationSelector(obj.primary_selector).name}, + }, + } + + if obj.content: + content_body = { + "type": "TextualBody", + "value": obj.content, + "purpose": AnnotationPurpose(obj.purpose).name, + "format": "text/html" if obj.content_is_html else "text/plain", + **base_body, + } + + data["body"].append(content_body) + + if obj.primary_selector == "SV": + data["target"]["selector"]["value"] = obj.svg + + if obj.primary_selector == "RG" and isinstance(obj, UserAnnotation): + range_selector = { + "startSelector": { + "type": AnnotationSelector("XP").name, + "value": f"//*[@id='{obj.start_selector.pk}']", + "refinedBy": { + "type": AnnotationSelector("TP").name, + "start": obj.start_offset, + }, + }, + "endSelector": { + "type": AnnotationSelector("XP").name, + "value": f"//*[@id='{obj.end_selector.pk}']", + "refinedBy": { + "type": AnnotationSelector("TP").name, + "end": obj.end_offset, + }, + }, + "refinedBy": { + "type": "FragmentSelector", + "value": f"xywh=pixel:{obj.x},{obj.y},{obj.w},{obj.h}", + "conformsTo": "https://www.w3.org/TR/media-frags/", + }, + } + + data["target"]["selector"] = { + **data["target"]["selector"], + **range_selector, + } + + if hasattr(obj, "tag_list"): + for anno_tag in obj.tag_list: + data["body"].append( + { + "type": "TextualBody", + "value": anno_tag, + "purpose": AnnotationPurpose("TG").name, + **base_body, + } + ) + + if obj.primary_selector == "FR": + data["target"]["selector"]["value"] = obj.fragment + data["target"]["selector"][ + "conformsTo" + ] = "http://www.w3.org/TR/media-frags/" + # else: + # fragment_selector = { + # "source": obj.canvas.resource_id, + # "selector": { + # "type": AnnotationSelector("FR").name, + # "value": obj.fragment, + # "conformsTo": "http://www.w3.org/TR/media-frags/", + # }, + # } + + # data['targets'].append(fragment_selector) + + return data + # return None + + # TODO: is this needed? + @classmethod + def __serialize_item(cls, obj): + return obj.item + + +def motivation(value): + if "commenting" in value: + return Annotation.OA_COMMENTING + return Annotation.sup + + +def Deserializer(data): + annotation = {"id": data["id"].replace("#", "")} + annotation["owner"] = USER.objects.get(username=data["body"][0]["creator"]["id"]) + source_parts = data["target"]["source"].split("/") + canvas_pid = source_parts[-1] if source_parts[-1] != "canvas" else source_parts[-2] + annotation["canvas"] = Canvas.objects.get(pid=canvas_pid) + tags = [] + + if "motivation" in data.keys(): + annotation["motivation"] = motivation(data["motivation"]) + else: + annotation["motivation"] = motivation(data["body"][0]["purpose"]) + + for body in data["body"]: + if body["purpose"] == "commenting": + annotation["content"] = body["value"] + annotation["purpose"] = AnnotationPurpose("CM") + if body["purpose"] == "tagging": + tags.append(body["value"]) + elif body["type"] == "TextualBody": + annotation["content"] = body["value"] + soup = BeautifulSoup(body["value"], "html.parser") + annotation["raw_content"] = soup.get_text(separator=" ", strip=True) + if data["body"][0]["creator"]["id"] == "OCR": + annotation["resource_type"] = Annotation.OCR + annotation["motivation"] = Annotation.SUP + annotation["purpose"] = AnnotationPurpose("SP") + else: + annotation["resource_type"] = Annotation.TEXT + + annotation["primary_selector"] = AnnotationSelector[ + data["target"]["selector"]["type"] + ] + + if data["target"]["selector"]["type"] == "SvgSelector": + annotation["svg"] = data["target"]["selector"]["value"] + + if ( + "refinedBy" in data["target"]["selector"] + and data["target"]["selector"]["refinedBy"]["type"] == "FragmentSelector" + ): + annotation["x"], annotation["y"], annotation["w"], annotation["h"] = [ + float(n) + for n in data["target"]["selector"]["refinedBy"]["value"] + .split("=")[-1] + .split(",") + ] + + if data["target"]["selector"]["type"] == "FragmentSelector": + annotation["x"], annotation["y"], annotation["w"], annotation["h"] = [ + float(n) + for n in data["target"]["selector"]["value"].split(":")[-1].split(",") + ] + + if data["target"]["selector"]["type"] == "RangeSelector": + annotation["start_selector"], _ = Annotation.objects.get_or_create( + pk=findall( + r"([A-Za-z0-9\-]+)", + data["target"]["selector"]["startSelector"]["value"], + )[-1] + ) + annotation["end_selector"], _ = Annotation.objects.get_or_create( + pk=findall( + r"([A-Za-z0-9\-]+)", data["target"]["selector"]["endSelector"]["value"] + )[-1] + ) + annotation["start_offset"] = data["target"]["selector"]["startSelector"][ + "refinedBy" + ]["start"] + annotation["end_offset"] = data["target"]["selector"]["endSelector"][ + "refinedBy" + ]["end"] + + try: + annotation["x"], annotation["y"], annotation["w"], annotation["h"] = [ + float(n) + for n in data["target"]["selector"]["refinedBy"]["value"] + .split(":")[-1] + .split(",") + ] + except KeyError: + # If this is not present, it will be calculated on save. + pass + + return ( + annotation, + tags, + ) diff --git a/apps/iiif/serializers/v3/annotation_page.py b/apps/iiif/serializers/v3/annotation_page.py new file mode 100644 index 000000000..e995f8d4a --- /dev/null +++ b/apps/iiif/serializers/v3/annotation_page.py @@ -0,0 +1,60 @@ +# pylint: disable = attribute-defined-outside-init, too-few-public-methods +"""Module for serializing IIIF Annotation Lists""" +import json +from django.core.serializers import serialize, deserialize +from django.core.serializers.base import SerializerDoesNotExist +from django.contrib.auth import get_user_model +from django.db.models import Q +import config.settings.local as settings +from ..base import Serializer as JSONSerializer + +USER = get_user_model() + +class Serializer(JSONSerializer): + """ + IIIF V3 Annotation Page https://iiif.io/api/presentation/3.0/#55-annotation-page + """ + def _init_options(self): + super()._init_options() + self.annotations = self.json_kwargs.pop('annotations', 0) + + def get_dump_object(self, obj): + # TODO: Add more validation checks before trying to serialize. + data = { + '@context': 'http://iiif.io/api/presentation/3/context.json', + 'id': '{h}/iiif/{m}/annotationpage/{c}'.format( + h=settings.HOSTNAME, + m=obj.manifest.pid, + c=obj.pid + ), + 'type': 'AnnotationPage' + } + + items = [] + + for annotation in self.annotations: + items.append( + json.loads( + serialize( + 'annotation_v3', + [annotation] + ) + ) + ) + + data['items'] = items + + return data + +def Deserializer(data): + """Deserialize IIIF Annotation Page + + :returns list of Annotations. + """ + annotations = [] + + for item in data['items']: + annotations.append(deserialize('annotation_v3', item)) + + return annotations + diff --git a/apps/iiif/serializers/v3/canvas.py b/apps/iiif/serializers/v3/canvas.py new file mode 100644 index 000000000..52c62d178 --- /dev/null +++ b/apps/iiif/serializers/v3/canvas.py @@ -0,0 +1,74 @@ +# pylint: disable = attribute-defined-outside-init, too-few-public-methods +"""Module for serializing IIIF Canvas""" +from django.urls import reverse +import config.settings.local as settings +from apps.iiif.serializers.base import Serializer as JSONSerializer + + +class Serializer(JSONSerializer): + """ + Convert a queryset to IIIF Canvas + """ + + def _init_options(self): + super()._init_options() + self.current_user = self.json_kwargs.pop("current_user", None) + + def get_dump_object(self, obj): + kwargs = { + "username": "ocr", + "vol": obj.manifest.pid, + "canvas": obj.pid, + "version": "v3", + } + + ocr_uri = "{h}{k}".format( + h=settings.HOSTNAME, k=reverse("user_comments", kwargs=kwargs) + ) + + data = { + "id": f"{obj.v3_baseurl}/canvas", + "type": "Canvas", + "label": obj.label, + "height": obj.height, + "width": obj.width, + "items": [ + { + "id": f"{obj.v3_baseurl}/page/{obj.position}", + "type": "Annotation", + "motivation": "painting", + "body": { + "id": obj.resource_id, + "type": "Image", + "format": "image/jpeg", + "height": obj.height, + "width": obj.width, + }, + } + ], + "annotations": [{"type": "AnnotationPage", "id": ocr_uri}], + } + + if self.current_user and self.current_user.username != "": + kwargs["username"] = self.current_user.username + user_annotations_uri = "{h}{k}".format( + h=settings.HOSTNAME, k=reverse("user_comments", kwargs=kwargs) + ) + + data["annotations"].append( + {"type": "AnnotationPage", "id": user_annotations_uri} + ) + + return data + + +def Deserializer(data): + """ + Deserialize IIIF v3 Manifest + """ + return { + "pid": data["id"].split("/")[-2], + "resource": data["id"].split("/")[-2], + "width": data["width"], + "height": data["height"], + } diff --git a/apps/iiif/serializers/v3/fixtures/v3_user_annotation_list.json b/apps/iiif/serializers/v3/fixtures/v3_user_annotation_list.json new file mode 100644 index 000000000..fc3e1614e --- /dev/null +++ b/apps/iiif/serializers/v3/fixtures/v3_user_annotation_list.json @@ -0,0 +1,362 @@ +{ + "@context": "http://iiif.io/api/presentation/3/context.json", + "id": "https://dev.readux.io/iiif/txzqg/annotationpage/txzqg_000.tif", + "type": "AnnotationPage", + "items": [ + { + "@context": "http://www.w3.org/ns/anno.jsonld", + "id": "28e92e3c-0407-482e-98d9-39ecc1ee0070", + "type": "Annotation", + "motivation": "commenting", + "body": [ + { + "type": "TextualBody", + "value": "

some annotation

", + "purpose": "commenting", + "format": "text/html", + "creator": { + "id": "wilkins", + "name": "Dominique Wilkins" + }, + "created": "2022-09-16T18:41:31.534Z", + "modified": "2022-09-16T18:41:31.534Z" + } + ], + "target": { + "source": "https://iip.readux.io/iiif/2/spb1b_000.tif", + "selector": { + "type": "RangeSelector", + "startSelector": { + "@type": "XPathSelector", + "value": "//*[@id='5a1b56bb-b32d-419a-969a-0465982c8a0f']", + "refinedBy": { + "@type": "TextPositionSelector", + "start": 0 + } + }, + "endSelector": { + "@type": "XPathSelector", + "value": "//*[@id='ece5a9ff-ee53-4106-8177-5b18fe3e1da8']", + "refinedBy": { + "@type": "TextPositionSelector", + "end": 2 + } + }, + "refinedBy": { + "type": "FragmentSelector", + "value": "xywh=pixel:1069,984,776,87", + "conformsTo": "https://www.w3.org/TR/media-frags/" + } + } + } + }, + { + "@context": "http://www.w3.org/ns/anno.jsonld", + "id": "#ebd29ec6-b428-44aa-bd90-b08a18cd5f11", + "type": "Annotation", + "body": [ + { + "type": "TextualBody", + "value": "

fefefee

", + "purpose": "commenting", + "format": "text/html", + "creator": { + "id": "wilkins", + "name": "Dominique Wilkins" + }, + "created": "2022-06-22T22:21:53.114Z", + "modified": "2022-06-22T22:21:53.114Z" + } + ], + "target": { + "source": "https://iip.readux.io/iiif/2/txzqg_000.tif", + "selector": { + "type": "RangeSelector", + "startSelector": { + "type": "XPathSelector", + "value": "//*[@id='6f281727-b853-4f72-8b80-8a64910c9655']", + "refinedBy": { + "type": "TextPositionSelector", + "start": 1 + } + }, + "endSelector": { + "type": "XPathSelector", + "value": "//*[@id='6f281727-b853-4f72-8b80-8a64910c9655']", + "refinedBy": { + "type": "TextPositionSelector", + "end": 12 + } + } + } + } + }, + { + "@context": "http://www.w3.org/ns/anno.jsonld", + "id": "#c57b78ac-d337-49e0-bdf0-08936aad081e", + "type": "Annotation", + "body": [ + { + "type": "TextualBody", + "value": "

blah

", + "purpose": "commenting", + "format": "text/html", + "creator": { + "id": "wilkins", + "name": "Dominique Wilkins" + }, + "created": "2023-05-03T15:28:46.716Z", + "modified": "2023-05-03T15:28:46.716Z" + } + ], + "target": { + "source": "https://iip.readux.io/iiif/2/txzqg_000.tif", + "selector": { + "type": "FragmentSelector", + "value": "xywh=pixel:850,3851,908,481", + "conformsTo": "http://www.w3.org/TR/media-frags/" + } + } + }, + { + "@context": "http://www.w3.org/ns/anno.jsonld", + "id": "#064ecb82-ce9d-470b-a30e-19795b46d84d", + "type": "Annotation", + "body": [ + { + "type": "TextualBody", + "value": "

veeveev!!!!!

", + "purpose": "commenting", + "format": "text/html", + "creator": { + "id": "wilkins", + "name": "Dominique Wilkins" + }, + "created": "2022-06-22T22:18:21.172Z", + "modified": "2023-05-11T17:55:18.048Z" + }, + { + "type": "TextualBody", + "value": "tag", + "purpose": "tagging", + "creator": { + "id": "wilkins", + "name": "Dominique Wilkins" + }, + "created": "2022-06-22T22:18:21.172Z", + "modified": "2023-05-11T17:55:18.048Z" + } + ], + "target": { + "source": "https://iip.readux.io/iiif/2/txzqg_000.tif", + "selector": { + "type": "RangeSelector", + "startSelector": { + "type": "XPathSelector", + "value": "//*[@id='b3fab616-3462-42ec-8b95-3a0ef2c0af1c']", + "refinedBy": { + "type": "TextPositionSelector", + "start": 2 + } + }, + "endSelector": { + "type": "XPathSelector", + "value": "//*[@id='29831ded-a408-4357-a859-46054190658e']", + "refinedBy": { + "type": "TextPositionSelector", + "end": 5 + } + } + } + } + }, + { + "@context": "http://www.w3.org/ns/anno.jsonld", + "id": "#a2db93b5-0531-45ed-9402-88a5b8e42df1", + "type": "Annotation", + "body": [ + { + "type": "TextualBody", + "value": "

point

", + "purpose": "commenting", + "format": "text/html", + "creator": { + "id": "wilkins", + "name": "Dominique Wilkins" + }, + "created": "2023-05-16T18:15:03.207Z", + "modified": "2023-05-16T18:15:35.547Z" + } + ], + "target": { + "source": "https://iip.readux.io/iiif/2/txzqg_000.tif", + "selector": { + "type": "FragmentSelector", + "value": "xywh=pixel:1473,813,711,148", + "conformsTo": "http://www.w3.org/TR/media-frags/" + } + } + }, + { + "@context": "http://www.w3.org/ns/anno.jsonld", + "id": "#1dd5a6c6-08af-4af7-8355-0579837e865e", + "type": "Annotation", + "body": [ + { + "type": "TextualBody", + "value": "

King!

", + "purpose": "commenting", + "format": "text/html", + "creator": { + "id": "wilkins", + "name": "Dominique Wilkins" + }, + "created": "2022-06-22T22:22:55.316Z", + "modified": "2023-05-16T18:16:09.191Z" + } + ], + "target": { + "source": "https://iip.readux.io/iiif/2/txzqg_000.tif", + "selector": { + "type": "SvgSelector", + "value": "" + } + } + }, + { + "@context": "http://www.w3.org/ns/anno.jsonld", + "id": "#a9f37f43-f19f-43d7-95ca-c7d7cdb3e7bc", + "type": "Annotation", + "body": [ + { + "type": "TextualBody", + "value": "

text

", + "purpose": "commenting", + "format": "text/html", + "creator": { + "id": "wilkins", + "name": "Dominique Wilkins" + }, + "created": "2025-07-01T17:41:37.178Z", + "modified": "2025-07-01T17:41:37.178Z" + } + ], + "target": { + "source": "https://iip.readux.io/iiif/2/txzqg_000.tif", + "selector": { + "type": "RangeSelector", + "startSelector": { + "type": "XPathSelector", + "value": "//*[@id='1eee408f-b3c2-47c1-913e-913e491a92ea']", + "refinedBy": { + "type": "TextPositionSelector", + "start": 0 + } + }, + "endSelector": { + "type": "XPathSelector", + "value": "//*[@id='1eee408f-b3c2-47c1-913e-913e491a92ea']", + "refinedBy": { + "type": "TextPositionSelector", + "end": 4 + } + } + } + } + }, + { + "@context": "http://www.w3.org/ns/anno.jsonld", + "id": "#adae60c0-721a-4f3d-bae1-a631dc7ee313", + "type": "Annotation", + "body": [ + { + "type": "TextualBody", + "value": "

box

", + "purpose": "commenting", + "format": "text/html", + "creator": { + "id": "wilkins", + "name": "Dominique Wilkins" + }, + "created": "2025-07-01T17:42:21.944Z", + "modified": "2025-07-01T17:42:21.944Z" + } + ], + "target": { + "source": "https://iip.readux.io/iiif/2/txzqg_000.tif", + "selector": { + "type": "FragmentSelector", + "value": "xywh=pixel:1106,2608,409,421", + "conformsTo": "http://www.w3.org/TR/media-frags/" + } + } + }, + { + "@context": "http://www.w3.org/ns/anno.jsonld", + "id": "#3cc07ff7-4a33-47b8-b7e1-67023da7086a", + "type": "Annotation", + "body": [ + { + "type": "TextualBody", + "value": "

circle with tag

", + "purpose": "commenting", + "format": "text/html", + "creator": { + "id": "wilkins", + "name": "Dominique Wilkins" + }, + "created": "2025-07-01T17:43:19.310Z", + "modified": "2025-07-01T17:43:19.310Z" + } + ], + "target": { + "source": "https://iip.readux.io/iiif/2/txzqg_000.tif", + "selector": { + "type": "SvgSelector", + "value": "" + } + } + }, + { + "@context": "http://www.w3.org/ns/anno.jsonld", + "id": "#6c8a28dd-9e4e-4ec8-922a-c8839eea8d71", + "type": "Annotation", + "body": [ + { + "type": "TextualBody", + "value": "

apollo

", + "purpose": "commenting", + "format": "text/html", + "creator": { + "id": "wilkins", + "name": "Dominique Wilkins" + }, + "created": "2025-07-01T18:04:09.697Z", + "modified": "2025-07-01T18:04:09.697Z" + } + ], + "target": { + "source": "https://iip.readux.io/iiif/2/txzqg_000.tif", + "selector": { + "type": "RangeSelector", + "startSelector": { + "type": "XPathSelector", + "value": "//*[@id='480a4652-93ae-4292-915a-f2eed66d645e']", + "refinedBy": { + "type": "TextPositionSelector", + "start": 0 + } + }, + "endSelector": { + "type": "XPathSelector", + "value": "//*[@id='480a4652-93ae-4292-915a-f2eed66d645e']", + "refinedBy": { + "type": "TextPositionSelector", + "end": 6 + } + } + } + } + } + ] +} diff --git a/apps/iiif/serializers/v3/manifest.py b/apps/iiif/serializers/v3/manifest.py new file mode 100644 index 000000000..9c39164ed --- /dev/null +++ b/apps/iiif/serializers/v3/manifest.py @@ -0,0 +1,157 @@ +"""Module for serializing IIIF Annotation Lists""" + +import re +import json +from django.core.serializers import serialize +from apps.iiif.manifests.models import Manifest +from apps.iiif.canvases.models import Canvas +from apps.iiif.serializers.base import Serializer as JSONSerializer + + +class Serializer(JSONSerializer): + """ + Convert a queryset to IIIF Manifest + """ + + def _init_options(self): + super()._init_options() + self.current_user = self.json_kwargs.pop("current_user", None) + + def get_dump_object(self, obj): + try: + thumbnail = f"{obj.image_server.server_base}/{obj.start_canvas.resource}" + except (Canvas.MultipleObjectsReturned, Canvas.DoesNotExist): + thumbnail = f"{obj.image_server.server_base}/{obj.canvas_set.all().first().resource}" + + metadata = [] + if len(obj.metadata) > 0: + if isinstance(obj.metadata, dict): + metadata.append(obj.metadata) + if isinstance(obj.metadata, list): + metadata += obj.metadata + + if obj.author is not None: + metadata.append({"label": "Author", "value": obj.author}) + + if obj.publisher is not None: + metadata.append({"label": "Publisher", "value": obj.publisher}) + + if obj.published_city is not None: + metadata.append({"label": "Published City", "value": obj.published_city}) + + if obj.published_date is not None: + metadata.append({"label": "Published Date", "value": obj.published_date}) + + if obj.published_date_edtf is not None: + metadata.append( + {"label": "Published Date EDTF", "value": str(obj.published_date_edtf)} + ) + + if obj.languages.count() > 0: + metadata.append( + { + "label": "Languages", + "value": [lng.code for lng in obj.languages.all()], + } + ) + + if obj.collections.count() > 0: + metadata.append( + { + "label": "Collections", + "value": [col.label for col in obj.collections.all()], + } + ) + + data = { + "@context": "http://iiif.io/api/presentation/3/context.json", + "id": f"{obj.v3_baseurl}/manifest", + "type": "Manifest", + "label": obj.label, + "metadata": metadata, + "summary": {"none": [obj.summary]}, + "viewingDirection": obj.viewingdirection, + "rights": obj.license, + "thumbnail": [ + { + "id": f"{thumbnail}/full/600,/0/default.jpg", + "type": "Image", + "format": "image/jpeg", + "service": [ + {"id": thumbnail, "type": "ImageService3", "profile": "level1"} + ], + } + ], + "items": [], + } + + if obj.pdf: + data["rendering"] = [ + { + "id": obj.pdf, + "type": "Text", + "label": {"en": ["Download as PDF"]}, + "format": "application/pdf", + } + ] + + for canvas in obj.canvas_set.all(): + data["items"].append( + json.loads( + serialize("canvas_v3", [canvas], current_user=self.current_user) + ) + ) + + return data + + +def Deserializer(data): + """ + Deserialize IIIF v3 Manifest + """ + manifest = { + "pid": data["id"].split("/")[-2], + "viewingdirection": data["viewingDirection"], + "license": data["rights"], + } + + if "rendering" in data: + for renderer in data["rendering"]: + if "pdf" in renderer["format"]: + manifest["pdf"] = renderer["id"] + + relations = {} + + fields = [f.name for f in Manifest._meta.get_fields()] + + for key, value in data.items(): + if key in fields and key != "id": + if key == "metadata": + manifest["metadata"] = [] + if isinstance(data["metadata"], list): + for attr in data["metadata"]: + if isinstance(attr, dict): + key = attr["label"] + field = re.sub(r"\([^)]*\)", "", key).strip() + field = field.replace(" ", "_") + field = field.lower() + if field in fields: + if field == "collections" or field == "languages": + relations[field] = attr["value"] + elif field == "authors": + manifest["author"] = attr["value"] + else: + manifest[field] = attr["value"] + else: + manifest["metadata"].append(attr) + elif isinstance(value, str): + manifest[key] = value + elif isinstance(value, dict) and len(value.keys()) > 0: + if "en" in value.keys(): + manifest[key] = value["en"][0] + elif "none" in value.keys(): + manifest[key] = value["none"][0] + else: + manifest[key] = value[value.keys()[0]] + + return (manifest, relations) diff --git a/apps/ingest/migrations/__init__.py b/apps/iiif/serializers/v3/tests/__init__.py similarity index 100% rename from apps/ingest/migrations/__init__.py rename to apps/iiif/serializers/v3/tests/__init__.py diff --git a/apps/iiif/serializers/v3/tests/complex_metadata.py b/apps/iiif/serializers/v3/tests/complex_metadata.py new file mode 100644 index 000000000..5ec1fe6a0 --- /dev/null +++ b/apps/iiif/serializers/v3/tests/complex_metadata.py @@ -0,0 +1,107 @@ +def complex_metadata(): + """Example complex metadata + + Returns: + list: list of dicts as an example of complex metadata + """ + return [ + {"label": "Published City", "value": "Atlanta"}, + {"label": "Full Title", "value": "Down at the cross"}, + { + "label": "Title Page Text", + "value": "1914 | DOWN AT THE CROSS | By | D. E. DORTCH J. C. MIDYETT | W. G. COOPER W. W. BENTLEY | THE DORTCH PUBLISHING CO. | CHARLOTTE, N. C.", + }, + { + "label": "Creator", + "value": "Dortch, D. E. (David Elijah), 1851-1928;Cooper, W. G. (William Gustin), 1861-1938;Bentley, W. Warren (William Warren)", + }, + { + "label": "Creator URI", + "value": "http://id.loc.gov/authorities/names/no99026513;http://id.loc.gov/authorities/names/n2002024617;http://id.loc.gov/authorities/names/no92026871", + }, + {"label": "Place of Publication FAST", "value": "North Carolina--Charlotte"}, + { + "label": "Place of Publication FAST URI", + "value": "http://id.worldcat.org/fast/1204596", + }, + { + "label": "Place of Publication Getty", + "value": "United States, North Carolina, Mecklenburg, Charlotte", + }, + { + "label": "Place of Publication Getty URI", + "value": "http://vocab.getty.edu/page/tgn/7013584", + }, + {"label": "Language Code", "value": "en"}, + {"label": "Archive", "value": "Pitts Theology Library"}, + {"label": "Collection", "value": ""}, + {"label": "OCLC Number", "value": "41408869"}, + {"label": "Medium", "value": "songbook"}, + {"label": "Dublin Core Type", "value": "Image"}, + {"label": "License Name", "value": "No Copyright - United States"}, + { + "label": "Place of Contributor Affiliation", + "value": "United States, Maryland, Wicomico, Delmar;United States, Kentucky, Union, Uniontown;United States, New Jersey, Monmouth, Atlantic Highlands;United States, Tennessee, Coffee", + }, + { + "label": "Place of Contributor Affiliation URI", + "value": "http://vocab.getty.edu/page/tgn/2046857;http://vocab.getty.edu/page/tgn/2041728;http://vocab.getty.edu/page/tgn/2064001;http://vocab.getty.edu/page/tgn/2001852", + }, + { + "label": "Place of Manufacture", + "value": "United States, Ohio, Hamilton, Cincinnati", + }, + { + "label": "Place of Manufacture URI", + "value": "http://vocab.getty.edu/page/tgn/7013604", + }, + {"label": "Place of Use", "value": ""}, + {"label": "Place of Use URI", "value": ""}, + {"label": "Item Type", "value": "Print with non-autograph annotations"}, + {"label": "Physical Description", "value": "1 vocal score: [225], [4] p."}, + {"label": "Condition", "value": "complete"}, + {"label": "Pagination", "value": "225 unnumbered pages, 4 unnumbered pages"}, + {"label": "Notation", "value": "Seven-shape Aikin"}, + {"label": "Scoring Summary", "value": "V (5), Coro, (campana), keyb"}, + { + "label": "Total Scoring", + "value": "S;A;T;Bariton;B;Coro di fanciulli 1: S;Coro di fanciulli 2: S;Coro S;Coro A;Coro T;Coro B;(campana);keyb", + }, + {"label": "Content", "value": "Sacred"}, + {"label": "Still in Use", "value": "No"}, + { + "label": "Subject", + "value": "Hymnals;Hymns, English;Cloth bindings (Bookbinding);Hymnals--Prices;Church music;Hymn tunes--Anthologies;Musical notation--Economic aspects;Nostalgia in music;Methods (Music)--Advertising;Hymnals--Advertising;Bible--Advertising;Shape-note hymnals;Salvation Army;Hymn texts--Homiletical use;Organ;Concerts--Tullahoma, Tenn.;Performance practice (Music);Canadian Americans--Music;Gospel music;Sacred songs with keyboard;Choruses, sacred (mixed voices), unaccompanied;Marginalia;Railroads;Alexander, Charles M. (Charles McCallon), 1867-1920--Provenance;Musical meter and rhythm;Conducting;Preschool music;Choruses, sacred (children's voices), unaccompanied;Chants;Concerts--Smithville, Tex.;Contrafacta;Folk music--Scotland;Women composers;Motherhood;Prayer meetings;Funeral music;Mountaineering;Birthdays--Songs and music", + }, + {"label": "Contributor", "value": ""}, + {"label": "Contributor URI", "value": ""}, + {"label": "Publisher Name", "value": ""}, + {"label": "Publisher URI", "value": ""}, + {"label": "Place of Distribution", "value": ""}, + {"label": "Place of Distribution URI", "value": ""}, + {"label": "Place of Field Research", "value": ""}, + {"label": "Place of Field Research URI", "value": ""}, + {"label": "Dimensions", "value": "20 x 13.5 cm"}, + { + "label": "People", + "value": "Dortch, D. E. (David Elijah), 1851-1928;Cooper, W. G. (William Gustin), 1861-1938;Bentley, W. Warren (William Warren)", + }, + {"label": "Country", "value": "United States"}, + { + "label": "State", + "value": "New Jersey;Tennessee;Ohio;North Carolina;Kentucky;Maryland", + }, + { + "label": "County", + "value": "Hamilton, Ohio;Wicomico, Maryland;Union, Kentucky;Monmouth, New Jersey;Mecklenburg, North Carolina", + }, + { + "label": "Inhabited Place", + "value": "Uniontown, Kentucky;Cincinnati, Ohio;Coffee, Tennessee;Atlantic Highlands, New Jersey;Charlotte, North Carolina;Delmar, Maryland", + }, + { + "label": "Instrumentation", + "value": "soprano (vocal solo);alto (vocal solo);tenor (vocal solo);baritone (vocal solo);bass (vocal solo);soprano (children's chorus part);soprano (children's chorus part);soprano (chorus part);alto (chorus part);tenor (chorus part);bass (chorus part);bells;keyboard", + }, + {"label": "Digital Format", "value": "image/tiff"}, + ] diff --git a/apps/iiif/serializers/v3/tests/test_annotation_page.py b/apps/iiif/serializers/v3/tests/test_annotation_page.py new file mode 100644 index 000000000..f40a73772 --- /dev/null +++ b/apps/iiif/serializers/v3/tests/test_annotation_page.py @@ -0,0 +1,123 @@ +"""Test Module for IIIF Serializers""" + +import json +from apps.readux.models import UserAnnotation +import config.settings.local as settings +from django.test import TestCase +from django.core.serializers import serialize, deserialize +from django.contrib.auth import get_user_model +from apps.iiif.annotations.choices import AnnotationSelector +from apps.iiif.annotations.tests.factories import AnnotationFactory +from apps.iiif.canvases.tests.factories import CanvasFactory +from apps.iiif.manifests.tests.factories import ManifestFactory +from apps.readux.tests.factories import UserAnnotationFactory +from apps.users.tests.factories import UserFactory + +User = get_user_model() + + +class SerializerTests(TestCase): + def setUp(self): + self.ocr_user, _ = User.objects.get_or_create(name="OCR", username="ocr") + self.owner = UserFactory.create() + + def test_annotation_page(self): + canvas = CanvasFactory.create(manifest=ManifestFactory.create()) + AnnotationFactory.create(owner=self.ocr_user, canvas=canvas) + UserAnnotationFactory(canvas=canvas, owner=self.owner) + UserAnnotationFactory.create( + canvas=canvas, + svg="", + primary_selector=AnnotationSelector("SV"), + owner=self.owner, + ) + start_selector = AnnotationFactory.create(canvas=canvas, order=2) + end_selector = AnnotationFactory.create(canvas=canvas, order=12) + UserAnnotationFactory.create( + canvas=canvas, + primary_selector=AnnotationSelector("RG"), + start_offset=1, + end_offset=2, + content="

HTML comment

", + owner=self.owner, + start_selector=start_selector, + end_selector=end_selector, + ) + + user_annotations = UserAnnotation.objects.filter( + owner=self.owner, canvas=canvas + ) + + annotation_count = ( + canvas.annotation_set.count() + canvas.userannotation_set.count() + ) + + serialized_annotation_page = json.loads( + serialize( + "annotation_page_v3", + [canvas], + annotations=canvas.userannotation_set.all(), + ) + ) + + assert annotation_count == 6 + assert len(serialized_annotation_page["items"]) == 3 + assert ( + f"{canvas.manifest.pid}/annotationpage/{canvas.pid}" + in serialized_annotation_page["id"] + ) + assert serialized_annotation_page["id"].startswith("https") + + +class DeserializerTests(TestCase): + def test_web_annotation_comment_fragment_deserialization(self): + user = UserFactory.create() + canvas = CanvasFactory.create(manifest=ManifestFactory.create()) + annotation_page = { + "@context": "http://iiif.io/api/presentation/3/context.json", + "id": "{h}/iiif/{m}/annotationpage/{c}".format( + h=settings.HOSTNAME, m=canvas.manifest.pid, c=canvas.pid + ), + "items": [ + { + "type": "Annotation", + "motivation": "commenting", + "body": [ + { + "purpose": "commenting", + "type": "TextualBody", + "created": "2022-06-06T20:29:08.108Z", + "value": "

Really smart annotation

", + "format": "text/html", + "creator": {"id": user.username, "name": user.name}, + "modified": "2022-06-06T20:29:08.108Z", + } + ], + "target": { + "source": canvas.resource_id, + "selector": { + "type": "FragmentSelector", + "conformsTo": "http://www.w3.org/TR/media-frags/", + "value": "xywh=pixel:575.7898559570312,1263.1917724609375,677.0464477539062,737.7884521484375", + }, + }, + "@context": "http://www.w3.org/ns/anno.jsonld", + "id": "#51602663-36ee-4692-a327-2f438daf48a9", + } + ], + } + + deserialized_annotations = deserialize("annotation_page_v3", annotation_page) + + assert len(deserialized_annotations) == 1 + + for item in deserialized_annotations: + deserialized_annotation, _ = item + annotation = UserAnnotation(**deserialized_annotation) + assert annotation.owner == user + assert annotation.canvas == canvas + assert annotation.content == annotation_page["items"][0]["body"][0]["value"] + assert annotation.x == 575.7898559570312 + assert annotation.y == 1263.1917724609375 + assert annotation.w == 677.0464477539062 + assert annotation.h == 737.7884521484375 diff --git a/apps/iiif/serializers/v3/tests/test_annotations.py b/apps/iiif/serializers/v3/tests/test_annotations.py new file mode 100644 index 000000000..54768c9d2 --- /dev/null +++ b/apps/iiif/serializers/v3/tests/test_annotations.py @@ -0,0 +1,321 @@ +"""Test Module for IIIF Serializers""" + +import json +from django.test import TestCase +from django.core.serializers import serialize, deserialize +from apps.iiif.annotations.models import Annotation +from apps.iiif.annotations.choices import AnnotationPurpose, AnnotationSelector +from apps.iiif.annotations.tests.factories import AnnotationFactory +from apps.iiif.canvases.models import Canvas +from apps.iiif.canvases.tests.factories import CanvasFactory +from apps.iiif.manifests.tests.factories import ManifestFactory +from apps.readux.models import UserAnnotation +from apps.readux.tests.factories import UserAnnotationFactory +from apps.users.tests.factories import UserFactory +from django.contrib.auth import get_user_model + +User = get_user_model() + + +class SerializerTests(TestCase): + def setUp(self): + self.ocr_user, _ = User.objects.get_or_create(name="OCR", username="ocr") + + def test_ocr_annotation(self): + canvas = CanvasFactory.create(manifest=ManifestFactory.create()) + ocr_anno = AnnotationFactory.create(owner=self.ocr_user, canvas=canvas) + serialized_ocr_anno = json.loads(serialize("annotation_v3", [ocr_anno])) + + body = serialized_ocr_anno["body"][0] + target = serialized_ocr_anno["target"] + + assert body["value"].startswith("") + assert body["format"] == "text/html" + assert "data-letter-spacing" in body["value"] + assert body["creator"] == {"id": "ocr", "name": "OCR"} + assert target["source"] == canvas.resource_id + assert ( + target["selector"]["value"] + == f"xywh=pixel:{ocr_anno.x},{ocr_anno.y},{ocr_anno.w},{ocr_anno.h}" + ) + assert target["selector"]["type"] == AnnotationSelector("FR").name + assert len(serialized_ocr_anno["body"]) == 1 + + def test_fragment_annotation(self): + canvas = CanvasFactory.create(manifest=ManifestFactory.create()) + frag_anno = UserAnnotationFactory(canvas=canvas) + serialized_frag_anno = json.loads(serialize("annotation_v3", [frag_anno])) + body = serialized_frag_anno["body"][0] + target = serialized_frag_anno["target"] + + assert body["value"] == frag_anno.content + assert body["format"] == "text/plain" + assert body["creator"] == { + "id": frag_anno.owner.username, + "name": frag_anno.owner.name, + } + assert target["source"] == canvas.resource_id + assert ( + target["selector"]["value"] + == f"xywh=pixel:{frag_anno.x},{frag_anno.y},{frag_anno.w},{frag_anno.h}" + ) + assert target["selector"]["type"] == AnnotationSelector("FR").name + assert len(serialized_frag_anno["body"]) == 1 + + def test_svg_annotation(self): + canvas = CanvasFactory.create(manifest=ManifestFactory.create()) + svg_anno = UserAnnotationFactory.create( + canvas=canvas, + svg="", + primary_selector=AnnotationSelector("SV"), + ) + serialized_svg_anno = json.loads(serialize("annotation_v3", [svg_anno])) + body = serialized_svg_anno["body"][0] + svg_target = serialized_svg_anno["target"] + + assert body["value"] == svg_anno.content + assert body["format"] == "text/plain" + assert body["creator"] == { + "id": svg_anno.owner.username, + "name": svg_anno.owner.name, + } + assert svg_target["source"] == canvas.resource_id + assert svg_target["selector"]["value"] == svg_anno.svg + assert svg_target["selector"]["type"] == AnnotationSelector("SV").name + assert len(serialized_svg_anno["body"]) == 1 + + def test_text_annotation(self): + canvas = CanvasFactory.create(manifest=ManifestFactory.create()) + text_anno = UserAnnotationFactory.create( + canvas=canvas, + primary_selector=AnnotationSelector("RG"), + start_selector=AnnotationFactory.create(canvas=canvas, order=2), + end_selector=AnnotationFactory.create(canvas=canvas, order=12), + start_offset=1, + end_offset=2, + content="

HTML comment

", + ) + + serialized_text_anno = json.loads(serialize("annotation_v3", [text_anno])) + body = serialized_text_anno["body"][0] + text_target = serialized_text_anno["target"] + + assert len(serialized_text_anno["body"]) == 1 + assert body["value"] == text_anno.content + assert body["format"] == "text/html" + assert body["creator"] == { + "id": text_anno.owner.username, + "name": text_anno.owner.name, + } + assert text_target["source"] == canvas.resource_id + assert ( + text_target["selector"]["startSelector"]["value"] + == f"//*[@id='{text_anno.start_selector.pk}']" + ) + assert ( + text_target["selector"]["endSelector"]["value"] + == f"//*[@id='{text_anno.end_selector.pk}']" + ) + assert ( + text_target["selector"]["startSelector"]["refinedBy"]["start"] + == text_anno.start_offset + ) + assert ( + text_target["selector"]["endSelector"]["refinedBy"]["end"] + == text_anno.end_offset + ) + assert text_target["selector"]["type"] == AnnotationSelector("RG").name + + +class DeserializerTests(TestCase): + + def test_web_annotation_comment_fragment_deserialization(self): + user = UserFactory.create() + canvas = CanvasFactory.create(manifest=ManifestFactory.create()) + web_annotation = { + "type": "Annotation", + "motivation": "commenting", + "body": [ + { + "purpose": "commenting", + "type": "TextualBody", + "created": "2022-06-06T20:29:08.108Z", + "value": "

Really smart annotation

", + "format": "text/html", + "creator": {"id": user.username, "name": user.name}, + "modified": "2022-06-06T20:29:08.108Z", + } + ], + "target": { + "source": canvas.resource_id, + "selector": { + "type": "FragmentSelector", + "conformsTo": "http://www.w3.org/TR/media-frags/", + "value": "xywh=pixel:575.7898559570312,1263.1917724609375,677.0464477539062,737.7884521484375", + }, + }, + "@context": "http://www.w3.org/ns/anno.jsonld", + "id": "#51602663-36ee-4692-a327-2f438daf48a9", + } + + deserialized_annotation, _ = deserialize("annotation_v3", web_annotation) + annotation = UserAnnotation(**deserialized_annotation) + assert annotation.owner == user + assert annotation.canvas == canvas + assert annotation.content == web_annotation["body"][0]["value"] + assert annotation.x == 575.7898559570312 + assert annotation.y == 1263.1917724609375 + assert annotation.w == 677.0464477539062 + assert annotation.h == 737.7884521484375 + + def test_web_annotation_comment_svg_deserialization(self): + user = UserFactory.create() + canvas = CanvasFactory.create(manifest=ManifestFactory.create()) + web_annotation = { + "type": "Annotation", + "motivation": "commenting", + "body": [ + { + "purpose": "commenting", + "type": "TextualBody", + "created": "2022-06-06T20:38:37.123Z", + "value": "

Even smarter annotation

", + "format": "text/html", + "creator": {"id": user.username, "name": user.name}, + "modified": "2022-06-06T20:38:37.123Z", + }, + { + "type": "TextualBody", + "value": "Tag One", + "purpose": "tagging", + "format": "text/plain", + "creator": {"id": user.username, "name": user.name}, + "created": "2022-06-06T20:38:43.052Z", + "modified": "2022-06-06T20:38:43.720Z", + }, + ], + "target": { + "source": canvas.resource_id, + "selector": { + "type": "SvgSelector", + "value": '', + "refinedBy": { + "type": "FragmentSelector", + "value": "xywh=448.96453857421875,1384.190185546875,1224.1728515625,1224.173095703125", + }, + }, + }, + "@context": "http://www.w3.org/ns/anno.jsonld", + "id": "#db7cd136-cdb7-4b1e-b33c-f0afd66c1aee", + } + + deserialized_annotation, tags = deserialize("annotation_v3", web_annotation) + annotation = UserAnnotation(**deserialized_annotation) + annotation.save() + for tag in tags: + annotation.tags.add(tag) + assert annotation.owner == user + assert annotation.canvas == canvas + assert annotation.content == web_annotation["body"][0]["value"] + assert annotation.raw_content == "Even smarter annotation" + assert "Tag One" in annotation.tag_list + assert annotation.svg + assert annotation.x == 448.96453857421875 + assert annotation.y == 1384.190185546875 + assert annotation.w == 1224.1728515625 + assert annotation.h == 1224.173095703125 + + def test_web_annotation_comment_range_deserialization(self): + user = UserFactory.create() + canvas = CanvasFactory.create(manifest=ManifestFactory.create()) + start = AnnotationFactory.create(canvas=canvas, order=3) + end = AnnotationFactory.create(canvas=canvas, order=15) + web_annotation = { + "type": "Annotation", + "motivation": "commenting", + "body": [ + { + "type": "TextualBody", + "value": "

Yet another annotation

", + "purpose": "commenting", + "format": "text/html", + "creator": {"id": user.username, "name": user.name}, + } + ], + "target": { + "source": canvas.resource_id, + "selector": { + "type": "RangeSelector", + "startSelector": { + "@type": "XPathSelector", + "value": f"//*[@id='{start.pk}']", + "refinedBy": {"@type": "TextPositionSelector", "start": 1}, + }, + "endSelector": { + "type": "XPathSelector", + "value": f"//*[@id='{end.pk}']", + "refinedBy": {"@type": "TextPositionSelector", "end": 5}, + }, + "refinedBy": { + "type": "FragmentSelector", + "value": "xywh=pixel:1069,984,776,87", + "conformsTo": "https://www.w3.org/TR/media-frags/", + }, + }, + }, + "@context": "http://www.w3.org/ns/anno.jsonld", + "id": "#da324841-beaa-4710-858e-f128580c6f2d", + } + + annotation_attrs, _ = deserialize("annotation_v3", web_annotation) + annotation = UserAnnotation.objects.create(**annotation_attrs) + assert annotation.owner == user + assert annotation.canvas == canvas + assert annotation.content == web_annotation["body"][0]["value"] + assert annotation.start_selector == start + assert ( + annotation.start_offset + == web_annotation["target"]["selector"]["startSelector"]["refinedBy"][ + "start" + ] + ) + assert annotation.end_selector == end + assert ( + annotation.end_offset + == web_annotation["target"]["selector"]["endSelector"]["refinedBy"]["end"] + ) + + def test_deserialization_update(self): + canvas = CanvasFactory.create(manifest=ManifestFactory.create()) + svg_anno = UserAnnotationFactory.create( + canvas=canvas, + svg="", + primary_selector=AnnotationSelector("SV"), + purpose=AnnotationPurpose("CM"), + ) + serialized_svg_anno = json.loads(serialize("annotation_v3", [svg_anno])) + body = serialized_svg_anno["body"][0] + new_content = "some updated content" + + assert serialized_svg_anno["body"][0]["value"] == svg_anno.content + assert serialized_svg_anno["id"].replace("#", "") == str(svg_anno.id) + assert svg_anno.content != new_content + serialized_svg_anno["body"][0]["value"] = new_content + + serialized_svg_anno["body"].append( + { + "type": "TextualBody", + "purpose": "tagging", + "format": "text/plain", + "value": "awesome", + } + ) + + updated_anno_attrs, tags = deserialize("annotation_v3", serialized_svg_anno) + + svg_anno.update(updated_anno_attrs, tags) + svg_anno.refresh_from_db() + + assert svg_anno.content == new_content + assert tags == ["awesome"] diff --git a/apps/iiif/serializers/v3/tests/test_manifest.py b/apps/iiif/serializers/v3/tests/test_manifest.py new file mode 100644 index 000000000..0d204f7ca --- /dev/null +++ b/apps/iiif/serializers/v3/tests/test_manifest.py @@ -0,0 +1,80 @@ +"""Test Module for IIIF Serializers""" + +import json +from random import randint +from django.test import TestCase +from django.core.serializers import serialize, deserialize +from django.contrib.auth import get_user_model +from apps.iiif.annotations.tests.factories import AnnotationFactory +from apps.iiif.canvases.tests.factories import CanvasFactory +from apps.iiif.kollections.tests.factories import CollectionFactory +from apps.iiif.manifests.tests.factories import ManifestFactory +from apps.iiif.manifests.models import Language +from apps.readux.tests.factories import UserAnnotationFactory +from apps.users.tests.factories import UserFactory +from .complex_metadata import complex_metadata + +User = get_user_model() + + +class SerializerTests(TestCase): + def setUp(self): + """_summary_""" + self.manifest = ManifestFactory.create() + self.user = UserFactory.create() + for _ in range(0, 10): + canvas = CanvasFactory.create(manifest=self.manifest) + for _ in range(0, randint(3, 5)): + AnnotationFactory.create(canvas=canvas) + for _ in range(0, randint(3, 5)): + UserAnnotationFactory.create(canvas=canvas, owner=self.user) + for _ in range(0, 2): + self.manifest.collections.add(CollectionFactory.create()) + self.manifest.languages.add(Language.objects.get(code="en")) + + self.manifest.refresh_from_db() + + self.serialized_manifest = json.loads( + serialize("manifest_v3", [self.manifest], current_user=self.user) + ) + + def test_manifest(self): + """It should serialize a volume with the correct number of canvases.""" + assert ( + len(self.serialized_manifest["items"]) == self.manifest.canvas_set.count() + ) + + def test_deserializer(self): + """It should deserialize an manifest.""" + manifest, relations = deserialize("manifest_v3", self.serialized_manifest) + assert manifest["pid"] == self.manifest.pid + assert len(relations["languages"]) == 1 + assert len(relations["collections"]) == 2 + + def test_serializing_complex_metadata(self): + """It should serialize extra metadata""" + self.manifest.metadata = complex_metadata() + self.manifest.save() + self.manifest.refresh_from_db() + updated_serialized_manifest = json.loads( + serialize("manifest_v3", [self.manifest], current_user=self.user) + ) + for item in complex_metadata(): + assert item in updated_serialized_manifest["metadata"] + assert "Collections" in [ + md["label"] for md in updated_serialized_manifest["metadata"] + ] + + # def test_deserializing_complex_metadata(self): + # """It should serialize extra metadata""" + # self.manifest.metadata = complex_metadata() + # self.manifest.save() + # self.manifest.refresh_from_db() + # print(self.manifest.metadata) + # updated_serialized_manifest = json.loads( + # serialize("manifest_v3", [self.manifest], current_user=self.user) + # ) + # manifest, relations = deserialize("manifest_v3", updated_serialized_manifest) + # assert manifest["published_city"] == "Atlanta" + # assert "collections" in relations + # assert "languages" in relations diff --git a/apps/iiif/serializers/v3/volume_annotation_page.py b/apps/iiif/serializers/v3/volume_annotation_page.py new file mode 100644 index 000000000..898b567bf --- /dev/null +++ b/apps/iiif/serializers/v3/volume_annotation_page.py @@ -0,0 +1,61 @@ +# pylint: disable = attribute-defined-outside-init, too-few-public-methods +"""Module for serializing IIIF Annotation Lists""" +import json +from django.core.serializers import serialize, deserialize +from django.core.serializers.base import SerializerDoesNotExist +from django.contrib.auth import get_user_model +from django.db.models import Q +import config.settings.local as settings +from ..base import Serializer as JSONSerializer + +USER = get_user_model() + +class Serializer(JSONSerializer): + """ + IIIF V3 Annotation Page https://iiif.io/api/presentation/3.0/#55-annotation-page + """ + def _init_options(self): + super()._init_options() + self.volume_annotations = self.json_kwargs.pop('volume_annotations', 0) + + def get_dump_object(self, obj): + # TODO: Add more validation checks before trying to serialize. + data = { + '@context': 'http://iiif.io/api/presentation/3/context.json', + 'id': '{h}/iiif/{m}/annotationpage/{c}'.format( + h=settings.HOSTNAME, + m=obj.pid, + c=obj.pid + ), + 'type': 'AnnotationPage' + } + + items = [] + + for canvas_annotation in self.volume_annotations: + for annotation in canvas_annotation: + items.append( + json.loads( + serialize( + 'annotation_v3', + [annotation] + ) + ) + ) + + data['items'] = items + + return data + +def Deserializer(data): + """Deserialize IIIF Annotation Page + + :returns list of Annotations. + """ + annotations = [] + + for item in data['items']: + annotations.append(deserialize('annotation_v3', item)) + + return annotations + diff --git a/apps/ingest/admin.py b/apps/ingest/admin.py deleted file mode 100644 index 2a9cf1ef9..000000000 --- a/apps/ingest/admin.py +++ /dev/null @@ -1,225 +0,0 @@ -"""[summary]""" -import logging -from mimetypes import guess_type -from os import environ, path, remove, listdir, rmdir -from django.core.files.base import ContentFile -from django.contrib import admin -from django.shortcuts import redirect -from django.urls import reverse -from django.utils.html import format_html -from django_celery_results.models import TaskResult -from apps.ingest import tasks -from .models import Bulk, IngestTaskWatcher, Local, Remote -from .services import clean_metadata, create_manifest, get_associated_meta, get_metadata_from -from .forms import BulkVolumeUploadForm - -LOGGER = logging.getLogger(__name__) -class LocalAdmin(admin.ModelAdmin): - """Django admin ingest.models.local resource.""" - fields = ('bundle', 'image_server', 'collections') - show_save_and_add_another = False - - def save_model(self, request, obj, form, change): - obj.save() - obj.manifest = create_manifest(obj) - obj.creator = request.user - obj.save() - obj.refresh_from_db() - super().save_model(request, obj, form, change) - if environ["DJANGO_ENV"] != 'test': # pragma: no cover - local_task_id = tasks.create_canvas_form_local_task.delay(obj.id) - local_task_result = TaskResult(task_id=local_task_id) - local_task_result.save() - file = request.FILES['bundle'] - IngestTaskWatcher.manager.create_watcher( - task_id=local_task_id, - task_result=local_task_result, - task_creator=request.user, - associated_manifest=obj.manifest, - filename=file.name - ) - else: - tasks.create_canvas_form_local_task(obj.id) - - def response_add(self, request, obj, post_url_continue=None): - obj.refresh_from_db() - manifest_id = obj.manifest.id - return redirect('/admin/manifests/manifest/{m}/change/'.format(m=manifest_id)) - - class Meta: # pylint: disable=too-few-public-methods, missing-class-docstring - model = Local - -class RemoteAdmin(admin.ModelAdmin): - """Django admin ingest.models.remote resource.""" - fields = ('remote_url',) - show_save_and_add_another = False - - def save_model(self, request, obj, form, change): - obj.manifest = create_manifest(obj) - obj.save() - obj.refresh_from_db() - super().save_model(request, obj, form, change) - if environ["DJANGO_ENV"] != 'test': # pragma: no cover - remote_task_id = tasks.create_remote_canvases.delay(obj.id) - remote_task_result = TaskResult(task_id=remote_task_id) - remote_task_result.save() - IngestTaskWatcher.manager.create_watcher( - task_id=remote_task_id, - task_result=remote_task_result, - task_creator=request.user, - filename=obj.remote_url, - associated_manifest=obj.manifest - ) - - def response_add(self, request, obj, post_url_continue=None): - obj.refresh_from_db() - manifest_id = obj.manifest.id - return redirect('/admin/manifests/manifest/{m}/change/'.format(m=manifest_id)) - -class BulkAdmin(admin.ModelAdmin): - """Django admin ingest.models.bulk resource.""" - - form = BulkVolumeUploadForm - - def save_model(self, request, obj, form, change): - # Save M2M relationships with collections so we can access them later - if form.is_valid(): - form.save(commit=False) - form.save_m2m() - obj.save() - # Get files from multi upload form - files = request.FILES.getlist("volume_files") - # Find the metadata file and load it into list of dicts - all_metadata = get_metadata_from(files) - for file in files: - # Skip metadata file now - if 'metadata' in file.name.casefold() and 'zip' not in guess_type(file.name)[0]: - continue - - # Associate metadata with zipfile - if all_metadata is not None: - file_meta = clean_metadata(get_associated_meta(all_metadata, file)) - else: - file_meta = {} - - # Create a Local object - new_local = Local.objects.create( - bulk=obj, - image_server=obj.image_server, - creator=request.user - ) - new_local.collections.set(obj.collections.all()) - if file_meta: - new_local.metadata=file_meta - - # Save tempfile in bundle_from_bulk - with ContentFile(file.read()) as file_content: - new_local.bundle_from_bulk.save(file.name, file_content) - new_local.save() - new_local.refresh_from_db() - - # Queue task to upload to S3 - if environ["DJANGO_ENV"] != 'test': # pragma: no cover - upload_task = tasks.upload_to_s3_task.delay( - local_id=new_local.id, - ) - upload_task_result = TaskResult(task_id=upload_task.id) - upload_task_result.save() - IngestTaskWatcher.manager.create_watcher( - task_id=upload_task.id, - task_result=upload_task_result, - task_creator=request.user, - filename=file.name - ) - obj.refresh_from_db() - super().save_model(request, obj, form, change) - - def response_add(self, request, obj, post_url_continue=None): - # Delete local file - file_path = obj.volume_files.path - if path.isfile(file_path): - remove(file_path) - else: - LOGGER.error(f"Could not cleanup {file_path}") - dir_path = file_path[0:file_path.rindex('/')] - if not path.isfile(file_path) and len(listdir(dir_path)) == 0: - rmdir(dir_path) - obj.delete() - url_to = reverse('admin:ingest_ingesttaskwatcher_changelist') - return redirect(url_to) - - class Meta: # pylint: disable=too-few-public-methods, missing-class-docstring - model = Bulk - -class TaskWatcherAdmin(admin.ModelAdmin): - """Django admin for ingest.models.IngestTaskWatcher resource.""" - - list_display = ( - "id", - "filename", - "task_name", - "task_status", - "task_creator", - "date_created", - "date_done", - ) - fields = ( - "id", - "filename", - "task_name", - "task_status", - "task_creator", - "date_created", - "date_done", - ) - list_filter = ('task_creator', 'task_result__task_name') - search_fields = ('filename',) - date_hierarchy = 'task_result__date_created' - empty_value_display = '(none)' - - def task_status(self, obj): - """ Returns the task result with a link to view its details """ - if obj.task_result: - url = reverse('admin:%s_%s_change' % ( - obj.task_result._meta.app_label, - obj.task_result._meta.model_name - ), args=[obj.task_result.id] ) - return format_html( - "{label}", - url=url, - label=obj.task_result.status - ) - return None - task_status.admin_order_field = 'task_result__status' - - def task_name(self, obj): - """ Returns the task name for this task """ - if obj.task_result: - return obj.task_result.task_name - return None - task_name.admin_order_field = 'task_result__task_name' - - def date_created(self, obj): - """ Returns the creation date for this task """ - if obj.task_result: - return obj.task_result.date_created - return None - date_created.admin_order_field = 'task_result__date_created' - - def date_done(self, obj): - """ Returns the finished date for this task """ - if obj.task_result: - return obj.task_result.date_done - return None - date_done.admin_order_field = 'task_result__date_done' - - def has_add_permission(self, request): - return False - - def has_change_permission(self, request, obj=None): - return False - -admin.site.register(Local, LocalAdmin) -admin.site.register(Remote, RemoteAdmin) -admin.site.register(Bulk, BulkAdmin) -admin.site.register(IngestTaskWatcher, TaskWatcherAdmin) diff --git a/apps/ingest/apps.py b/apps/ingest/apps.py deleted file mode 100644 index 1db540292..000000000 --- a/apps/ingest/apps.py +++ /dev/null @@ -1,8 +0,0 @@ -"""Configuration for :class:`apps.ingest`""" -from django.apps import AppConfig - - -class IngestConfig(AppConfig): - """Ingest config""" - name = 'apps.ingest' - verbose_name = 'Ingest' diff --git a/apps/ingest/fixtures/bundle.zip b/apps/ingest/fixtures/bundle.zip deleted file mode 100644 index 65227898a..000000000 Binary files a/apps/ingest/fixtures/bundle.zip and /dev/null differ diff --git a/apps/ingest/fixtures/bundle_with_junk.zip b/apps/ingest/fixtures/bundle_with_junk.zip deleted file mode 100644 index 22f61f654..000000000 Binary files a/apps/ingest/fixtures/bundle_with_junk.zip and /dev/null differ diff --git a/apps/ingest/fixtures/bundle_with_underscores.zip b/apps/ingest/fixtures/bundle_with_underscores.zip deleted file mode 100644 index 6e266031b..000000000 Binary files a/apps/ingest/fixtures/bundle_with_underscores.zip and /dev/null differ diff --git a/apps/ingest/fixtures/csv_meta.zip b/apps/ingest/fixtures/csv_meta.zip deleted file mode 100644 index 44025ef88..000000000 Binary files a/apps/ingest/fixtures/csv_meta.zip and /dev/null differ diff --git a/apps/ingest/fixtures/manifest-label-as-array.json b/apps/ingest/fixtures/manifest-label-as-array.json deleted file mode 100644 index 01152bf1d..000000000 --- a/apps/ingest/fixtures/manifest-label-as-array.json +++ /dev/null @@ -1,232 +0,0 @@ -{ - "@context": "http://iiif.io/api/presentation/2/context.json", - "@id": "https://fake.archivelab.io/iiif/09359080.4757.emory.edu/manifest.json", - "@type": "sc:Manifest", - "attribution": "The Internet Archive", - "description": "Respect Frederick Douglass.", - "label": ["Address", "by", "American", "Hero", "Frederick", "Douglass"], - "logo": "https://encrypted-tbn2.gstatic.com/images?q=tbn:ANd9GcReMN4l9cgu_qb1OwflFeyfHcjp8aUfVNSJ9ynk2IfuHwW1I4mDSw", - "metadata": [ - { - "label": "title", - "value": "Address by Hon. Frederick Douglass, delivered in the Metropolitan A.M.E. Church, Washington, D.C., Tuesday, January 9th, 1894, on the lessons of the hour : in which he discusses the various aspects of the so-called, but mis-called, Negro problem" - }, - { - "label": "publisher", - "value": "Baltimore : Press of Thomas & Evans" - }, - { - "label": "subject", - "value": "African Americans" - }, - { - "label": "date", - "value": "1894" - }, - { - "label": "contributor", - "value": "Emory University, Robert W. Woodruff Library" - }, - { - "label": "creator", - "value": "Douglass, Frederick, 1818-1895. Metropolitan A.M.E. Church (Washington, D.C.)" - } - ], - "related": "http://archive.org/details/09359080.4757.emory.edu", - "seeAlso": "http://archive.org/metadata/09359080.4757.emory.edu", - "sequences": [ - { - "@context": "http://iiif.io/api/image/2/context.json", - "@id": "https://fake.archivelab.io/iiif/09359080.4757.emory.edu/canvas/default", - "@type": "sc:Sequence", - "canvases": [ - { - "@context": "http://iiif.io/api/presentation/2/context.json", - "@id": "https://fake.archivelab.io/iiif/09359080.4757.emory.edu$0/canvas", - "@type": "sc:Canvas", - "description": "", - "height": 2908, - "images": [ - { - "@context": "http://iiif.io/api/image/2/context.json", - "@id": "https://fake.archivelab.io/iiif/09359080.4757.emory.edu$0/annotation", - "@type": "oa:Annotation", - "motivation": "sc:painting", - "on": "https://fake.archivelab.io/iiif/09359080.4757.emory.edu$0/annotation", - "resource": { - "@id": "https://fake.archivelab.io/iiif/09359080.4757.emory.edu$0/full/full/0/default.jpg", - "@type": "dctypes:Image", - "format": "image/jpeg", - "height": 2908, - "service": { - "@context": "http://iiif.io/api/image/2/context.json", - "@id": "https://fake.archivelab.io/iiif/09359080.4757.emory.edu$0", - "profile": "https://iiif.io/api/image/2/profiles/level2.json" - }, - "width": 2039 - } - } - ], - "label": "p. ", - "width": 2039 - }, - { - "@context": "http://iiif.io/api/presentation/2/context.json", - "@id": "https://fake.archivelab.io/iiif/09359080.4757.emory.edu$1/canvas", - "@type": "sc:Canvas", - "description": "", - "height": 2908, - "images": [ - { - "@context": "http://iiif.io/api/image/2/context.json", - "@id": "https://fake.archivelab.io/iiif/09359080.4757.emory.edu$1/annotation", - "@type": "oa:Annotation", - "motivation": "sc:painting", - "on": "https://fake.archivelab.io/iiif/09359080.4757.emory.edu$1/annotation", - "resource": { - "@id": "https://fake.archivelab.io/iiif/09359080.4757.emory.edu$1/full/full/0/default.jpg", - "@type": "dctypes:Image", - "format": "image/jpeg", - "height": 2908, - "service": { - "@context": "http://iiif.io/api/image/2/context.json", - "@id": "https://fake.archivelab.io/iiif/09359080.4757.emory.edu$1", - "profile": "https://iiif.io/api/image/2/profiles/level2.json" - }, - "width": 2039 - } - } - ], - "label": "p. ", - "width": 2039 - }, - { - "@context": "http://iiif.io/api/presentation/2/context.json", - "@id": "https://fake.archivelab.io/iiif/09359080.4757.emory.edu$2/canvas", - "@type": "sc:Canvas", - "description": "", - "height": 2908, - "images": [ - { - "@context": "http://iiif.io/api/image/2/context.json", - "@id": "https://fake.archivelab.io/iiif/09359080.4757.emory.edu$2/annotation", - "@type": "oa:Annotation", - "motivation": "sc:painting", - "on": "https://fake.archivelab.io/iiif/09359080.4757.emory.edu$2/annotation", - "resource": { - "@id": "https://fake.archivelab.io/iiif/09359080.4757.emory.edu$2/full/full/0/default.jpg", - "@type": "dctypes:Image", - "format": "image/jpeg", - "height": 2908, - "service": { - "@context": "http://iiif.io/api/image/2/context.json", - "@id": "https://fake.archivelab.io/iiif/09359080.4757.emory.edu$2", - "profile": "https://iiif.io/api/image/2/profiles/level2.json" - }, - "width": 2039 - } - } - ], - "label": "p. ", - "width": 2039 - }, - { - "@context": "http://iiif.io/api/presentation/2/context.json", - "@id": "https://fake.archivelab.io/iiif/09359080.4757.emory.edu$3/canvas", - "@type": "sc:Canvas", - "description": "", - "height": 2909, - "images": [ - { - "@context": "http://iiif.io/api/image/2/context.json", - "@id": "https://fake.archivelab.io/iiif/09359080.4757.emory.edu$3/annotation", - "@type": "oa:Annotation", - "motivation": "sc:painting", - "on": "https://fake.archivelab.io/iiif/09359080.4757.emory.edu$3/annotation", - "resource": { - "@id": "https://fake.archivelab.io/iiif/09359080.4757.emory.edu$3/full/full/0/default.jpg", - "@type": "dctypes:Image", - "format": "image/jpeg", - "height": 2909, - "service": { - "@context": "http://iiif.io/api/image/2/context.json", - "@id": "https://fake.archivelab.io/iiif/09359080.4757.emory.edu$3", - "profile": "https://iiif.io/api/image/2/profiles/level2.json" - }, - "width": 2040 - } - } - ], - "label": "p. ", - "width": 2040 - }, - { - "@context": "http://iiif.io/api/presentation/2/context.json", - "@id": "https://fake.archivelab.io/iiif/09359080.4757.emory.edu$4/canvas", - "@type": "sc:Canvas", - "description": "", - "height": 2908, - "images": [ - { - "@context": "http://iiif.io/api/image/2/context.json", - "@id": "https://fake.archivelab.io/iiif/09359080.4757.emory.edu$4/annotation", - "@type": "oa:Annotation", - "motivation": "sc:painting", - "on": "https://fake.archivelab.io/iiif/09359080.4757.emory.edu$4/annotation", - "resource": { - "@id": "https://fake.archivelab.io/iiif/09359080.4757.emory.edu$4/full/full/0/default.jpg", - "@type": "dctypes:Image", - "format": "image/jpeg", - "height": 2908, - "service": { - "@context": "http://iiif.io/api/image/2/context.json", - "@id": "https://fake.archivelab.io/iiif/09359080.4757.emory.edu$4", - "profile": "https://iiif.io/api/image/2/profiles/level2.json" - }, - "width": 2039 - } - } - ], - "label": "p. ", - "width": 2039 - }, - { - "@context": "http://iiif.io/api/presentation/2/context.json", - "@id": "https://fake.archivelab.io/iiif/09359080.4757.emory.edu$5/canvas", - "@type": "sc:Canvas", - "description": "", - "height": 2909, - "images": [ - { - "@context": "http://iiif.io/api/image/2/context.json", - "@id": "https://fake.archivelab.io/iiif/09359080.4757.emory.edu$5/annotation", - "@type": "oa:Annotation", - "motivation": "sc:painting", - "on": "https://fake.archivelab.io/iiif/09359080.4757.emory.edu$5/annotation", - "resource": { - "@id": "https://fake.archivelab.io/iiif/09359080.4757.emory.edu$5/full/full/0/default.jpg", - "@type": "dctypes:Image", - "format": "image/jpeg", - "height": 2909, - "service": { - "@context": "http://iiif.io/api/image/2/context.json", - "@id": "https://fake.archivelab.io/iiif/09359080.4757.emory.edu$5", - "profile": "https://iiif.io/api/image/2/profiles/level2.json" - }, - "width": 2040 - } - } - ], - "label": "p. ", - "width": 2040 - } - ], - "label": "default" - } - ], - "thumbnail": { - "@id": "https://ia801601.us.archive.org/BookReader/BookReaderPreview.php?id=09359080.4757.emory.edu&subPrefix=09359080_4757&itemPath=/0/items/09359080.4757.emory.edu&server=ia801601.us.archive.org&page=preview&" - }, - "viewingHint": "paged", - "viewingDirection": "left-to-right" -} \ No newline at end of file diff --git a/apps/ingest/fixtures/manifest.json b/apps/ingest/fixtures/manifest.json deleted file mode 100644 index 78e69050b..000000000 --- a/apps/ingest/fixtures/manifest.json +++ /dev/null @@ -1,232 +0,0 @@ -{ - "@context": "http://iiif.io/api/presentation/2/context.json", - "@id": "https://fake.archivelab.io/iiif/09359080.4757.emory.edu/manifest.json", - "@type": "sc:Manifest", - "attribution": "The Internet Archive", - "description": "Respect Frederick Douglass.", - "label": "Address by Hon. Frederick Douglass", - "logo": "https://encrypted-tbn2.gstatic.com/images?q=tbn:ANd9GcReMN4l9cgu_qb1OwflFeyfHcjp8aUfVNSJ9ynk2IfuHwW1I4mDSw", - "metadata": [ - { - "label": "title", - "value": "Address by Hon. Frederick Douglass, delivered in the Metropolitan A.M.E. Church, Washington, D.C., Tuesday, January 9th, 1894, on the lessons of the hour : in which he discusses the various aspects of the so-called, but mis-called, Negro problem" - }, - { - "label": "publisher", - "value": "Baltimore : Press of Thomas & Evans" - }, - { - "label": "subject", - "value": "African Americans" - }, - { - "label": "date", - "value": "1894" - }, - { - "label": "contributor", - "value": "Emory University, Robert W. Woodruff Library" - }, - { - "label": "creator", - "value": "Douglass, Frederick, 1818-1895. Metropolitan A.M.E. Church (Washington, D.C.)" - } - ], - "related": "http://archive.org/details/09359080.4757.emory.edu", - "seeAlso": "http://archive.org/metadata/09359080.4757.emory.edu", - "sequences": [ - { - "@context": "http://iiif.io/api/image/2/context.json", - "@id": "https://fake.archivelab.io/iiif/09359080.4757.emory.edu/canvas/default", - "@type": "sc:Sequence", - "canvases": [ - { - "@context": "http://iiif.io/api/presentation/2/context.json", - "@id": "https://fake.archivelab.io/iiif/09359080.4757.emory.edu$0/canvas", - "@type": "sc:Canvas", - "description": "", - "height": 2908, - "images": [ - { - "@context": "http://iiif.io/api/image/2/context.json", - "@id": "https://fake.archivelab.io/iiif/09359080.4757.emory.edu$0/annotation", - "@type": "oa:Annotation", - "motivation": "sc:painting", - "on": "https://fake.archivelab.io/iiif/09359080.4757.emory.edu$0/annotation", - "resource": { - "@id": "https://fake.archivelab.io/iiif/09359080.4757.emory.edu$0/full/full/0/default.jpg", - "@type": "dctypes:Image", - "format": "image/jpeg", - "height": 2908, - "service": { - "@context": "http://iiif.io/api/image/2/context.json", - "@id": "https://fake.archivelab.io/iiif/09359080.4757.emory.edu$0", - "profile": "https://iiif.io/api/image/2/profiles/level2.json" - }, - "width": 2039 - } - } - ], - "label": "p. ", - "width": 2039 - }, - { - "@context": "http://iiif.io/api/presentation/2/context.json", - "@id": "https://fake.archivelab.io/iiif/09359080.4757.emory.edu$1/canvas", - "@type": "sc:Canvas", - "description": "", - "height": 2908, - "images": [ - { - "@context": "http://iiif.io/api/image/2/context.json", - "@id": "https://fake.archivelab.io/iiif/09359080.4757.emory.edu$1/annotation", - "@type": "oa:Annotation", - "motivation": "sc:painting", - "on": "https://fake.archivelab.io/iiif/09359080.4757.emory.edu$1/annotation", - "resource": { - "@id": "https://fake.archivelab.io/iiif/09359080.4757.emory.edu$1/full/full/0/default.jpg", - "@type": "dctypes:Image", - "format": "image/jpeg", - "height": 2908, - "service": { - "@context": "http://iiif.io/api/image/2/context.json", - "@id": "https://fake.archivelab.io/iiif/09359080.4757.emory.edu$1", - "profile": "https://iiif.io/api/image/2/profiles/level2.json" - }, - "width": 2039 - } - } - ], - "label": "p. ", - "width": 2039 - }, - { - "@context": "http://iiif.io/api/presentation/2/context.json", - "@id": "https://fake.archivelab.io/iiif/09359080.4757.emory.edu$2/canvas", - "@type": "sc:Canvas", - "description": "", - "height": 2908, - "images": [ - { - "@context": "http://iiif.io/api/image/2/context.json", - "@id": "https://fake.archivelab.io/iiif/09359080.4757.emory.edu$2/annotation", - "@type": "oa:Annotation", - "motivation": "sc:painting", - "on": "https://fake.archivelab.io/iiif/09359080.4757.emory.edu$2/annotation", - "resource": { - "@id": "https://fake.archivelab.io/iiif/09359080.4757.emory.edu$2/full/full/0/default.jpg", - "@type": "dctypes:Image", - "format": "image/jpeg", - "height": 2908, - "service": { - "@context": "http://iiif.io/api/image/2/context.json", - "@id": "https://fake.archivelab.io/iiif/09359080.4757.emory.edu$2", - "profile": "https://iiif.io/api/image/2/profiles/level2.json" - }, - "width": 2039 - } - } - ], - "label": "p. ", - "width": 2039 - }, - { - "@context": "http://iiif.io/api/presentation/2/context.json", - "@id": "https://fake.archivelab.io/iiif/09359080.4757.emory.edu$3/canvas", - "@type": "sc:Canvas", - "description": "", - "height": 2909, - "images": [ - { - "@context": "http://iiif.io/api/image/2/context.json", - "@id": "https://fake.archivelab.io/iiif/09359080.4757.emory.edu$3/annotation", - "@type": "oa:Annotation", - "motivation": "sc:painting", - "on": "https://fake.archivelab.io/iiif/09359080.4757.emory.edu$3/annotation", - "resource": { - "@id": "https://fake.archivelab.io/iiif/09359080.4757.emory.edu$3/full/full/0/default.jpg", - "@type": "dctypes:Image", - "format": "image/jpeg", - "height": 2909, - "service": { - "@context": "http://iiif.io/api/image/2/context.json", - "@id": "https://fake.archivelab.io/iiif/09359080.4757.emory.edu$3", - "profile": "https://iiif.io/api/image/2/profiles/level2.json" - }, - "width": 2040 - } - } - ], - "label": "p. ", - "width": 2040 - }, - { - "@context": "http://iiif.io/api/presentation/2/context.json", - "@id": "https://fake.archivelab.io/iiif/09359080.4757.emory.edu$4/canvas", - "@type": "sc:Canvas", - "description": "", - "height": 2908, - "images": [ - { - "@context": "http://iiif.io/api/image/2/context.json", - "@id": "https://fake.archivelab.io/iiif/09359080.4757.emory.edu$4/annotation", - "@type": "oa:Annotation", - "motivation": "sc:painting", - "on": "https://fake.archivelab.io/iiif/09359080.4757.emory.edu$4/annotation", - "resource": { - "@id": "https://fake.archivelab.io/iiif/09359080.4757.emory.edu$4/full/full/0/default.jpg", - "@type": "dctypes:Image", - "format": "image/jpeg", - "height": 2908, - "service": { - "@context": "http://iiif.io/api/image/2/context.json", - "@id": "https://fake.archivelab.io/iiif/09359080.4757.emory.edu$4", - "profile": "https://iiif.io/api/image/2/profiles/level2.json" - }, - "width": 2039 - } - } - ], - "label": "p. ", - "width": 2039 - }, - { - "@context": "http://iiif.io/api/presentation/2/context.json", - "@id": "https://fake.archivelab.io/iiif/09359080.4757.emory.edu$5/canvas", - "@type": "sc:Canvas", - "description": "", - "height": 2909, - "images": [ - { - "@context": "http://iiif.io/api/image/2/context.json", - "@id": "https://fake.archivelab.io/iiif/09359080.4757.emory.edu$5/annotation", - "@type": "oa:Annotation", - "motivation": "sc:painting", - "on": "https://fake.archivelab.io/iiif/09359080.4757.emory.edu$5/annotation", - "resource": { - "@id": "https://fake.archivelab.io/iiif/09359080.4757.emory.edu$5/full/full/0/default.jpg", - "@type": "dctypes:Image", - "format": "image/jpeg", - "height": 2909, - "service": { - "@context": "http://iiif.io/api/image/2/context.json", - "@id": "https://fake.archivelab.io/iiif/09359080.4757.emory.edu$5", - "profile": "https://iiif.io/api/image/2/profiles/level2.json" - }, - "width": 2040 - } - } - ], - "label": "p. ", - "width": 2040 - } - ], - "label": "default" - } - ], - "thumbnail": { - "@id": "https://ia801601.us.archive.org/BookReader/BookReaderPreview.php?id=09359080.4757.emory.edu&subPrefix=09359080_4757&itemPath=/0/items/09359080.4757.emory.edu&server=ia801601.us.archive.org&page=preview&" - }, - "viewingHint": "paged", - "viewingDirection": "left-to-right" -} \ No newline at end of file diff --git a/apps/ingest/fixtures/metadata.csv b/apps/ingest/fixtures/metadata.csv deleted file mode 100644 index 35814bde6..000000000 --- a/apps/ingest/fixtures/metadata.csv +++ /dev/null @@ -1,2 +0,0 @@ -Filename,Label,Summary,Author,Published city,Published date,Publisher -no_meta_file,Test Bundle,Test file,Test author,Test City,2021,Pubilsher test \ No newline at end of file diff --git a/apps/ingest/fixtures/metadata.zip b/apps/ingest/fixtures/metadata.zip deleted file mode 100644 index bee3de718..000000000 Binary files a/apps/ingest/fixtures/metadata.zip and /dev/null differ diff --git a/apps/ingest/fixtures/nested_volume.zip b/apps/ingest/fixtures/nested_volume.zip deleted file mode 100644 index 002236f5b..000000000 Binary files a/apps/ingest/fixtures/nested_volume.zip and /dev/null differ diff --git a/apps/ingest/fixtures/nga.json b/apps/ingest/fixtures/nga.json deleted file mode 100644 index 9f5d45323..000000000 --- a/apps/ingest/fixtures/nga.json +++ /dev/null @@ -1,64 +0,0 @@ -{ - "@context": "http://iiif.io/api/presentation/2/context.json", - "@id": "https://www.nga.gov/content/ngaweb/api/v1/iiif/presentation/manifest.json?cultObj:id=164652", - "@type": "sc:Manifest", - "label": "Autre veue du Campo Vacine", - "description": "Autre veue du Campo Vacine", - "logo": "https://www.nga.gov/etc/designs/ngaweb/images/nga-seal.png", - "attribution": "Provided by National Gallery of Art", - "metadata": [ - { - "label": "Artist", - "value": "Silvestre, Israël " - }, - { - "label": "Accession Number", - "value": "1981.69.12.242" - }, - { - "label": "Creation Year", - "value": "1660" - }, - { - "label": "Title", - "value": "Autre veue du Campo Vacine" - } - ], - "viewingDirection": "left-to-right", - "viewingHint": "individuals", - "sequences": [{ - "@type": "sc:Sequence", - "label": "Autre veue du Campo Vacine", - "canvases": [{ - "@id": "https://cq.nga.gov/content/ngaweb/api/v1/iiif/presentation/manifest/sequence/canvas.json?cultObj:id=164652", - "@type": "sc:Canvas", - "width": 4000, - "height": 2074, - "label": "Autre veue du Campo Vacine", - "images": [{ - "@type": "oa:Annotation", - "motivation": "sc:painting", - "resource": { - "@id": "https://www.nga.gov/content/ngaweb/api/v1/iiif/presentation/manifest/sequence/canvas/resource.json?cultObj:id=164652", - "@type": "dctypes:Image", - "format": "image/jpeg", - "height": 2074, - "width": 4000, - "service": { - "@context": "http://iiif.io/api/image/2/context.json", - "@id": "https://media.nga.gov/iiif/cba52a13-d390-4ee4-8965-250593c600ba", - "profile": "http://iiif.io/api/image/2/level2.json" - } - }, - "on": "https://cq.nga.gov/content/ngaweb/api/v1/iiif/presentation/manifest/sequence/canvas.json?cultObj:id=164652" - }], - "thumbnail": { - "@id": "https://media.nga.gov/iiif/cba52a13-d390-4ee4-8965-250593c600ba/full/!100,100/0/default.jpg", - "@type": "dctypes:Image", - "format": "image/jpeg", - "width": 100, - "height": 100 - } - }] - }] - } \ No newline at end of file diff --git a/apps/ingest/fixtures/no_meta_file.zip b/apps/ingest/fixtures/no_meta_file.zip deleted file mode 100644 index 889649226..000000000 Binary files a/apps/ingest/fixtures/no_meta_file.zip and /dev/null differ diff --git a/apps/ingest/fixtures/princeton.json b/apps/ingest/fixtures/princeton.json deleted file mode 100644 index f06300c63..000000000 --- a/apps/ingest/fixtures/princeton.json +++ /dev/null @@ -1,147 +0,0 @@ -{ - "@context": "http://iiif.io/api/presentation/2/context.json", - "@type": "sc:Manifest", - "@id": "https://figgy.princeton.edu/concern/scanned_resources/e38d7ebb-3ae6-4eb8-a3ef-d70b5184b005/manifest", - "label": ["Nvova pianta et alzata della città di Roma con tvtte le strade, piazze et edificii de tempi, palazzi, giardini et altre fabbriche antiche e moderne come si trovano al presente nel pontificato di N.S. Papa Innocentio XI con le loro dichiarationi nomi et indice copiosissimo / disegniata et intagliata da Gio. Battista Falda da Valdvggio et date al pvblico da Gio. Giacomo de Rossi dalle sve stampe in Roma alla pace l'anno 1676 con privileggio del Som. Pont"], - "description": ["\"Con l'aggivunta delle nove fabriche di chiese et altri edificij fatti sin' all'anno presente, MDCCXXX\"", "In lower right corner: Giorgio Widman intagliole littere.", "Oriented with North to the left.", "Pictorial map of Rome within the city walls as they existed in 1676.", "Includes indexes of 495 buildings and inset (smaller scale) of Rome and its environs.", "In case, mounted on fabric and folded."], - "viewingHint": "paged", - "viewingDirection": "left-to-right", - "metadata": [{ - "label": "Author", - "value": ["Falda, Giovanni Battista, approximately 1640-1678"] - }, { - "label": "Alternative", - "value": ["Nuova pianta et alzata della città di Roma con tutte le strade, piazze et edificii de tempi, palazzi, giardini et altre fabbriche antiche e moderne come si trovano al presente nel pontificato di N.S. Papa Innocentio XI con le loro dichiarationi nomi et indice copiosissimo"] - }, { - "label": "Extent", - "value": ["1 map (12 sheets) mounted on cloth ; 138 x 151 cm. in case 54 cm.", "Graphic scales given in Italian miles."] - }, { - "label": "Identifier", - "value": ["\u003ca href='http://arks.princeton.edu/ark:/88435/5d86p0240' alt='Identifier'\u003ehttp://arks.princeton.edu/ark:/88435/5d86p0240\u003c/a\u003e"] - }, { - "label": "Replaces", - "value": ["pudl0023/4166894"] - }, { - "label": "Title", - "value": [{ - "@value": "Nvova pianta et alzata della città di Roma con tvtte le strade, piazze et edificii de tempi, palazzi, giardini et altre fabbriche antiche e moderne come si trovano al presente nel pontificato di N.S. Papa Innocentio XI con le loro dichiarationi nomi et indice copiosissimo / disegniata et intagliata da Gio. Battista Falda da Valdvggio et date al pvblico da Gio. Giacomo de Rossi dalle sve stampe in Roma alla pace l'anno 1676 con privileggio del Som. Pont", - "@language": "ita" - }] - }, { - "label": "Type", - "value": ["Maps", "Maps, Pictorial"] - }, { - "label": "Contributor", - "value": ["Rossi, Giovanni Giacomo de, 1627-1691", "Widman, Georgio"] - }, { - "label": "Creator", - "value": ["Falda, Giovanni Battista, approximately 1640-1678"] - }, { - "label": "Date", - "value": ["1730"] - }, { - "label": "Language", - "value": ["Italian"] - }, { - "label": "Local Identifier", - "value": ["p0p097960k"] - }, { - "label": "Publisher", - "value": ["[Roma] : G. G. de Rossi, 1730."] - }, { - "label": "Subject", - "value": ["Rome (Italy)—Maps—Early works to 1800"] - }, { - "label": "Call Number", - "value": ["Oversize G6714.R7 F342 1730e", "Electronic Resource"] - }, { - "label": "Location", - "value": ["SAX Oversize G6714.R7 F342 1730e", "SAX Electronic Resource", "ELF1 Oversize G6714.R7 F342 1730e", "ELF1 Electronic Resource"] - }, { - "label": "Electronic Locations", - "value": [{ - "id": null, - "internal_resource": "LabeledURI", - "created_at": null, - "updated_at": null, - "new_record": true, - "uri": { - "@id": "https://catalog.princeton.edu/catalog/4166894#view" - }, - "label": "Digital content" - }] - }, { - "label": "Member Of Collections", - "value": ["Marquand Library Selections"] - }], - "sequences": [{ - "@type": "sc:Sequence", - "@id": "https://figgy.princeton.edu/concern/scanned_resources/e38d7ebb-3ae6-4eb8-a3ef-d70b5184b005/manifest/sequence/normal", - "rendering": [{ - "@id": "https://figgy.princeton.edu/concern/scanned_resources/e38d7ebb-3ae6-4eb8-a3ef-d70b5184b005/pdf", - "label": "Download as PDF", - "format": "application/pdf" - }], - "canvases": [{ - "@type": "sc:Canvas", - "@id": "https://figgy.princeton.edu/concern/scanned_resources/e38d7ebb-3ae6-4eb8-a3ef-d70b5184b005/manifest/canvas/1ba0d507-a39d-4720-bb7d-2abe2cfa4b1e", - "label": "Composite view", - "local_identifier": "p1257bv430", - "rendering": [{ - "@id": "https://figgy.princeton.edu/downloads/1ba0d507-a39d-4720-bb7d-2abe2cfa4b1e/file/64074eff-c630-44a8-9733-485e5e0e0f34", - "label": "Download the original file", - "format": "image/tiff" - }], - "width": 6945, - "height": 7200, - "images": [{ - "@type": "oa:Annotation", - "motivation": "sc:painting", - "resource": { - "@type": "dctypes:Image", - "@id": "https://iiif-cloud.princeton.edu/iiif/2/0b%2F21%2F99%2F0b21998720154beaacc8218b5063373d%2Fintermediate_file/full/1000,/0/default.jpg", - "height": 7200, - "width": 6945, - "format": "image/jpeg", - "service": { - "@context": "http://iiif.io/api/image/2/context.json", - "@id": "https://iiif-cloud.princeton.edu/iiif/2/0b%2F21%2F99%2F0b21998720154beaacc8218b5063373d%2Fintermediate_file", - "profile": "http://iiif.io/api/image/2/level2.json" - } - }, - "@id": "https://figgy.princeton.edu/concern/scanned_resources/e38d7ebb-3ae6-4eb8-a3ef-d70b5184b005/manifest/image/1ba0d507-a39d-4720-bb7d-2abe2cfa4b1e", - "on": "https://figgy.princeton.edu/concern/scanned_resources/e38d7ebb-3ae6-4eb8-a3ef-d70b5184b005/manifest/canvas/1ba0d507-a39d-4720-bb7d-2abe2cfa4b1e" - }] - }], - "viewingHint": "paged" - }], - "structures": [{ - "@type": "sc:Range", - "@id": "https://figgy.princeton.edu/concern/scanned_resources/e38d7ebb-3ae6-4eb8-a3ef-d70b5184b005/manifest/range/rb5280354-6427-4407-b83e-364733b1a988", - "label": "", - "viewingHint": "top", - "ranges": [], - "canvases": ["https://figgy.princeton.edu/concern/scanned_resources/e38d7ebb-3ae6-4eb8-a3ef-d70b5184b005/manifest/canvas/1ba0d507-a39d-4720-bb7d-2abe2cfa4b1e"] - }], - "seeAlso": [{ - "@id": "https://figgy.princeton.edu/catalog/e38d7ebb-3ae6-4eb8-a3ef-d70b5184b005.jsonld", - "format": "application/ld+json" - }, { - "@id": "https://bibdata.princeton.edu/bibliographic/4166894", - "format": "text/xml" - }], - "license": "http://rightsstatements.org/vocab/NKC/1.0/", - "thumbnail": { - "@id": "https://iiif-cloud.princeton.edu/iiif/2/0b%2F21%2F99%2F0b21998720154beaacc8218b5063373d%2Fintermediate_file/full/!200,150/0/default.jpg", - "service": { - "@context": "http://iiif.io/api/image/2/context.json", - "@id": "https://iiif-cloud.princeton.edu/iiif/2/0b%2F21%2F99%2F0b21998720154beaacc8218b5063373d%2Fintermediate_file", - "profile": "http://iiif.io/api/image/2/level2.json" - } - }, - "rendering": { - "@id": "http://arks.princeton.edu/ark:/88435/5d86p0240", - "format": "text/html" - }, - "logo": "https://figgy.princeton.edu/assets/pul_logo_icon-7b5f9384dfa5ca04f4851c6ee9e44e2d6953e55f893472a3e205e1591d3b2ca6.png" -} \ No newline at end of file diff --git a/apps/ingest/fixtures/single-image.zip b/apps/ingest/fixtures/single-image.zip deleted file mode 100644 index f7ab31fb4..000000000 Binary files a/apps/ingest/fixtures/single-image.zip and /dev/null differ diff --git a/apps/ingest/fixtures/tsv.zip b/apps/ingest/fixtures/tsv.zip deleted file mode 100644 index d0a4c02f8..000000000 Binary files a/apps/ingest/fixtures/tsv.zip and /dev/null differ diff --git a/apps/ingest/forms.py b/apps/ingest/forms.py deleted file mode 100644 index eb7414535..000000000 --- a/apps/ingest/forms.py +++ /dev/null @@ -1,11 +0,0 @@ -from django import forms -from django.forms import ClearableFileInput -from .models import Bulk - -class BulkVolumeUploadForm(forms.ModelForm): - class Meta: - model = Bulk - fields = ['image_server', 'volume_files', 'collections'] - widgets = { - 'volume_files': ClearableFileInput(attrs={'multiple': True}), - } diff --git a/apps/ingest/mail.py b/apps/ingest/mail.py deleted file mode 100644 index 0ad5fd8d2..000000000 --- a/apps/ingest/mail.py +++ /dev/null @@ -1,68 +0,0 @@ -from traceback import format_tb -from django.urls.base import reverse -from django.template.loader import get_template -from django.conf import settings -from django.core.mail import send_mail - -def send_email_on_failure(task_watcher=None, exception=None, traceback=None): - """Function to send an email on task success signal from Celery. - - :param task_watcher: The task watcher object - :type task_watcher: app.ingest.models.TaskWatcher - :param exception: Exception instance raised - :type exception: Exception - :param traceback: Stack trace object - :type traceback: traceback - """ - context = {} - if task_watcher is not None: - context['filename'] = task_watcher.filename - if exception is not None: - context['exception'] = exception.__repr__() - if traceback is not None: - context['traceback'] = '\n'.join(format_tb(traceback)) - context['result_url'] = settings.HOSTNAME + reverse( - "admin:%s_%s_change" - % ( - task_watcher.task_result._meta.app_label, - task_watcher.task_result._meta.model_name, - ), - args=[task_watcher.task_result.id], - ) - html_email = get_template('ingest_failure_email.html').render(context) - text_email = get_template('ingest_failure_email.txt').render(context) - if task_watcher is not None and task_watcher.task_creator is not None: - send_mail( - '[Readux] Failed: Ingest ' + task_watcher.filename, - text_email, - settings.READUX_EMAIL_SENDER, - [task_watcher.task_creator.email], - fail_silently=False, - html_message=html_email - ) - -def send_email_on_success(task_watcher=None): - context = {} - if task_watcher is not None: - context['filename'] = task_watcher.filename - if task_watcher is not None and task_watcher.associated_manifest is not None: - context['manifest_url'] = settings.HOSTNAME + reverse( - 'admin:manifests_manifest_change', args=(task_watcher.associated_manifest.id,) - ) - context['manifest_pid'] = task_watcher.associated_manifest.pid - context['volume_url'] = task_watcher.associated_manifest.get_volume_url() - else: - context['manifests_list_url'] = settings.HOSTNAME + reverse( - 'admin:manifests_manifest_changelist' - ) - html_email = get_template('ingest_success_email.html').render(context) - text_email = get_template('ingest_success_email.txt').render(context) - if task_watcher is not None and task_watcher.task_creator is not None: - send_mail( - '[Readux] Ingest complete: ' + task_watcher.filename, - text_email, - settings.READUX_EMAIL_SENDER, - [task_watcher.task_creator.email], - fail_silently=False, - html_message=html_email - ) \ No newline at end of file diff --git a/apps/ingest/migrations/0001_initial.py b/apps/ingest/migrations/0001_initial.py deleted file mode 100644 index 14d62394c..000000000 --- a/apps/ingest/migrations/0001_initial.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 2.2.10 on 2020-10-21 15:50 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ] - - operations = [ - migrations.CreateModel( - name='Local', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('bundle', models.FileField(upload_to='tmp/')), - ], - ), - ] diff --git a/apps/ingest/migrations/0001_squashed_0005_auto_20201027_1653.py b/apps/ingest/migrations/0001_squashed_0005_auto_20201027_1653.py deleted file mode 100644 index 54fe41309..000000000 --- a/apps/ingest/migrations/0001_squashed_0005_auto_20201027_1653.py +++ /dev/null @@ -1,30 +0,0 @@ -# Generated by Django 2.2.10 on 2020-10-29 15:21 - -import apps.ingest.models -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - replaces = [('ingest', '0001_initial'), ('ingest', '0002_auto_20201021_1610'), ('ingest', '0003_auto_20201027_1452'), ('ingest', '0004_auto_20201027_1605'), ('ingest', '0005_auto_20201027_1653')] - - initial = True - - dependencies = [ - ('manifests', '0012_auto_20200819_1608'), - ('canvases', '0005_auto_20201027_1452'), - ] - - operations = [ - migrations.CreateModel( - name='Local', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('bundle', models.FileField(upload_to='')), - ('temp_file_path', models.FilePathField(path='/tmp/tmpz0h5pz94')), - ('image_server', models.ForeignKey(null=True, on_delete=django.db.models.deletion.DO_NOTHING, to='manifests.ImageServer')), - ('manifest', models.ForeignKey(null=True, on_delete=django.db.models.deletion.DO_NOTHING, to='manifests.Manifest')), - ], - ), - ] diff --git a/apps/ingest/migrations/0002_auto_20201021_1610.py b/apps/ingest/migrations/0002_auto_20201021_1610.py deleted file mode 100644 index 5aedf96ac..000000000 --- a/apps/ingest/migrations/0002_auto_20201021_1610.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 2.2.10 on 2020-10-21 16:10 - -import apps.ingest.models -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('ingest', '0001_initial'), - ] - - operations = [ - migrations.AlterField( - model_name='local', - name='bundle', - field=models.FileField(upload_to=''), - ), - ] diff --git a/apps/ingest/migrations/0002_auto_20210106_1658.py b/apps/ingest/migrations/0002_auto_20210106_1658.py deleted file mode 100644 index d7de0b6d6..000000000 --- a/apps/ingest/migrations/0002_auto_20210106_1658.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 2.2.10 on 2021-01-06 16:58 - -import apps.ingest.models -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('ingest', '0001_squashed_0005_auto_20201027_1653'), - ] - - operations = [ - migrations.CreateModel( - name='Remote', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('remote_url', models.CharField(max_length=255)), - ('manifest', models.ForeignKey(null=True, on_delete=django.db.models.deletion.DO_NOTHING, to='manifests.Manifest')), - ], - ), - ] diff --git a/apps/ingest/migrations/0002_auto_20210107_2159.py b/apps/ingest/migrations/0002_auto_20210107_2159.py deleted file mode 100644 index 7c91a5cb7..000000000 --- a/apps/ingest/migrations/0002_auto_20210107_2159.py +++ /dev/null @@ -1,15 +0,0 @@ -# Generated by Django 2.2.10 on 2021-01-07 21:59 - -import apps.ingest.models -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('ingest', '0001_squashed_0005_auto_20201027_1653'), - ] - - operations = [ - - ] diff --git a/apps/ingest/migrations/0003_auto_20201027_1452.py b/apps/ingest/migrations/0003_auto_20201027_1452.py deleted file mode 100644 index e6fa623ab..000000000 --- a/apps/ingest/migrations/0003_auto_20201027_1452.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 2.2.10 on 2020-10-27 14:52 - -import apps.ingest.models -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('canvases', '0005_auto_20201027_1452'), - ('ingest', '0002_auto_20201021_1610'), - ] - - operations = [ - migrations.AddField( - model_name='local', - name='image_server', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='canvases.IServer'), - ) - ] diff --git a/apps/ingest/migrations/0003_auto_20210108_1619.py b/apps/ingest/migrations/0003_auto_20210108_1619.py deleted file mode 100644 index 526bc6232..000000000 --- a/apps/ingest/migrations/0003_auto_20210108_1619.py +++ /dev/null @@ -1,15 +0,0 @@ -# Generated by Django 2.2.10 on 2021-01-08 16:19 - -import apps.ingest.models -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('ingest', '0002_auto_20210106_1658'), - ] - - operations = [ - - ] diff --git a/apps/ingest/migrations/0004_auto_20201027_1605.py b/apps/ingest/migrations/0004_auto_20201027_1605.py deleted file mode 100644 index 1291e996b..000000000 --- a/apps/ingest/migrations/0004_auto_20201027_1605.py +++ /dev/null @@ -1,15 +0,0 @@ -# Generated by Django 2.2.10 on 2020-10-27 16:05 - -import apps.ingest.models -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('ingest', '0003_auto_20201027_1452'), - ] - - operations = [ - - ] diff --git a/apps/ingest/migrations/0004_auto_20210108_1922.py b/apps/ingest/migrations/0004_auto_20210108_1922.py deleted file mode 100644 index 32fa05958..000000000 --- a/apps/ingest/migrations/0004_auto_20210108_1922.py +++ /dev/null @@ -1,15 +0,0 @@ -# Generated by Django 2.2.10 on 2021-01-08 19:22 - -import apps.ingest.models -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('ingest', '0003_auto_20210108_1619'), - ] - - operations = [ - - ] diff --git a/apps/ingest/migrations/0005_auto_20201027_1653.py b/apps/ingest/migrations/0005_auto_20201027_1653.py deleted file mode 100644 index dbeb94a81..000000000 --- a/apps/ingest/migrations/0005_auto_20201027_1653.py +++ /dev/null @@ -1,26 +0,0 @@ -# Generated by Django 2.2.10 on 2020-10-27 16:53 - -import apps.ingest.models -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('manifests', '0012_auto_20200819_1608'), - ('ingest', '0004_auto_20201027_1605'), - ] - - operations = [ - migrations.AddField( - model_name='local', - name='manifest', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.DO_NOTHING, to='manifests.Manifest'), - ), - migrations.AlterField( - model_name='local', - name='image_server', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.DO_NOTHING, to='manifests.ImageServer'), - ) - ] diff --git a/apps/ingest/migrations/0005_auto_20210119_1936.py b/apps/ingest/migrations/0005_auto_20210119_1936.py deleted file mode 100644 index da61d2c4c..000000000 --- a/apps/ingest/migrations/0005_auto_20210119_1936.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 2.2.10 on 2021-01-19 19:36 - -import apps.ingest.models -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('ingest', '0004_auto_20210108_1922'), - ('manifests', '0015_auto_20210108_1922') - ] - - operations = [ - migrations.AlterField( - model_name='local', - name='image_server', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.DO_NOTHING, to='manifests.ImageServer'), - ), - ] diff --git a/apps/ingest/migrations/0006_auto_20210122_1646.py b/apps/ingest/migrations/0006_auto_20210122_1646.py deleted file mode 100644 index 64d37a74e..000000000 --- a/apps/ingest/migrations/0006_auto_20210122_1646.py +++ /dev/null @@ -1,15 +0,0 @@ -# Generated by Django 2.2.10 on 2021-01-22 16:46 - -import apps.ingest.models -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('ingest', '0005_auto_20210119_1936'), - ] - - operations = [ - - ] diff --git a/apps/ingest/migrations/0007_merge_20210208_2009.py b/apps/ingest/migrations/0007_merge_20210208_2009.py deleted file mode 100644 index 3d2772de2..000000000 --- a/apps/ingest/migrations/0007_merge_20210208_2009.py +++ /dev/null @@ -1,14 +0,0 @@ -# Generated by Django 2.2.10 on 2021-02-08 20:09 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('ingest', '0006_auto_20210122_1646'), - ('ingest', '0002_auto_20210107_2159'), - ] - - operations = [ - ] diff --git a/apps/ingest/migrations/0008_auto_20210309_1840.py b/apps/ingest/migrations/0008_auto_20210309_1840.py deleted file mode 100644 index 79c60a295..000000000 --- a/apps/ingest/migrations/0008_auto_20210309_1840.py +++ /dev/null @@ -1,15 +0,0 @@ -# Generated by Django 2.2.10 on 2021-03-09 18:40 - -import apps.ingest.models -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('ingest', '0007_merge_20210208_2009'), - ] - - operations = [ - - ] diff --git a/apps/ingest/migrations/0009_auto_20210805_1731.py b/apps/ingest/migrations/0009_auto_20210805_1731.py deleted file mode 100644 index bc58097ba..000000000 --- a/apps/ingest/migrations/0009_auto_20210805_1731.py +++ /dev/null @@ -1,37 +0,0 @@ -# Generated by Django 2.2.10 on 2021-08-05 17:31 - -import apps.ingest.models -from django.db import migrations, models -import storages.backends.s3boto3 -import uuid - - -class Migration(migrations.Migration): - - dependencies = [ - ('ingest', '0008_auto_20210309_1840'), - ] - - operations = [ - migrations.CreateModel( - name='Bulk', - fields=[ - ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ], - ), - migrations.CreateModel( - name='BulkUploads', - fields=[ - ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('volume_file', models.FileField(storage=storages.backends.s3boto3.S3Boto3Storage, upload_to=apps.ingest.models.bulk_path)), - ], - ), - migrations.AlterModelOptions( - name='local', - options={'verbose_name_plural': 'Local'}, - ), - migrations.AlterModelOptions( - name='remote', - options={'verbose_name_plural': 'Remote'}, - ), - ] diff --git a/apps/ingest/migrations/0010_auto_20210805_1743.py b/apps/ingest/migrations/0010_auto_20210805_1743.py deleted file mode 100644 index f974954ac..000000000 --- a/apps/ingest/migrations/0010_auto_20210805_1743.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 2.2.10 on 2021-08-05 17:43 - -import apps.ingest.models -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('ingest', '0009_auto_20210805_1731'), - ] - - operations = [ - migrations.RenameModel( - old_name='BulkUploads', - new_name='Volume', - ) - ] diff --git a/apps/ingest/migrations/0011_auto_20210805_1748.py b/apps/ingest/migrations/0011_auto_20210805_1748.py deleted file mode 100644 index 38aee87a3..000000000 --- a/apps/ingest/migrations/0011_auto_20210805_1748.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 2.2.10 on 2021-08-05 17:48 - -import apps.ingest.models -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('ingest', '0010_auto_20210805_1743'), - ] - - operations = [ - migrations.AddField( - model_name='volume', - name='bulk', - field=models.ForeignKey(default='6b48744e-30a6-4de3-8ff5-5a527cf48e4e', on_delete=django.db.models.deletion.DO_NOTHING, to='ingest.Bulk'), - preserve_default=False, - ) - ] diff --git a/apps/ingest/migrations/0015_auto_20210811_1605.py b/apps/ingest/migrations/0015_auto_20210811_1605.py deleted file mode 100644 index 6edcf2461..000000000 --- a/apps/ingest/migrations/0015_auto_20210811_1605.py +++ /dev/null @@ -1,25 +0,0 @@ -# Generated by Django 2.2.10 on 2021-08-11 16:05 - -import apps.ingest.models -import apps.ingest.storages -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('ingest', '0011_auto_20210805_1748'), - ] - - operations = [ - migrations.AlterField( - model_name='local', - name='bundle', - field=models.FileField(storage=apps.ingest.storages.TmpStorage(), upload_to=''), - ), - migrations.AlterField( - model_name='volume', - name='volume_file', - field=models.FileField(storage=apps.ingest.storages.TmpStorage(), upload_to=apps.ingest.models.bulk_path), - ), - ] diff --git a/apps/ingest/migrations/0016_auto_20210812_1339.py b/apps/ingest/migrations/0016_auto_20210812_1339.py deleted file mode 100644 index 4dc09b46f..000000000 --- a/apps/ingest/migrations/0016_auto_20210812_1339.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 2.2.10 on 2021-08-12 13:39 - -import apps.ingest.models -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('ingest', '0015_auto_20210811_1605'), - ] - - operations = [ - migrations.AddField( - model_name='local', - name='bundle_local', - field=models.BooleanField(default=False), - ) - ] diff --git a/apps/ingest/migrations/0017_auto_20210812_1358.py b/apps/ingest/migrations/0017_auto_20210812_1358.py deleted file mode 100644 index 66437331a..000000000 --- a/apps/ingest/migrations/0017_auto_20210812_1358.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 2.2.10 on 2021-08-12 13:58 - -import apps.ingest.models -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('ingest', '0016_auto_20210812_1339'), - ] - - operations = [ - migrations.RemoveField( - model_name='local', - name='bundle_local', - ), - migrations.AddField( - model_name='local', - name='local_bundle_path', - field=models.CharField(blank=True, max_length=100, null=True), - ) - ] diff --git a/apps/ingest/migrations/0018_auto_20210813_1514.py b/apps/ingest/migrations/0018_auto_20210813_1514.py deleted file mode 100644 index 38279670a..000000000 --- a/apps/ingest/migrations/0018_auto_20210813_1514.py +++ /dev/null @@ -1,29 +0,0 @@ -# Generated by Django 2.2.10 on 2021-08-13 15:14 - -import apps.ingest.models -import apps.ingest.storages -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('ingest', '0017_auto_20210812_1358'), - ] - - operations = [ - migrations.RemoveField( - model_name='local', - name='temp_file_path', - ), - migrations.AlterField( - model_name='local', - name='bundle', - field=models.FileField(storage=apps.ingest.storages.IngestStorage(), upload_to=''), - ), - migrations.AlterField( - model_name='volume', - name='volume_file', - field=models.FileField(storage=apps.ingest.storages.IngestStorage(), upload_to=apps.ingest.models.bulk_path), - ), - ] diff --git a/apps/ingest/migrations/0019_auto_20210819_1310.py b/apps/ingest/migrations/0019_auto_20210819_1310.py deleted file mode 100644 index 4cbd56d10..000000000 --- a/apps/ingest/migrations/0019_auto_20210819_1310.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 2.2.23 on 2021-08-19 13:10 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('ingest', '0018_auto_20210813_1514'), - ] - - operations = [ - migrations.AlterField( - model_name='local', - name='local_bundle_path', - field=models.CharField(blank=True, max_length=500, null=True), - ), - ] diff --git a/apps/ingest/migrations/0020_auto_20210915_1327.py b/apps/ingest/migrations/0020_auto_20210915_1327.py deleted file mode 100644 index c22e494ba..000000000 --- a/apps/ingest/migrations/0020_auto_20210915_1327.py +++ /dev/null @@ -1,29 +0,0 @@ -# Generated by Django 2.2.23 on 2021-09-15 13:27 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('manifests', '0020_auto_20210915_1327'), - ('ingest', '0019_auto_20210819_1310'), - ] - - operations = [ - migrations.AlterModelOptions( - name='bulk', - options={'verbose_name_plural': 'Bulk'}, - ), - migrations.AddField( - model_name='bulk', - name='image_server', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.DO_NOTHING, to='manifests.ImageServer'), - ), - migrations.AlterField( - model_name='volume', - name='bulk', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='ingest.Bulk'), - ), - ] diff --git a/apps/ingest/migrations/0021_auto_20210915_1433.py b/apps/ingest/migrations/0021_auto_20210915_1433.py deleted file mode 100644 index 619066660..000000000 --- a/apps/ingest/migrations/0021_auto_20210915_1433.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 2.2.23 on 2021-09-15 14:33 - -import apps.ingest.models -import apps.ingest.storages -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('ingest', '0020_auto_20210915_1327'), - ] - - operations = [ - migrations.AddField( - model_name='bulk', - name='volume_files', - field=models.FileField(default='test', storage=apps.ingest.storages.IngestStorage(), upload_to=apps.ingest.models.bulk_path), - preserve_default=False, - ), - migrations.DeleteModel( - name='Volume', - ), - ] diff --git a/apps/ingest/migrations/0022_auto_20210915_1651.py b/apps/ingest/migrations/0022_auto_20210915_1651.py deleted file mode 100644 index 54b998785..000000000 --- a/apps/ingest/migrations/0022_auto_20210915_1651.py +++ /dev/null @@ -1,25 +0,0 @@ -# Generated by Django 2.2.23 on 2021-09-15 16:51 - -import apps.ingest.storages -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('ingest', '0021_auto_20210915_1433'), - ] - - operations = [ - migrations.AddField( - model_name='local', - name='bulk', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='local_uploads', to='ingest.Bulk'), - ), - migrations.AlterField( - model_name='bulk', - name='volume_files', - field=models.FileField(storage=apps.ingest.storages.IngestStorage(), upload_to=''), - ), - ] diff --git a/apps/ingest/migrations/0023_auto_20210916_1803.py b/apps/ingest/migrations/0023_auto_20210916_1803.py deleted file mode 100644 index 5d25b056a..000000000 --- a/apps/ingest/migrations/0023_auto_20210916_1803.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 2.2.23 on 2021-09-16 18:03 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('ingest', '0022_auto_20210915_1651'), - ] - - operations = [ - migrations.AlterField( - model_name='bulk', - name='volume_files', - field=models.FileField(upload_to=''), - ), - migrations.AlterField( - model_name='local', - name='bulk', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='local_uploads', to='ingest.Bulk'), - ), - ] diff --git a/apps/ingest/migrations/0024_auto_20210916_1809.py b/apps/ingest/migrations/0024_auto_20210916_1809.py deleted file mode 100644 index c187f29ea..000000000 --- a/apps/ingest/migrations/0024_auto_20210916_1809.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 2.2.23 on 2021-09-16 18:09 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('ingest', '0023_auto_20210916_1803'), - ] - - operations = [ - migrations.AlterField( - model_name='local', - name='bulk', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='local_uploads', to='ingest.Bulk'), - ), - ] diff --git a/apps/ingest/migrations/0025_auto_20210920_2130.py b/apps/ingest/migrations/0025_auto_20210920_2130.py deleted file mode 100644 index 81d791c25..000000000 --- a/apps/ingest/migrations/0025_auto_20210920_2130.py +++ /dev/null @@ -1,25 +0,0 @@ -# Generated by Django 2.2.23 on 2021-09-20 21:30 - -import apps.ingest.models -import django.contrib.postgres.fields.jsonb -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('ingest', '0024_auto_20210916_1809'), - ] - - operations = [ - migrations.AddField( - model_name='local', - name='metadata', - field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, default=dict), - ), - migrations.AlterField( - model_name='bulk', - name='volume_files', - field=models.FileField(upload_to=apps.ingest.models.bulk_path), - ), - ] diff --git a/apps/ingest/migrations/0026_remote_metadata.py b/apps/ingest/migrations/0026_remote_metadata.py deleted file mode 100644 index 59225d180..000000000 --- a/apps/ingest/migrations/0026_remote_metadata.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 2.2.23 on 2021-09-20 22:08 - -import django.contrib.postgres.fields.jsonb -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('ingest', '0025_auto_20210920_2130'), - ] - - operations = [ - migrations.AddField( - model_name='remote', - name='metadata', - field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, default=dict), - ), - ] diff --git a/apps/ingest/migrations/0027_remove_local_local_bundle_path.py b/apps/ingest/migrations/0027_remove_local_local_bundle_path.py deleted file mode 100644 index 5e8ac7adf..000000000 --- a/apps/ingest/migrations/0027_remove_local_local_bundle_path.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 2.2.23 on 2021-09-22 19:57 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('ingest', '0026_remote_metadata'), - ] - - operations = [ - migrations.RemoveField( - model_name='local', - name='local_bundle_path', - ), - ] diff --git a/apps/ingest/migrations/0028_ingesttaskwatcher.py b/apps/ingest/migrations/0028_ingesttaskwatcher.py deleted file mode 100644 index ff69456d5..000000000 --- a/apps/ingest/migrations/0028_ingesttaskwatcher.py +++ /dev/null @@ -1,29 +0,0 @@ -# Generated by Django 2.2.23 on 2021-09-28 18:39 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('ingest', '0027_remove_local_local_bundle_path'), - ] - - operations = [ - migrations.CreateModel( - name='IngestTaskWatcher', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('filename', models.CharField(max_length=255, null=True)), - ('task_id', models.CharField(max_length=255, null=True)), - ('task_creator', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='created_tasks', to=settings.AUTH_USER_MODEL)), - ('task_result', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='django_celery_results.TaskResult')), - ], - options={ - 'verbose_name_plural': 'IngestTaskWatcher', - }, - ), - ] diff --git a/apps/ingest/migrations/0029_auto_20210928_1851.py b/apps/ingest/migrations/0029_auto_20210928_1851.py deleted file mode 100644 index 2044fb009..000000000 --- a/apps/ingest/migrations/0029_auto_20210928_1851.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 2.2.23 on 2021-09-28 18:51 - -from django.db import migrations -import django.db.models.manager - - -class Migration(migrations.Migration): - - dependencies = [ - ('ingest', '0028_ingesttaskwatcher'), - ] - - operations = [ - migrations.AlterModelManagers( - name='ingesttaskwatcher', - managers=[ - ('manager', django.db.models.manager.Manager()), - ], - ), - ] diff --git a/apps/ingest/migrations/0030_auto_20211007_2031.py b/apps/ingest/migrations/0030_auto_20211007_2031.py deleted file mode 100644 index ce9d6da7a..000000000 --- a/apps/ingest/migrations/0030_auto_20211007_2031.py +++ /dev/null @@ -1,25 +0,0 @@ -# Generated by Django 2.2.24 on 2021-10-07 20:31 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('ingest', '0029_auto_20210928_1851'), - ] - - operations = [ - migrations.AlterModelOptions( - name='ingesttaskwatcher', - options={'verbose_name_plural': 'Ingest Statuses'}, - ), - migrations.AddField( - model_name='local', - name='creator', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_locals', to=settings.AUTH_USER_MODEL), - ), - ] diff --git a/apps/ingest/migrations/0031_ingesttaskwatcher_associated_manifest.py b/apps/ingest/migrations/0031_ingesttaskwatcher_associated_manifest.py deleted file mode 100644 index c6cbd4e36..000000000 --- a/apps/ingest/migrations/0031_ingesttaskwatcher_associated_manifest.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 2.2.24 on 2021-10-12 16:12 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('manifests', '0029_auto_20211012_1612'), - ('ingest', '0030_auto_20211007_2031'), - ] - - operations = [ - migrations.AddField( - model_name='ingesttaskwatcher', - name='associated_manifest', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='manifests.Manifest'), - ), - ] diff --git a/apps/ingest/migrations/0032_auto_20211026_1736.py b/apps/ingest/migrations/0032_auto_20211026_1736.py deleted file mode 100644 index 02a285ddc..000000000 --- a/apps/ingest/migrations/0032_auto_20211026_1736.py +++ /dev/null @@ -1,25 +0,0 @@ -# Generated by Django 2.2.24 on 2021-10-26 17:36 - -import apps.ingest.models -import apps.ingest.storages -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('ingest', '0031_ingesttaskwatcher_associated_manifest'), - ] - - operations = [ - migrations.AddField( - model_name='local', - name='bundle_from_bulk', - field=models.FileField(null=True, upload_to=apps.ingest.models.bulk_path), - ), - migrations.AlterField( - model_name='local', - name='bundle', - field=models.FileField(null=True, storage=apps.ingest.storages.IngestStorage(), upload_to=''), - ), - ] diff --git a/apps/ingest/migrations/0033_auto_20211028_1527.py b/apps/ingest/migrations/0033_auto_20211028_1527.py deleted file mode 100644 index a10e2faec..000000000 --- a/apps/ingest/migrations/0033_auto_20211028_1527.py +++ /dev/null @@ -1,36 +0,0 @@ -# Generated by Django 2.2.24 on 2021-10-28 15:27 - -import apps.ingest.models -import apps.ingest.storages -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('kollections', '0011_auto_20211028_1527'), - ('ingest', '0032_auto_20211026_1736'), - ] - - operations = [ - migrations.AddField( - model_name='bulk', - name='collections', - field=models.ManyToManyField(blank=True, help_text='Optional: Collections to attach to ALL volumes ingested in this form.', null=True, to='kollections.Collection'), - ), - migrations.AddField( - model_name='local', - name='collections', - field=models.ManyToManyField(blank=True, help_text='Optional: Collections to attach to the volume ingested in this form.', null=True, to='kollections.Collection'), - ), - migrations.AlterField( - model_name='local', - name='bundle', - field=models.FileField(blank=True, null=True, storage=apps.ingest.storages.IngestStorage(), upload_to=''), - ), - migrations.AlterField( - model_name='local', - name='bundle_from_bulk', - field=models.FileField(blank=True, null=True, upload_to=apps.ingest.models.bulk_path), - ), - ] diff --git a/apps/ingest/migrations/0034_auto_20220112_2229.py b/apps/ingest/migrations/0034_auto_20220112_2229.py deleted file mode 100644 index 45f825c64..000000000 --- a/apps/ingest/migrations/0034_auto_20220112_2229.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 2.2.24 on 2022-01-12 22:29 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('ingest', '0033_auto_20211028_1527'), - ] - - operations = [ - migrations.AlterField( - model_name='bulk', - name='collections', - field=models.ManyToManyField(blank=True, help_text='Optional: Collections to attach to ALL volumes ingested in this form.', to='kollections.Collection'), - ), - migrations.AlterField( - model_name='local', - name='collections', - field=models.ManyToManyField(blank=True, help_text='Optional: Collections to attach to the volume ingested in this form.', to='kollections.Collection'), - ), - ] diff --git a/apps/ingest/models.py b/apps/ingest/models.py deleted file mode 100644 index 8f234f8d4..000000000 --- a/apps/ingest/models.py +++ /dev/null @@ -1,449 +0,0 @@ -""" Model classes for ingesting volumes. """ -import os -import uuid -import logging -import json -from tempfile import gettempdir -from io import BytesIO -import pysftp -from mimetypes import guess_type -from requests import get -from stream_unzip import stream_unzip, TruncatedDataError -from boto3 import client -from tablib import Dataset -from django.db import models -from django.conf import settings -from django.contrib.postgres.fields import JSONField -from django.core.files.base import ContentFile -from django_celery_results.models import TaskResult -from apps.iiif.canvases.models import Canvas -from apps.iiif.canvases.tasks import add_ocr_task, add_oa_ocr_task -from apps.iiif.kollections.models import Collection -from apps.iiif.manifests.models import Manifest, ImageServer -from apps.ingest import services -from apps.utils.fetch import fetch_url -from .storages import IngestStorage - -LOGGER = logging.getLogger(__name__) -logging.getLogger('botocore').setLevel(logging.ERROR) -logging.getLogger('boto3').setLevel(logging.ERROR) -logging.getLogger('s3transfer').setLevel(logging.ERROR) -logging.getLogger('httpretty').setLevel(logging.ERROR) -logging.getLogger('httpretty.core').setLevel(logging.ERROR) -logging.getLogger('paramiko').setLevel(logging.ERROR) - -def bulk_path(instance, filename): - return os.path.join('bulk', str(instance.id), filename ) - -class IngestTaskWatcherManager(models.Manager): - """ Manager class for associating user and ingest data with a task result """ - def create_watcher(self, filename, task_id, task_result, task_creator, associated_manifest=None): - """ - Creates an instance of IngestTaskWatcher with provided params - """ - watcher = self.create( - filename=filename, - task_id=task_id, - task_result=task_result, - task_creator=task_creator, - associated_manifest=associated_manifest - ) - return watcher - - -class IngestTaskWatcher(models.Model): - """ Model class for associating user and ingest data with a task result """ - filename = models.CharField(max_length=255, null=True) - task_id = models.CharField(max_length=255, null=True) - task_result = models.ForeignKey(TaskResult, on_delete=models.CASCADE, null=True) - task_creator = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.CASCADE, - null=True, - related_name='created_tasks' - ) - associated_manifest = models.ForeignKey(Manifest, on_delete=models.SET_NULL, null=True) - manager = IngestTaskWatcherManager() - - class Meta: - verbose_name_plural = 'Ingest Statuses' - -class IngestAbstractModel(models.Model): - metadata = JSONField(default=dict, blank=True) - manifest = models.ForeignKey(Manifest, on_delete=models.DO_NOTHING, null=True) - - class Meta: # pylint: disable=too-few-public-methods, missing-class-docstring - abstract = True - - -class Bulk(models.Model): - """ Model class for bulk ingesting volumes from local files. """ - id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - image_server = models.ForeignKey(ImageServer, on_delete=models.DO_NOTHING, null=True) - volume_files = models.FileField(blank=False, upload_to=bulk_path) - collections = models.ManyToManyField( - Collection, - blank=True, - help_text="Optional: Collections to attach to ALL volumes ingested in this form." - ) - - class Meta: - verbose_name_plural = 'Bulk' - -class Local(IngestAbstractModel): - """ Model class for ingesting a volume from local files. """ - bulk = models.ForeignKey(Bulk, related_name='local_uploads', on_delete=models.SET_NULL, null=True) - bundle_from_bulk = models.FileField(null=True, blank=True, upload_to=bulk_path) - bundle = models.FileField(null=True, blank=True, storage=IngestStorage()) - image_server = models.ForeignKey(ImageServer, on_delete=models.DO_NOTHING, null=True) - creator = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.SET_NULL, - null=True, - related_name='created_locals' - ) - collections = models.ManyToManyField( - Collection, - blank=True, - help_text="Optional: Collections to attach to the volume ingested in this form." - ) - - remote_dir = None - - class Meta: - verbose_name_plural = 'Local' - - @property - def s3_client(self): - if self.image_server.storage_service == 's3': - return client('s3') - - return None - - def create_sftp_connection(self): - - if self.image_server.storage_service == 'sftp': - connection_options = pysftp.CnOpts() - - # if os.environ['DJANGO_ENV'] == 'test': - connection_options.hostkeys = None - - return pysftp.Connection(**self.image_server.sftp_connection, cnopts=connection_options) - - return None - - def open_metadata(self): - """ - Set metadata property from extracted metadata from file. - """ - try: - for zipped_file, _, unzipped_chunks in stream_unzip(self.__zipped_chunks()): - _, file_name, file_type = self.__file_info(zipped_file) - tmp_file = bytes() - for chunk in unzipped_chunks: - if file_type and not self.__is_junk(file_name) and 'metadata' in file_name: - tmp_file += chunk - if len(tmp_file) > 0 and file_type and not self.__is_junk(file_name): - if 'csv' in file_type or 'tab-separated' in file_type: - metadata = Dataset().load(tmp_file.decode('utf-8-sig')) - elif 'officedocument' in file_type: - metadata = Dataset().load(BytesIO(tmp_file)) - if metadata is not None: - self.metadata = services.clean_metadata(metadata.dict[0]) - return - except TruncatedDataError: - # TODO: Why does `apps.ingest.tests.test_admin.IngestAdminTest.test_local_admin_save` raise this? - pass - - def volume_to_s3(self): - """ Upload images and OCR files to an S3 bucket - - :return: Tuple of two lists. One list of image files and one of OCR files - :rtype: tuple - """ - self.__unzip_bundle() - - return ( - [file.key for file in self.image_server.bucket.objects.filter(Prefix=f'{self.manifest.pid}/') if '_*ocr*_' not in file.key and file.key.split('/')[0] == self.manifest.pid], - [file.key for file in self.image_server.bucket.objects.filter(Prefix=f'{self.manifest.pid}/') if '_*ocr*_' in file.key and file.key.split('/')[0] == self.manifest.pid] - ) - - def volume_to_sftp(self): - """ Upload images and OCR files via SFTP - - :return: Tuple of two lists. One list of image files and one of OCR files - :rtype: tuple - """ - # sftp = self.create_sftp_connection() - # sftp.mkdir(self.manifest.pid) - # with sftp.cd(self.manifest.pid): - # sftp.mkdir('_*ocr*_') - - # sftp.close() - self.__unzip_bundle() - return self.__list_sftp_files() - - def bundle_to_s3(self): - """Uploads the zipfile stored in bundle_from_bulk to S3 - :param file_path: File path for uploaded file - :type file_path: str - """ - if bool(self.bundle_from_bulk): - # Save to bundle - if not bool(self.bundle): - if not os.path.isfile(self.bundle_from_bulk.path): - raise Exception(f"Could not find file: {self.bundle_from_bulk.path}") - bulk_name = self.bundle_from_bulk.name - with ContentFile(self.bundle_from_bulk.read()) as file_content: - self.bundle.save(bulk_name, file_content) - # Delete tempfile - if bool(self.bundle): - old_path = self.bundle_from_bulk.path - os.remove(old_path) - dir_path = old_path[0:old_path.rindex('/')] - if not os.path.isfile(old_path) and len(os.listdir(dir_path)) == 0: - os.rmdir(dir_path) - self.bundle_from_bulk.delete() - - @property - def file_list(self): - """Returns a list of files in the zip. Used for testing. - - :return: List of files in zip. - :rtype: list - """ - files = [] - for zipped_file, file_size, unzipped_chunks in stream_unzip(self.__zipped_chunks()): - file_path, file_name, file_type = self.__file_info(zipped_file) - files.append(file_path) - # Not looping through the chunks throws an UnexpectedSignatureError - for _ in unzipped_chunks: - pass - - return files - - def create_canvases(self): - """ - Create Canvas objects for each image file. - """ - image_files, ocr_files = ([], []) - - if self.image_server.storage_service == 's3': - image_files, ocr_files = self.volume_to_s3() - elif self.image_server.storage_service == 'sftp': - image_files, ocr_files = self.volume_to_sftp() - - if len(image_files) == 0: - # TODO: Throw an error here? - pass - - for index, key in enumerate(sorted(image_files)): - image_file = key.split('/')[-1].replace('_', '-') - - if not image_file: - continue - - ocr_file_path = None - if len(ocr_files) > 0: - image_name = '.'.join(image_file.split('.')[:-1]) - - try: - ocr_key = [key for key in ocr_files if image_name in key][0] - ocr_file_path = ocr_key - except IndexError: - # Every image may not have a matching OCR file - ocr_file_path = None - pass - - position = index + 1 - canvas, created = Canvas.objects.get_or_create( - manifest=self.manifest, - pid=f'{self.manifest.pid}_{image_file}', - ocr_file_path=ocr_file_path, - position=position - ) - - if created and canvas.ocr_file_path is not None: - if os.environ['DJANGO_ENV'] == 'test': - add_ocr_task(canvas.id) - else: - ocr_task_id = add_ocr_task.delay(canvas.id) - ocr_task_result = TaskResult(task_id=ocr_task_id) - ocr_task_result.save() - IngestTaskWatcher.manager.create_watcher( - task_id=ocr_task_id, - task_result=ocr_task_result, - task_creator=self.creator, - filename=canvas.ocr_file_path - ) - - # Request the thumbnail so a cached version is created on IIIF server. - if os.environ['DJANGO_ENV'] != 'test': - get(canvas.thumbnail) - - - if self.manifest.canvas_set.count() == len(image_files): - self.delete() - else: - # TODO: Log or though an error/waring? - pass - - def __unzip_bundle(self): - """ - Unzip and upload image and OCR files in the bundle, without loading the entire ZIP file - into memory or any of its uncompressed files. - """ - for zipped_file, _, unzipped_chunks in stream_unzip(self.__zipped_chunks()): - file_path, file_name, file_type = self.__file_info(zipped_file) - - has_type_or_is_hocr = file_name.endswith('.hocr') or file_type - tmp_file = bytes() - for chunk in unzipped_chunks: - if has_type_or_is_hocr and not self.__is_junk(file_name): - tmp_file += chunk - if (file_type or file_name.endswith('.hocr')) and not self.__is_junk(file_name): - file_name = file_name.replace('_', '-') - if file_type and 'image' in file_type and 'images' in file_path: - self.__upload_file(tmp_file, file_name, file_path) - if file_type: - is_ocr_file_type = ( - 'text' in file_type - or 'xml' in file_type - or 'json' in file_type - or 'html' in file_type - ) - if 'ocr' in file_path and ( - file_name.endswith('.hocr') or is_ocr_file_type - ): - self.__upload_file(tmp_file, file_name, file_path) - - def __upload_file(self, file, file_name, path): - remote_path = f'{self.manifest.pid}{self.image_server.path_delineator}{file_name}' - if self.image_server.storage_service == 's3': - if 'ocr' in path: - remote_path = f'{self.manifest.pid}/_*ocr*_/{file_name}' - self.image_server.bucket.upload_fileobj( - BytesIO(file), - remote_path - ) - elif self.image_server.storage_service == 'sftp': - # Check if the file will be stored in a sub directory. - self.remote_dir = os.path.dirname(remote_path) - # Make any needed local directories. - if self.remote_dir and not os.path.exists(os.path.join(gettempdir(), self.remote_dir)): - os.makedirs(os.path.join(gettempdir(), self.remote_dir)) - - # Define the full local path - local_path = os.path.join(gettempdir(), remote_path) - - # Write the file to the local disk. - with open(local_path, 'wb') as f: - f.write(BytesIO(file).getbuffer()) - - sftp = self.create_sftp_connection() - - if self.remote_dir: - # Make remote directories if needed. - if not sftp.isdir(self.remote_dir): - sftp.makedirs(self.remote_dir) - - # Change to the remote directory before upload. - sftp.chdir(self.remote_dir) - - sftp.put(local_path) - os.remove(local_path) - sftp.close() - - def __list_sftp_files(self): - sftp = self.create_sftp_connection() - - files = ( - [os.path.join(self.remote_dir, image) for image in sftp.listdir(self.remote_dir) if 'image' in guess_type(image)[0]], - [os.path.join(self.remote_dir, ocr_file) for ocr_file in sftp.listdir(self.remote_dir) if 'image' not in guess_type(ocr_file)[0]], - ) - sftp.close() - - return files - - @staticmethod - def __is_junk(path): - file = path.split('/')[-1] - return file.startswith('.') or file.startswith('~') or file.startswith('__') - - @staticmethod - def __file_info(path): - path = path.decode('UTF-8') - return [ - path, - path.split('/')[-1], - guess_type(path)[0] - ] - - def __zipped_chunks(self): - yield from self.bundle.file.obj.get()['Body'].iter_chunks(chunk_size=10240) - -class Remote(IngestAbstractModel): - """ Model class for ingesting a volume from remote manifest. """ - remote_url = models.CharField(max_length=255) - - class Meta: - verbose_name_plural = 'Remote' - - @property - def image_server(self): - """ Image server the Manifest """ - iiif_url = services.extract_image_server( - self.remote_manifest['sequences'][0]['canvases'][0] - ) - server, _created = ImageServer.objects.get_or_create(server_base=iiif_url) - - return server - - @property - def remote_manifest(self): - """Serialized remote IIIF Manifest data. - - :return: IIIF Manifest - :rtype: dict - """ - if os.environ['DJANGO_ENV'] == 'test': - return json.loads(open(os.path.join(settings.APPS_DIR, 'ingest/fixtures/manifest.json')).read()) - - return fetch_url(self.remote_url) - - def open_metadata(self): - """ Take a remote IIIF manifest and create a derivative version. """ - if self.remote_manifest['@context'] == 'http://iiif.io/api/presentation/2/context.json': - self.metadata = services.parse_iiif_v2_manifest(self.remote_manifest) - - def create_canvases(self): - # TODO: What if there are multiple sequences? Is that even allowed in IIIF? - for position, sc_canvas in enumerate(self.remote_manifest['sequences'][0]['canvases']): - canvas_metadata = None - # TODO: we will need some sort of check for IIIF API version, but not - # everyone includes a context for each canvas. - # if canvas['@context'] == 'http://iiif.io/api/presentation/2/context.json': - canvas_metadata = services.parse_iiif_v2_canvas(sc_canvas) - - if canvas_metadata is not None: - canvas, _ = Canvas.objects.get_or_create( - pid=canvas_metadata['pid'], - manifest=self.manifest, - position=position - ) - - for (key, value) in canvas_metadata.items(): - setattr(canvas, key, value) - canvas.save() - canvas.refresh_from_db() - - if os.environ['DJANGO_ENV'] != 'test': - add_ocr_task.delay(canvas.id) - - if 'otherContent' in sc_canvas and len(sc_canvas['otherContent']) > 0: - for content in sc_canvas['otherContent']: - if content['label'] == 'OCR Text': - if os.environ['DJANGO_ENV'] != 'test': - add_oa_ocr_task.delay(content['@id']) - else: - add_oa_ocr_task(content['@id']) diff --git a/apps/ingest/services.py b/apps/ingest/services.py deleted file mode 100644 index 18857a6b9..000000000 --- a/apps/ingest/services.py +++ /dev/null @@ -1,187 +0,0 @@ -""" Module of service classes and methods for ingest. """ -from mimetypes import guess_type -from urllib.parse import unquote, urlparse -from django.apps import apps -from tablib.core import Dataset -from apps.iiif.manifests.models import Manifest, RelatedLink - -def clean_metadata(metadata): - """Remove keys that do not aligin with Manifest fields. - - :param metadata: - :type metadata: tablib.Dataset - :return: Dictionary with keys matching Manifest fields - :rtype: dict - """ - metadata = {k.casefold().replace(' ', '_'): v for k, v in metadata.items()} - fields = [f.name for f in Manifest._meta.get_fields()] - invalid_keys = [] - - for key in metadata.keys(): - if key not in fields: - invalid_keys.append(key) - - for invalid_key in invalid_keys: - metadata.pop(invalid_key) - - return metadata - -def create_manifest(ingest): - """ - Create or update a Manifest from supplied metadata and images. - :return: New or updated Manifest with supplied `pid` - :rtype: iiif.manifest.models.Manifest - """ - manifest = None - # Make a copy of the metadata so we don't extract it over and over. - try: - if not bool(ingest.manifest) or ingest.manifest is None: - ingest.open_metadata() - - metadata = dict(ingest.metadata) - except TypeError: - metadata = None - if metadata: - if 'pid' in metadata: - manifest, created = Manifest.objects.get_or_create(pid=metadata['pid'].replace('_', '-')) - else: - manifest = Manifest.objects.create() - for (key, value) in metadata.items(): - setattr(manifest, key, value) - else: - manifest = Manifest() - - manifest.image_server = ingest.image_server - - # This was giving me a 'django.core.exceptions.AppRegistryNotReady: Models aren't loaded yet' error. - Remote = apps.get_model('ingest.remote') - - # Ensure that manifest has an ID before updating the M2M relationship - manifest.save() - if not isinstance(ingest, Remote): - manifest.refresh_from_db() - manifest.collections.set(ingest.collections.all()) - # Save again once relationship is set - manifest.save() - - # if type(ingest, .models.Remote): - if isinstance(ingest, Remote): - RelatedLink( - manifest=manifest, - link=ingest.remote_url, - format='application/ld+json' - ).save() - - return manifest - -def extract_image_server(canvas): - """Determins the IIIF image server URL for a given IIIF Canvas - - :param canvas: IIIF Canvas - :type canvas: dict - :return: IIIF image server URL - :rtype: str - """ - url = urlparse(canvas['images'][0]['resource']['service']['@id']) - parts = url.path.split('/') - parts.pop() - base_path = '/'.join(parts) - host = url.hostname - if url.port is not None: - host = '{h}:{p}'.format(h=url.hostname, p=url.port) - return '{s}://{h}{p}'.format(s=url.scheme, h=host, p=base_path) - -def parse_iiif_v2_manifest(data): - """Parse IIIF Manifest based on v2.1.1 or the presentation API. - https://iiif.io/api/presentation/2.1 - - :param data: IIIF Presentation v2.1.1 manifest - :type data: dict - :return: Extracted metadata - :rtype: dict - """ - properties = {} - manifest_data = [] - - if 'metadata' in data: - manifest_data.append({ 'metadata': data['metadata'] }) - - for iiif_metadata in [{prop['label']: prop['value']} for prop in data['metadata']]: - properties.update(iiif_metadata) - - # Sometimes, the label appears as a list. - if 'label' in data.keys() and isinstance(data['label'], list): - data['label'] = ' '.join(data['label']) - - manifest_data.extend([{prop: data[prop]} for prop in data if isinstance(data[prop], str)]) - - for datum in manifest_data: - properties.update(datum) - - properties['pid'] = urlparse(data['@id']).path.split('/')[-2] - properties['summary'] = data['description'] if 'description' in data else '' - - # TODO: Work out adding remote logos - if 'logo' in properties: - properties.pop('logo') - - manifest_metadata = clean_metadata(properties) - - return manifest_metadata - -def parse_iiif_v2_canvas(canvas): - """ """ - canvas_id = canvas['@id'].split('/') - pid = canvas_id[-1] if canvas_id[-1] != 'canvas' else canvas_id[-2] - - service = urlparse(canvas['images'][0]['resource']['service']['@id']) - resource = unquote(service.path.split('/').pop()) - - summary = canvas['description'] if 'description' in canvas.keys() else '' - label = canvas['label'] if 'label' in canvas.keys() else '' - return { - 'pid': pid, - 'height': canvas['height'], - 'width': canvas['width'], - 'summary': summary, - 'label': label, - 'resource': resource - } - -def get_metadata_from(files): - """ - Find metadata file in uploaded files. - :return: If metadata file exists, returns the values. If no file, returns None. - :rtype: list or None - """ - metadata = None - for file in files: - if metadata is not None: - continue - if 'zip' in guess_type(file.name)[0]: - continue - if 'metadata' in file.name.casefold(): - stream = file.read() - if 'csv' in guess_type(file.name)[0] or 'tab-separated' in guess_type(file.name)[0]: - metadata = Dataset().load(stream.decode('utf-8-sig')).dict - else: - metadata = Dataset().load(stream).dict - return metadata - -def get_associated_meta(all_metadata, file): - """ - Associate metadata with filename. - :return: If a matching filename is found, returns the row as dict, - with generated pid. Otherwise, returns {}. - :rtype: dict - """ - file_meta = {} - extless_filename = file.name[0:file.name.rindex('.')] - for meta_dict in all_metadata: - for key, val in meta_dict.items(): - if key.casefold() == 'filename': - metadata_found_filename = val - # Match filename column, case-sensitive, against filename - if metadata_found_filename and metadata_found_filename in (extless_filename, file.name): - file_meta = meta_dict - return file_meta diff --git a/apps/ingest/storages.py b/apps/ingest/storages.py deleted file mode 100644 index b10f47b5d..000000000 --- a/apps/ingest/storages.py +++ /dev/null @@ -1,8 +0,0 @@ -from storages.backends.s3boto3 import S3Boto3Storage - -class TmpStorage(S3Boto3Storage): - bucket_name = 'readux' - location = 'tmp' - -class IngestStorage(S3Boto3Storage): - bucket_name = 'readux-ingest' \ No newline at end of file diff --git a/apps/ingest/tasks.py b/apps/ingest/tasks.py deleted file mode 100644 index adfb66b88..000000000 --- a/apps/ingest/tasks.py +++ /dev/null @@ -1,118 +0,0 @@ -# pylint: disable = unused-argument - -""" Common tasks for ingest. """ -import logging -from celery import Celery -from celery.signals import task_success, task_failure -from django.apps import apps -from django.conf import settings -from django.contrib.auth import get_user_model -from django.core.files.base import ContentFile -from django_celery_results.models import TaskResult -from apps.ingest.models import IngestTaskWatcher -from .services import create_manifest -from .mail import send_email_on_failure, send_email_on_success - -# Use `apps.get_model` to avoid circular import error. Because the parameters used to -# create a background task have to be serializable, we can't just pass in the model object. -Local = apps.get_model('ingest.local') # pylint: disable = invalid-name -Remote = apps.get_model('ingest.remote') - -LOGGER = logging.getLogger(__name__) - -app = Celery('apps.ingest', result_extended=True) -app.config_from_object('django.conf:settings') -app.autodiscover_tasks(lambda: settings.INSTALLED_APPS) - -@app.task(name='creating_canvases_from_local', autoretry_for=(Exception,), retry_backoff=True, max_retries=20) -def create_canvas_form_local_task(ingest_id): - """Background task to create canvases and upload images. - - :param ingest_id: Primary key for .models.Local object - :type ingest_id: UUID - - """ - local_ingest = Local.objects.get(pk=ingest_id) - - if local_ingest.manifest is None: - local_ingest.manifest = create_manifest(local_ingest) - local_ingest.save() - local_ingest.refresh_from_db() - - local_ingest.create_canvases() - # Sometimes, the IIIF server is not ready to process the image by the time the canvas is saved to - # the database. As a double check loop through to make sure the height and width has been saved. - for canvas in local_ingest.manifest.canvas_set.all(): - if canvas.width == 0 or canvas.height == 0: - canvas.save() - -@app.task(name='creating_canvases_from_remote', autoretry_for=(Exception,), retry_backoff=True, max_retries=20) -def create_remote_canvases(ingest_id, *args, **kwargs): - """Task to create Canavs objects from remote IIIF manifest - - :param ingest_id: Primary key for .models.Remote object - :type ingest: UUID - """ - remote_ingest = Remote.objects.get(pk=ingest_id) - - if remote_ingest.manifest is None: - remote_ingest.manifest = create_manifest(remote_ingest) - remote_ingest.save() - remote_ingest.refresh_from_db() - - remote_ingest.create_canvases() - -@app.task(name='uploading_to_s3', autoretry_for=(Exception,), retry_backoff=True, max_retries=20) -def upload_to_s3_task(local_id): - """Task to create Canavs objects from remote IIIF manifest - - :param local_id: Primary key for .models.Local object - :type local_id: UUID - """ - local = Local.objects.get(pk=local_id) - - # Upload tempfile to S3 - local.bundle_to_s3() - - # Create manifest now that we have a file - if local.manifest is None: - local.manifest = create_manifest(local) - - # Queue task to create canvases etc. - local.save() - local.refresh_from_db() - local_task_id = create_canvas_form_local_task.delay(local_id) - local_task_result = TaskResult(task_id=local_task_id) - local_task_result.save() - IngestTaskWatcher.manager.create_watcher( - task_id=local_task_id, - task_result=local_task_result, - task_creator=get_user_model().objects.get(pk=local.creator.id), - associated_manifest=local.manifest, - filename=local.bundle.name - ) - -@task_failure.connect -def send_email_on_failure_task(sender=None, exception=None, task_id=None, traceback=None, *args, **kwargs): - """Function to send an email on task success signal from Celery. - - :param sender: The task object - :type sender: celery.task - """ - if sender is not None and 'creating_canvases_from_local' in sender.name: - task_watcher = IngestTaskWatcher.manager.get(task_id=task_id) - if task_watcher is not None: - send_email_on_failure(task_watcher, exception, traceback, *args, **kwargs) - -@task_success.connect -def send_email_on_success_task(sender=None, **kwargs): - """Function to send an email on task success signal from Celery. - - :param sender: The task object - :type sender: celery.task - """ - if sender is not None and 'creating_canvases_from_local' in sender.name: - task_id = sender.request.id - task_watcher = IngestTaskWatcher.manager.get(task_id=task_id) - if task_watcher is not None: - send_email_on_success(task_watcher) diff --git a/apps/ingest/templates/admin/ingest/bulk/change_form.html b/apps/ingest/templates/admin/ingest/bulk/change_form.html deleted file mode 100644 index 051b8962d..000000000 --- a/apps/ingest/templates/admin/ingest/bulk/change_form.html +++ /dev/null @@ -1,133 +0,0 @@ -{% extends "admin/change_form.html" %} -{% load i18n admin_urls %} -{% load static %} - -{% block extrastyle %} - {{ block.super }} - -{% endblock %} -{% block submit_buttons_bottom %} -
- - -
-{% endblock %} -{% block admin_change_form_document_ready %} - {{ block.super }} - -{% endblock %} -{% block content %} - {{ block.super }} -
-
Uploading...
-
0%
-
- -
-
-

- You must leave this window open during upload. -

-

- Once upload completes, you will be sent to a new page. - You may then navigate away while the rest of the ingest completes; you - will receive an email to notify you when the ingest has completed. -

-
-
-{% endblock %} \ No newline at end of file diff --git a/apps/ingest/templates/admin/ingest/local/change_form.html b/apps/ingest/templates/admin/ingest/local/change_form.html deleted file mode 100644 index 1d87df60a..000000000 --- a/apps/ingest/templates/admin/ingest/local/change_form.html +++ /dev/null @@ -1,41 +0,0 @@ -{% extends "admin/change_form.html" %} -{% load i18n admin_urls %} -{% load static %} - -{% block extrastyle %} - {{ block.super }} - -{% endblock %} -{% block submit_buttons_bottom %} - -{% endblock %} -{% block admin_change_form_document_ready %} - {{ block.super }} - -{% endblock %} -{% block content %} - {{block.super}} -
loading
-{% endblock %} \ No newline at end of file diff --git a/apps/ingest/templates/admin/ingest/remote/change_form.html b/apps/ingest/templates/admin/ingest/remote/change_form.html deleted file mode 100644 index 1d87df60a..000000000 --- a/apps/ingest/templates/admin/ingest/remote/change_form.html +++ /dev/null @@ -1,41 +0,0 @@ -{% extends "admin/change_form.html" %} -{% load i18n admin_urls %} -{% load static %} - -{% block extrastyle %} - {{ block.super }} - -{% endblock %} -{% block submit_buttons_bottom %} - -{% endblock %} -{% block admin_change_form_document_ready %} - {{ block.super }} - -{% endblock %} -{% block content %} - {{block.super}} -
loading
-{% endblock %} \ No newline at end of file diff --git a/apps/ingest/tests/factories.py b/apps/ingest/tests/factories.py deleted file mode 100644 index a580a66fe..000000000 --- a/apps/ingest/tests/factories.py +++ /dev/null @@ -1,47 +0,0 @@ -from os.path import join -from django_celery_results.models import TaskResult -from factory.django import DjangoModelFactory, FileField -from factory import Faker, SubFactory -from django.conf import settings -from apps.users.tests.factories import UserFactory -from apps.iiif.manifests.tests.factories import ImageServerFactory, ManifestFactory -from apps.ingest.models import Bulk, Local, Remote, IngestTaskWatcher - -class LocalFactory(DjangoModelFactory): - class Meta: - model = Local - - bundle = FileField(filename='bundle.zip', filepath=join(settings.APPS_DIR, 'ingest/fixtures/bundle.zip')) - image_server = SubFactory(ImageServerFactory) - manifest = None - -class RemoteFactory(DjangoModelFactory): - class Meta: - model = Remote - - manifest = None - remote_url = Faker('url') - -class BulkFactory(DjangoModelFactory): - class Meta: - model = Bulk - - volume_files = FileField(filename='bundle.zip', filepath=join(settings.APPS_DIR, 'ingest/fixtures/bundle.zip')) - image_server = SubFactory(ImageServerFactory) - -class TaskResultFactory(DjangoModelFactory): - class Meta: - model = TaskResult - - task_id = '1' - task_name = 'fake_task' - -class IngestTaskWatcherFactory(DjangoModelFactory): - class Meta: - model = IngestTaskWatcher - - task_id = '1' - filename = Faker('file_path') - task_result = SubFactory(TaskResultFactory) - task_creator = SubFactory(UserFactory) - associated_manifest = SubFactory(ManifestFactory) diff --git a/apps/ingest/tests/mock_sftp.py b/apps/ingest/tests/mock_sftp.py deleted file mode 100644 index 8bbc39ce9..000000000 --- a/apps/ingest/tests/mock_sftp.py +++ /dev/null @@ -1,50 +0,0 @@ -""" -Starts and stops a mock SFTP server using the Python `sftpserver` module. -https://github.com/rspivak/sftpserver -""" -import os -import signal -import socket -import subprocess -import tempfile - -class MockSFTP: - """ - The `sftpserver` module does not provide an obvious way to start a server, do - some stuff, and then stop the server. There is a plugin for PyTest, but it - seems to create a read-only server. I don't love this, but it will work for now. - :( - """ - def __init__(self): - self.key_file = os.path.join(tempfile.gettempdir(), 'readux_test_sshkey') - self.port = 3373 - self.host = 'localhost' - self.server = None - - if not os.path.exists(self.key_file): - subprocess.run(f'ssh-keygen -b 2048 -t rsa -f {self.key_file} -q -N ""', shell=True, check=True) - - server_command = f'sftpserver -k {self.key_file} -p {self.port} --host {self.host}' - - if not self.server: - try: - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.bind((self.host, self.port)) - sock.close() - self.server = subprocess.Popen( - server_command, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - shell=True, - start_new_session=True - ) - except OSError: - # For CircleCI, the sftp server is started in the background. - pass - - def stop_server(self): - """ Kill the process and remove the key file. """ - if self.server: - os.killpg(self.server.pid, signal.SIGTERM) - os.remove(self.key_file) - os.remove(f'{self.key_file}.pub') diff --git a/apps/ingest/tests/test_admin.py b/apps/ingest/tests/test_admin.py deleted file mode 100644 index 0c9488b47..000000000 --- a/apps/ingest/tests/test_admin.py +++ /dev/null @@ -1,391 +0,0 @@ -from os.path import join -from os import mkdir -from getpass import getuser -from shutil import rmtree -import boto3 -import httpretty -from django.conf import settings -from django.contrib.admin.sites import AdminSite -from django.core import files -from django.http import HttpResponseRedirect -from django.test import TestCase -from django.test.client import RequestFactory -from django.urls.base import reverse -from django_celery_results.models import TaskResult -from moto import mock_s3 -from apps.ingest.forms import BulkVolumeUploadForm -from apps.iiif.canvases.models import Canvas -from apps.iiif.kollections.models import Collection -from apps.iiif.kollections.tests.factories import CollectionFactory -from apps.iiif.manifests.models import Manifest -from apps.iiif.manifests.tests.factories import ManifestFactory, ImageServerFactory -from apps.ingest.models import Bulk, Local, Remote, IngestTaskWatcher -from apps.ingest.admin import BulkAdmin, LocalAdmin, RemoteAdmin, TaskWatcherAdmin -from apps.users.tests.factories import UserFactory -from .factories import BulkFactory, LocalFactory, RemoteFactory, TaskResultFactory -from .mock_sftp import MockSFTP - -@mock_s3 -class IngestAdminTest(TestCase): - @classmethod - def setUpClass(cls): - cls.sftp_server = MockSFTP() - - @classmethod - def tearDownClass(cls): - cls.sftp_server.stop_server() - - def setUp(self): - """ Set instance variables. """ - self.fixture_path = join(settings.APPS_DIR, 'ingest/fixtures/') - - self.s3_image_server = ImageServerFactory( - server_base='http://images.readux.ecds.emory', - storage_service='s3', - storage_path='readux' - ) - - self.sftp_image_server = ImageServerFactory( - server_base=self.sftp_server.host, - storage_service='sftp', - storage_path='admin_images', - sftp_port=self.sftp_server.port, - private_key_path=self.sftp_server.key_file, - sftp_user=getuser() - ) - - self.user = UserFactory.create(is_superuser=True) - - self.task_result = TaskResultFactory() - self.task_watcher = IngestTaskWatcher.manager.create_watcher( - task_id='1', - task_result=self.task_result, - task_creator=self.user, - filename='test_fake.zip' - ) - - # Create fake bucket for moto's mock S3 service. - conn = boto3.resource('s3', region_name='us-east-1') - conn.create_bucket(Bucket='readux') - conn.create_bucket(Bucket='readux-ingest') - - def test_local_admin_save_s3(self): - """It should add a create a manifest and canvases and delete the Local object""" - local = LocalFactory.build( - image_server=self.s3_image_server - ) - - original_manifest_count = Manifest.objects.count() - original_canvas_count = Canvas.objects.count() - - request_factory = RequestFactory() - - with open(join(self.fixture_path, 'no_meta_file.zip'), 'rb') as f: - content = files.base.ContentFile(f.read()) - - local.bundle = files.File(content.file, 'no_meta_file.zip') - - req = request_factory.post('/admin/ingest/local/add/', data={}) - req.user = self.user - - local_model_admin = LocalAdmin(model=Local, admin_site=AdminSite()) - local_model_admin.save_model(obj=local, request=req, form=None, change=None) - - # Saving should kick off the task to create the canvases and then delete - # the `Local` ingest object when done. - try: - local.refresh_from_db() - assert False - except Local.DoesNotExist: - assert True - - # A new `Manifest` should have been created along with the canvases - # in the ingest - assert Manifest.objects.count() == original_manifest_count + 1 - assert Canvas.objects.count() == original_canvas_count + 10 - - def test_local_admin_save_sftp(self): - """It should add a create a manifest and canvases and delete the Local object""" - httpretty.disable() - local = LocalFactory.build( - image_server=self.sftp_image_server - ) - - mkdir(local.image_server.storage_path) - - original_manifest_count = Manifest.objects.count() - original_canvas_count = Canvas.objects.count() - - request_factory = RequestFactory() - - with open(join(self.fixture_path, 'no_meta_file.zip'), 'rb') as f: - content = files.base.ContentFile(f.read()) - - local.bundle = files.File(content.file, 'no_meta_file.zip') - - req = request_factory.post('/admin/ingest/local/add/', data={}) - req.user = self.user - - local_model_admin = LocalAdmin(model=Local, admin_site=AdminSite()) - local_model_admin.save_model(obj=local, request=req, form=None, change=None) - - # Saving should kick off the task to create the canvases and then delete - # the `Local` ingest object when done. - try: - local.refresh_from_db() - assert False - except Local.DoesNotExist: - assert True - - rmtree(local.image_server.storage_path) - - # A new `Manifest` should have been created along with the canvases - # in the ingest - assert Manifest.objects.count() == original_manifest_count + 1 - assert Canvas.objects.count() == original_canvas_count + 10 - - def test_local_ingest_with_collections(self): - """It should add chosen collections to the Local's manifests""" - local = LocalFactory.build( - image_server=self.s3_image_server - ) - - # Force evaluation to get the true current list of manifests - manifests_before = list(Manifest.objects.all()) - - # Assign collections to Local - for _ in range(3): - CollectionFactory.create() - collections = Collection.objects.all() - local.save() - local.collections.set(collections) - assert len(local.collections.all()) == 3 - - # Make a local ingest - request_factory = RequestFactory() - with open(join(self.fixture_path, 'no_meta_file.zip'), 'rb') as f: - content = files.base.ContentFile(f.read()) - local.bundle = files.File(content.file, 'no_meta_file.zip') - req = request_factory.post('/admin/ingest/local/add/', data={}) - req.user = self.user - local_model_admin = LocalAdmin(model=Local, admin_site=AdminSite()) - local_model_admin.save_model(obj=local, request=req, form=None, change=None) - - # Get the newly created manifest by comparing current list to the list before - manifests_after = list(Manifest.objects.all()) - new_manifests = [x for x in manifests_after if x not in manifests_before] - assert len(new_manifests) == 1 - assert isinstance(new_manifests[0], Manifest) - - # The new manifest shouhld get the Local's collections - assert new_manifests[0].collections.count() == 3 - - def test_local_admin_response_add(self): - """It should redirect to new manifest""" - - local = LocalFactory.create(manifest=ManifestFactory.create()) - - local_model_admin = LocalAdmin(model=Local, admin_site=AdminSite()) - response = local_model_admin.response_add(obj=local, request=None) - - assert isinstance(response, HttpResponseRedirect) - assert response.url == f'/admin/manifests/manifest/{local.manifest.id}/change/' - - def test_remote_admin_save(self): - """It should add a manifest to the Local object""" - remote = RemoteFactory.create( - remote_url='https://dooley.net/manifest.json' - ) - - assert remote.manifest is None - - remote_model_admin = RemoteAdmin(model=Remote, admin_site=AdminSite()) - remote_model_admin.save_model(obj=remote, request=None, form=None, change=None) - - remote.refresh_from_db() - assert remote.manifest is not None - - def test_remote_admin_response_add(self): - """It should redirect to new manifest""" - - remote = RemoteFactory.create( - manifest=ManifestFactory.create(), - remote_url='https://poop.org/manifest.json' # pylint: disable=line-too-long - ) - - remote_model_admin = RemoteAdmin(model=Remote, admin_site=AdminSite()) - response = remote_model_admin.response_add(obj=remote, request=None) - - assert isinstance(response, HttpResponseRedirect) - assert response.url == f'/admin/manifests/manifest/{remote.manifest.id}/change/' - - def test_bulk_admin_save(self): - """It should add a Local object to this Bulk object""" - bulk = BulkFactory.create() - - assert len(bulk.local_uploads.all()) == 0 - - request_factory = RequestFactory() - req = request_factory.post('/admin/ingest/bulk/add/') - req.user = self.user - - bulk_model_admin = BulkAdmin(model=Bulk, admin_site=AdminSite()) - mock_form = BulkVolumeUploadForm() - req.FILES['volume_files'] = bulk.volume_files - bulk_model_admin.save_model(obj=bulk, request=req, form=mock_form, change=None) - - bulk.refresh_from_db() - assert len(bulk.local_uploads.all()) == 1 - - def test_bulk_admin_save_multiple(self): - """It should add three Local objects to this Bulk object""" - bulk = BulkFactory.create() - - assert len(bulk.local_uploads.all()) == 0 - - # Add 3 files to POST request - data = {} - file_list = [bulk.volume_files] - filepath2 = join(settings.APPS_DIR, 'ingest/fixtures/bundle_with_underscores.zip') - with open(filepath2, 'rb') as f: - content1 = files.base.ContentFile(f.read()) - file2 = files.File(content1.file, 'bundle_with_underscores.zip') - filepath3 = join(settings.APPS_DIR, 'ingest/fixtures/no_meta_file.zip') - with open(filepath3, 'rb') as f: - content2 = files.base.ContentFile(f.read()) - file3 = files.File(content2.file, 'no_meta_file.zip') - file_list.append(file2) - file_list.append(file3) - data['volume_files'] = file_list - - request_factory = RequestFactory() - req = request_factory.post('/admin/ingest/bulk/add/', data=data) - req.user = self.user - - bulk_model_admin = BulkAdmin(model=Bulk, admin_site=AdminSite()) - mock_form = BulkVolumeUploadForm() - bulk_model_admin.save_model(obj=bulk, request=req, form=mock_form, change=None) - - bulk.refresh_from_db() - assert len(bulk.local_uploads.all()) == 3 - - def test_bulk_admin_response_add(self): - """It should delete the Bulk object and redirect to manifests list""" - - bulk = BulkFactory.create() - bulk_model_admin = BulkAdmin(model=Bulk, admin_site=AdminSite()) - response = bulk_model_admin.response_add(obj=bulk, request=None) - - with self.assertRaises(Bulk.DoesNotExist): - bulk.refresh_from_db() - assert isinstance(response, HttpResponseRedirect) - assert response.url == reverse('admin:ingest_ingesttaskwatcher_changelist') - - def test_bulk_admin_with_external_metadata(self): - """It should add the metadata to the matching Local object""" - bulk = BulkFactory.create(image_server=self.s3_image_server) - - data = {} - data['volume_files'] = [] - - # Mock upload metadata csv with matching pid for zip - filepath1 = join(settings.APPS_DIR, 'ingest/fixtures/metadata.csv') - with open(filepath1, 'rb') as open_file: - content1 = files.base.ContentFile(open_file.read()) - file1 = files.File(content1.file, 'metadata.csv') - data['volume_files'].append(file1) - - # Mock upload a zip with no metadata - filepath2 = join(settings.APPS_DIR, 'ingest/fixtures/no_meta_file.zip') - with open(filepath2, 'rb') as open_file: - content2 = files.base.ContentFile(open_file.read()) - file2 = files.File(content2.file, 'no_meta_file.zip') - data['volume_files'].append(file2) - - request_factory = RequestFactory() - req = request_factory.post('/admin/ingest/bulk/add/', data=data) - req.user = self.user - - bulk_model_admin = BulkAdmin(model=Bulk, admin_site=AdminSite()) - mock_form = BulkVolumeUploadForm() - bulk_model_admin.save_model(obj=bulk, request=req, form=mock_form, change=None) - - bulk.refresh_from_db() - - local = bulk.local_uploads.first() - assert local.metadata is not None - assert isinstance(local.metadata, dict) - assert len(local.metadata) != 0 - assert local.metadata['label'] == 'Test Bundle' - - - def test_task_watcher_admin_functions(self): - """It should get the appropriate values from the watcher's associated TaskResult""" - watcher = self.task_watcher - assert isinstance(watcher.task_result, TaskResult) - assert watcher.task_id == watcher.task_result.task_id - assert watcher.task_result.task_name == 'fake_task' - assert watcher.task_result.status == 'PENDING' - - watcher_admin = TaskWatcherAdmin(model=IngestTaskWatcher, admin_site=AdminSite()) - assert watcher_admin.task_name(watcher) == 'fake_task' - assert 'PENDING' in watcher_admin.task_status(watcher) - - def test_bulk_ingest_with_collections(self): - """It should add the collections to the matching Local object""" - bulk = BulkFactory.create() - for _ in range(3): - CollectionFactory.create() - collections = Collection.objects.all() - bulk.save() - bulk.collections.set(collections) - data = {} - data['volume_files'] = [bulk.volume_files] - - request_factory = RequestFactory() - req = request_factory.post('/admin/ingest/bulk/add/', data=data) - req.user = self.user - - bulk_model_admin = BulkAdmin(model=Bulk, admin_site=AdminSite()) - mock_form = BulkVolumeUploadForm() - bulk_model_admin.save_model(obj=bulk, request=req, form=mock_form, change=None) - bulk.refresh_from_db() - created_local = bulk.local_uploads.first() - - assert created_local.collections.count() == 3 - - - # def test_local_admin_save_update_manifest(self): - # """It should add a manifest to the Local object""" - # local = LocalFactory.create() - - # assert local.manifest is None - - # request_factory = RequestFactory() - - # with open(join(self.fixture_path, 'no_meta_file.zip'), 'rb') as f: - # content = files.base.ContentFile(f.read()) - - # local.bundle = files.File(content.file, 'no_meta_file.zip') - - # req = request_factory.post('/admin/ingest/local/add/', data={}) - - # local_model_admin = LocalAdmin(model=Local, admin_site=AdminSite()) - # local_model_admin.save_model(obj=local, request=req, form=None, change=None) - - # local.refresh_from_db() - - # assert local.manifest is not None - # assert not local.manifest.label - - # manifest = local.manifest - # new_label = Faker._get_faker().name() - # manifest.label = new_label - # manifest.save() - - # assert manifest.label == new_label - - # create_canvas_form_local_task(local.id) - # manifest.refresh_from_db() - - # assert manifest.label == new_label diff --git a/apps/ingest/tests/test_local.py b/apps/ingest/tests/test_local.py deleted file mode 100644 index 8a77cb901..000000000 --- a/apps/ingest/tests/test_local.py +++ /dev/null @@ -1,370 +0,0 @@ -""" Tests for local ingest """ -from os import path, remove, mkdir -from getpass import getuser -from shutil import rmtree -import logging -import pytest -import boto3 -import httpretty -from moto import mock_s3 -from django.test import TestCase -from django.core.files.uploadedfile import SimpleUploadedFile -from django.conf import settings -from apps.iiif.canvases.models import Canvas -from apps.iiif.manifests.tests.factories import ManifestFactory, ImageServerFactory -from .mock_sftp import MockSFTP -from ..models import Local -from ..services import create_manifest -from ..storages import IngestStorage - -pytestmark = pytest.mark.django_db(transaction=True) # pylint: disable = invalid-name -LOGGER = logging.getLogger(__name__) - -@mock_s3 -class LocalTest(TestCase): - """ Tests for ingest.models.Local """ - @classmethod - def setUpClass(cls): - cls.sftp_server = MockSFTP() - - @classmethod - def tearDownClass(cls): - cls.sftp_server.stop_server() - - def setUp(self): - """ Set instance variables. """ - LOGGER.debug('SETTING UP') - self.fixture_path = path.join(settings.APPS_DIR, 'ingest/fixtures/') - self.image_server = ImageServerFactory( - server_base='http://readux.s3.amazonaws.com', - storage_service='s3', - storage_path='readux' - ) - # Create fake bucket for moto's mock S3 service. - conn = boto3.resource('s3', region_name='us-east-1') - conn.create_bucket(Bucket=self.image_server.storage_path) - conn.create_bucket(Bucket='readux-ingest') - - def mock_local(self, bundle, with_manifest=False, metadata={}, from_bulk=False): - # Note, I tried to use the factory here, but could not get it to override the file for bundle. - local = Local( - image_server = self.image_server, - metadata = metadata - ) - local.save() - file = SimpleUploadedFile( - name=bundle, - content=open(path.join(self.fixture_path, bundle), 'rb').read() - ) - if from_bulk: - local.bundle_from_bulk.save(bundle, file) - else: - local.bundle = file - - local.save() - - if with_manifest: - local.manifest = create_manifest(local) - local.save() - - return local - - def sftp_image_server(self): - if path.isdir('images'): - rmtree('images') - mkdir('images') - return ImageServerFactory( - server_base=self.sftp_server.host, - storage_service='sftp', - storage_path='images', - sftp_port=self.sftp_server.port, - private_key_path=self.sftp_server.key_file, - sftp_user=getuser() - ) - - - def test_bundle_upload(self): - """ It should upload the images using a fake S3 service from moto. """ - for bundle in ['bundle.zip', 'nested_volume.zip', 'csv_meta.zip']: - self.mock_local(bundle) - assert bundle in [f.key for f in IngestStorage().bucket.objects.all()] - - def test_image_upload_to_s3(self): - local = self.mock_local('bundle.zip', with_manifest=True) - - local.volume_to_s3() - - image_files = [f.key for f in local.image_server.bucket.objects.filter(Prefix=local.manifest.pid)] - - assert f'{local.manifest.pid}/00000008.jpg' in image_files - - def test_upload_to_sftp_with_default_delineator(self): - httpretty.disable() - local = self.mock_local('bundle.zip', with_manifest=True) - local.image_server = self.sftp_image_server() - - image_files, ocr_files = local.volume_to_sftp() - - assert path.exists( - path.join( - local.image_server.storage_path, - f'{local.manifest.pid}{local.image_server.path_delineator}00000005.jpg' - ) - ) - - assert path.exists( - path.join( - local.image_server.storage_path, - f'{local.manifest.pid}{local.image_server.path_delineator}00000005.tsv' - ) - ) - - rmtree(local.image_server.storage_path) - - assert path.join(local.manifest.pid, '00000008.tsv') in ocr_files - assert path.join(local.manifest.pid, '00000008.jpg') in image_files - - def test_ocr_upload_to_s3(self): - local = self.mock_local('nested_volume.zip', with_manifest=True) - - local.volume_to_s3() - - ocr_files = [f.key for f in local.image_server.bucket.objects.filter(Prefix=local.manifest.pid)] - - assert f'{local.manifest.pid}/_*ocr*_/00000008.tsv' in ocr_files - - def test_upload_to_sftp_with_underscore_delineator(self): - httpretty.disable() - local = self.mock_local('bundle.zip', with_manifest=True) - local.image_server = self.sftp_image_server() - local.image_server.path_delineator = '_' - - image_files, ocr_files = local.volume_to_sftp() - - assert path.exists( - path.join( - local.image_server.storage_path, - f'{local.manifest.pid}{local.image_server.path_delineator}00000005.jpg' - ) - ) - - assert path.exists( - path.join( - local.image_server.storage_path, - f'{local.manifest.pid}{local.image_server.path_delineator}00000003.tsv' - ) - ) - - rmtree(local.image_server.storage_path) - - assert f'{local.manifest.pid}_00000008.jpg' in image_files - assert f'{local.manifest.pid}_00000008.tsv' in ocr_files - - def test_metadata_from_excel(self): - """ It should create a manifest with metadata supplied in an Excel file. """ - local = self.mock_local('bundle.zip', with_manifest=True) - - assert 'pid' in local.metadata.keys() - - for key in local.metadata.keys(): - assert str(local.metadata[key]) == str(getattr(local.manifest, key)) - - def test_metadata_from_csv(self): - """ It should create a manifest with metadata supplied in a CSV file. """ - local = self.mock_local('csv_meta.zip', with_manifest=True) - - assert 'pid' in local.metadata.keys() - - for key in local.metadata.keys(): - assert local.metadata[key] == getattr(local.manifest, key) - - def test_metadata_from_tsv(self): - """ It should create a manifest with metadata supplied in a CSV file. """ - local = self.mock_local('tsv.zip', with_manifest=True) - - assert 'pid' in local.metadata.keys() - - for key in local.metadata.keys(): - assert local.metadata[key] == getattr(local.manifest, key) - - def test_no_metadata_file(self): - """ It should create a Manifest even when no metadata file is supplied. """ - local = self.mock_local('no_meta_file.zip', with_manifest=True) - - assert isinstance(local.manifest.pid, str) - assert len(local.manifest.pid) == 8 - - def test_single_image(self): - """ It should work when only one image is present. """ - local = self.mock_local('single-image.zip', with_manifest=True) - - local.volume_to_s3() - - image_files = [f.key for f in local.image_server.bucket.objects.filter(Prefix=local.manifest.pid)] - - assert f'{local.manifest.pid}/0011.jpg' in image_files - - def test_removing_junk(self): - """ - Any hidden files should not be uploaded. - """ - local = self.mock_local('bundle_with_junk.zip', with_manifest=True) - - local.volume_to_s3() - - ingest_files = [f.key for f in local.image_server.bucket.objects.filter(Prefix=local.manifest.pid)] - - assert 'ocr/.junk.tsv' in local.file_list - assert 'images/.00000010.jpg' in local.file_list - assert f'{local.manifest.pid}/00000009.jpg' in ingest_files - assert f'{local.manifest.pid}/.00000010.jpg' not in ingest_files - assert f'{local.manifest.pid}/_*ocr*_/00000003.tsv' in ingest_files - assert f'{local.manifest.pid}/_*ocr*_/.junk.tsv' not in ingest_files - - def test_removing_underscores(self): - """ - Any hidden files should be removed. - """ - local = self.mock_local('bundle_with_underscores.zip', with_manifest=True) - - local.volume_to_s3() - - ingest_files = [f.key for f in local.image_server.bucket.objects.filter(Prefix=local.manifest.pid)] - - underscore_files = [f for f in local.file_list if '_' in f] - assert len(underscore_files) == 10 - assert len([f for f in local.file_list if '-' in f]) == 0 - for underscore in [f for f in local.file_list if '_' in f]: - assert underscore not in ingest_files - - def test_when_metadata_in_filename(self): - """ - Make sure it doesn't get get confused when the word "metadata" is in - every path. - """ - local = self.mock_local('metadata.zip', with_manifest=True) - - image_files, ocr_files = local.volume_to_s3() - - files_in_zip = local.file_list - ingest_files = [f.key for f in local.image_server.bucket.objects.filter(Prefix=local.manifest.pid)] - - assert 'metadata/images/' in files_in_zip - assert all('metadata' in f for f in files_in_zip) - assert any('.DS_Store' in f for f in files_in_zip) - assert any('__MACOSX' in f for f in files_in_zip) - assert any('~$metadata.xlsx' in f for f in files_in_zip) - assert any('metadata/ocr/0001.tsv' in f for f in files_in_zip) - assert local.metadata['pid'] == 't9wtf-sample' - assert local.metadata['label'] == 't9wtf-sample' - assert '~$metadata.xlsx' not in ingest_files - assert f'{local.manifest.pid}/_*ocr*_/0001.tsv' in ocr_files - assert f'{local.manifest.pid}/0001.jpg' in image_files - assert len(ingest_files) == 2 - - def test_when_underscore_in_pid(self): - local = self.mock_local('no_meta_file.zip') - local.manifest = ManifestFactory.create( - image_server = self.image_server, - pid='p_i_d' - ) - - local.volume_to_s3() - local.volume_to_s3() - - ingest_files = [f.key for f in local.image_server.bucket.objects.filter(Prefix=local.manifest.pid)] - - assert all('p-i-d' in f for f in ingest_files) - - def test_creating_canvases(self): - """ - Make sure it doesn't get get confused when the word "metadata" is in - every path. - """ - local = self.mock_local('bundle.zip', with_manifest=True) - local.create_canvases() - - pid = local.manifest.pid - - assert local.manifest.canvas_set.all().count() == 10 - assert Canvas.objects.get(pid=f'{pid}_00000001.jpg').position == 1 - assert Canvas.objects.get(pid=f'{pid}_00000002.jpg').position == 2 - assert Canvas.objects.get(pid=f'{pid}_00000003.jpg').position == 3 - assert Canvas.objects.get(pid=f'{pid}_00000004.jpg').position == 4 - assert Canvas.objects.get(pid=f'{pid}_00000005.jpg').position == 5 - assert Canvas.objects.get(pid=f'{pid}_00000006.jpg').position == 6 - assert Canvas.objects.get(pid=f'{pid}_00000007.jpg').position == 7 - assert Canvas.objects.get(pid=f'{pid}_00000008.jpg').position == 8 - assert Canvas.objects.get(pid=f'{pid}_00000009.jpg').position == 9 - assert Canvas.objects.get(pid=f'{pid}_00000010.jpg').position == 10 - - - # def test_it_downloads_zip_when_local_bundle_path_is_not_none(self): - # local = self.mock_local('metadata.zip', with_manifest=True) - # local.local_bundle_path = 'swoop' - # files_in_zip = local.file_list - # assert 'metadata/images/' in files_in_zip - # assert exists(join(gettempdir(), 'metadata.zip')) - # assert local.local_bundle_path == join(gettempdir(), 'metadata.zip') - - # def test_it_cleans_up(self): - # local = self.mock_local('single-image.zip', with_manifest=True) - # local.local_bundle_path = 'swoop' - # local.zip_ref - # assert exists(join(gettempdir(), 'single-image.zip')) - # local.create_canvases() - # manifest = Manifest.objects.get(pk=local.manifest.id) - # assert manifest.canvas_set.count() == 1 - # assert exists(join(gettempdir(), 'single-image.zip')) is False - # try: - # Local.objects.get(pk=local.id) - # except Local.DoesNotExist: - # pass - - def test_it_creates_mainfest_with_metadata_property(self): - metadata = { - 'pid': '808', - 'title': 'Goodie Mob' - } - local = self.mock_local('no_meta_file.zip', metadata=metadata) - local.manifest = create_manifest(local) - assert local.manifest.pid == '808' - assert local.manifest.title == 'Goodie Mob' - - def test_moving_bulk_bundle_to_s3(self): - """ - It should upload Local.bundle_from_bulk to mock S3 by saving it to - Local.bundle, then it should clean up tempfiles - """ - # Make sure local with from_bulk is mocked correctly - local = self.mock_local('bundle.zip', from_bulk=True) - assert bool(local.bundle_from_bulk) is True - bulk_name = local.bundle_from_bulk.name - bulk_path = local.bundle_from_bulk.path - dir_path = bulk_path[0:bulk_path.rindex('/')] - assert bulk_name[bulk_name.rindex('/')+1:] == 'bundle.zip' - assert path.isfile(bulk_path) is True - assert path.isdir(dir_path) is True - - # Call bundle_to_s3() and test that it uploaded the file - local.bundle_to_s3() - assert bool(local.bundle) is True - assert local.bundle.name[local.bundle.name.rindex('/')+1:] == 'bundle.zip' - assert local.bundle.storage.exists(f'bulk/{local.id}/bundle.zip') # pylint: disable=no-member - - # Test tempfile cleanup - assert bool(local.bundle_from_bulk) is False - assert path.isfile(bulk_path) is False - assert path.isdir(dir_path) is False - - def test_bundle_to_s3_fails_for_deleted_tempfile(self): - """ - It should raise an exception because we deleted the tempfile before - running bundle_to_s3 - """ - local = self.mock_local('bundle.zip', from_bulk=True) - bulk_path = local.bundle_from_bulk.path - assert path.isfile(bulk_path) is True - remove(bulk_path) - assert path.isfile(bulk_path) is False - self.assertRaises(Exception, local.bundle_to_s3) diff --git a/apps/ingest/tests/test_mail.py b/apps/ingest/tests/test_mail.py deleted file mode 100644 index dd37f7c8c..000000000 --- a/apps/ingest/tests/test_mail.py +++ /dev/null @@ -1,33 +0,0 @@ -from html import escape -from traceback import format_tb -from django.core import mail -from django.test import TestCase -from apps.utils.fake_traceback import FakeException, FakeTraceback -from ..mail import send_email_on_failure, send_email_on_success -from .factories import IngestTaskWatcherFactory - -class MailTest(TestCase): - def test_send_failure_email(self): - task_watcher = IngestTaskWatcherFactory.create() - fake_tb = FakeTraceback() - fake_exc = FakeException('error').with_traceback(fake_tb) - - send_email_on_failure(task_watcher, fake_exc, fake_tb) - - # Test that one message has been sent. - self.assertEqual(len(mail.outbox), 1) - - # Verify that the subject of the first message is correct. - self.assertEqual(mail.outbox[0].subject, f'[Readux] Failed: Ingest {task_watcher.filename}') - assert escape(format_tb(fake_tb)[0]) in mail.outbox[0].body - - def test_send_success_email(self): - task_watcher = IngestTaskWatcherFactory.create() - - send_email_on_success(task_watcher) - - # Test that one message has been sent. - self.assertEqual(len(mail.outbox), 1) - - # Verify that the subject of the first message is correct. - self.assertEqual(mail.outbox[0].subject, f'[Readux] Ingest complete: {task_watcher.filename}') diff --git a/apps/ingest/tests/test_remote.py b/apps/ingest/tests/test_remote.py deleted file mode 100644 index ccc9a2aa9..000000000 --- a/apps/ingest/tests/test_remote.py +++ /dev/null @@ -1,40 +0,0 @@ -""" Tests for Remote Ingests """ -import os -from django.test import TestCase -from django.conf import settings -from apps.iiif.manifests.tests.factories import ManifestFactory -from .factories import RemoteFactory - -class RemoteTest(TestCase): - """ Tests for ingest.models.Remote """ - def setUp(self): - """ Set instance variables. """ - self.fixture_path = os.path.join(settings.APPS_DIR, 'ingest/fixtures/') - - def test_metadata_from_remote_manifest(self): - """ It should extract properties from remote IIIF manifest. """ - remote = RemoteFactory.create( - manifest=ManifestFactory.create(), - remote_url='https://iiif.archivelab.org/iiif/09359080.4757.emory.edu/manifest.json' # pylint: disable=line-too-long - ) - remote.open_metadata() - assert remote.metadata['pid'] == '09359080.4757.emory.edu' - assert remote.metadata['label'] == 'Address by Hon. Frederick Douglass' - assert remote.metadata['summary'] == 'Respect Frederick Douglass.' - assert remote.metadata['viewingdirection'] == 'left-to-right' - assert remote.metadata['publisher'] == 'Baltimore : Press of Thomas & Evans' - assert remote.metadata['attribution'] == 'The Internet Archive' - assert isinstance(remote.metadata['metadata'], list) - assert 'logo' not in remote.metadata - - def test_create_canvases_from_remote_manifest(self): - """ It should extract properties from remote IIIF manifest. """ - manifest = ManifestFactory.create() - for canvas in manifest.canvas_set.all(): - canvas.delete() - remote = RemoteFactory.create( - manifest=manifest, - remote_url='https://iiif.archivelab.org/iiif/09359080.4757.emory.edu/manifest.json' # pylint: disable=line-too-long - ) - remote.create_canvases() - self.assertEqual(remote.manifest.canvas_set.count(), 6) diff --git a/apps/ingest/tests/test_services.py b/apps/ingest/tests/test_services.py deleted file mode 100644 index 331d8e614..000000000 --- a/apps/ingest/tests/test_services.py +++ /dev/null @@ -1,108 +0,0 @@ -""" Tests for ingest.services """ -import os -import json -import boto3 -from moto import mock_s3 -from django.test import TestCase -from django.core.files.uploadedfile import SimpleUploadedFile -from django.conf import settings -from factory.django import FileField -from apps.iiif.canvases.tests.factories import CanvasFactory -from apps.iiif.manifests.models import Manifest -from apps.iiif.manifests.tests.factories import ManifestFactory, ImageServerFactory -import apps.ingest.services as services -from .factories import LocalFactory, RemoteFactory - -class ServicesTest(TestCase): - """ Tests for ingest.services """ - def setUp(self): - """ Set instance variables. """ - self.fixture_path = os.path.join(settings.APPS_DIR, 'ingest/fixtures/') - - def test_cleaning_metadata(self): - """ It should normalize keys and remove key/value pairs that - do not match a Manifest field. """ - fake_metadata = { - 'pid': 'blm', - 'invalid': 'trump', - 'summary': 'idk', - 'PUBLISHER': 'ecds', - 'Published City': 'atlanta' - } - - cleaned_metadata = services.clean_metadata(fake_metadata) - - manifest_fields = [f.name for f in Manifest._meta.get_fields()] - - for key in cleaned_metadata.keys(): - assert key in manifest_fields - - assert 'Published City' not in cleaned_metadata.keys() - assert 'PUBLISHER' not in cleaned_metadata.keys() - assert 'invalid' not in cleaned_metadata.keys() - assert cleaned_metadata['published_city'] == fake_metadata['Published City'] - assert cleaned_metadata['publisher'] == fake_metadata['PUBLISHER'] - - def test_extracting_image_server_with_port(self): - """ If should return a URL with a specified port. """ - canvas = { - 'images': [{ - 'resource': { - 'service': { - '@id': "https://readux.org:8000/iiif/readux:thgg7_1.jp2" - } - } - }] - } - image_server_url = services.extract_image_server(canvas) - assert image_server_url == 'https://readux.org:8000/iiif' - - def test_extracting_image_server_without_port(self): - """ If should return a URL with no port. """ - canvas = { - 'images': [{ - 'resource': { - 'service': { - '@id': "https://readux.org/iiif/readux:thgg7_1.jp2" - } - } - }] - } - image_server_url = services.extract_image_server(canvas) - assert image_server_url == 'https://readux.org/iiif' - - def test_adding_related_link_to_remote_ingest_manifest(self): - remote = RemoteFactory.create( - remote_url='https://swoop.net/manifest.json' # pylint: disable=line-too-long - ) - manifest = services.create_manifest(remote) - related_link = manifest.relatedlink_set.first() - assert related_link.link == remote.remote_url - - def test_parse_v2_manifest_with_label_as_list(self): - data = json.loads(open(os.path.join(settings.APPS_DIR, 'ingest/fixtures/manifest-label-as-array.json')).read()) - metadata = services.parse_iiif_v2_manifest(data) - self.assertEqual(metadata['label'], 'Address by American Hero Frederick Douglass') - - @mock_s3 - def test_when_pid_not_in_metadata(self): - image_server = ImageServerFactory.create() - conn = boto3.resource('s3', region_name='us-east-1') - conn.create_bucket(Bucket=image_server.storage_path) - conn.create_bucket(Bucket='readux-ingest') - for _ in range(1, 5): - ManifestFactory.create() - local = LocalFactory.create( - image_server=image_server, - bundle=FileField( - filename='no_meta_file.zip', - filepath=os.path.join(settings.APPS_DIR, 'ingest/fixtures/no_meta_file.zip') - ) - ) - local.metadata['label'] = 'Southernplayalisticadillacmuzik' - local.manifest = None - assert 'pid' not in local.metadata - assert dict(local.metadata) is not None - local.manifest = services.create_manifest(local) - assert local.manifest.label == 'Southernplayalisticadillacmuzik' - assert local.manifest.pid is not None diff --git a/apps/ingest/views.py b/apps/ingest/views.py deleted file mode 100644 index 91ea44a21..000000000 --- a/apps/ingest/views.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.shortcuts import render - -# Create your views here. diff --git a/apps/ingest/tests/__init__.py b/apps/ocr/__init__.py similarity index 100% rename from apps/ingest/tests/__init__.py rename to apps/ocr/__init__.py diff --git a/apps/ocr/apps.py b/apps/ocr/apps.py new file mode 100644 index 000000000..456d2227c --- /dev/null +++ b/apps/ocr/apps.py @@ -0,0 +1,7 @@ +"""Configuration for :class:`apps.ocr`""" +from django.apps import AppConfig + +class OCRConfig(AppConfig): + """OCR config""" + name = 'apps.ocr' + verbose_name = 'OCR' diff --git a/apps/ocr/urls.py b/apps/ocr/urls.py new file mode 100644 index 000000000..795bad2fe --- /dev/null +++ b/apps/ocr/urls.py @@ -0,0 +1,11 @@ +"""Url patterns for :class:`apps.iiif.annotations`""" +from django.urls import path +from . import views + +urlpatterns = [ + path( + '/comments//ocr', + views.WebAnnotationOCRForPage.as_view(), + name='web_annotation_ocr' + ) +] diff --git a/apps/ocr/views.py b/apps/ocr/views.py new file mode 100644 index 000000000..238befb8f --- /dev/null +++ b/apps/ocr/views.py @@ -0,0 +1,48 @@ +"""Django views for :class:`apps.iiif.annotations`""" +import json +from django.views.generic import ListView +from django.core.serializers import serialize +from django.contrib.auth import get_user_model +from django.http import JsonResponse +from apps.iiif.canvases.models import Canvas + +USER = get_user_model() + +class WebAnnotationOCRForPage(ListView): + """ + Django View for getting OCR annotations for a given canvas. + """ + def get_queryset(self): + """ + Function to get `.models.Annotation` objects for a given `..canvases.models.Canvas`. + Canvas objects is found by the `canvas.pid` found with the 'page' key in the requests + key word arguments (kwargs). + + :return: OCR annotations for given canvas. + :rtype: Django QuerySet + """ + return Canvas.objects.filter(pid=self.kwargs['canvas']) + + def get(self, request, *args, **kwargs): # pylint: disable = unused-argument + """ + Function to respond to HTTP GET requests for ocr annotations for a given canvas. + + :param request: HTTP GET request + :type request: django request object? + :return: Serialized JSON based on the IIIF presentation API standards. + :rtype: JSON + """ + owner = USER.objects.get(username='ocr', name='OCR') + + query_set = self.get_queryset() + + return JsonResponse( + json.loads( + serialize( + 'annotation_page_v3', + query_set, + annotations=query_set.first().annotation_set.filter(owner=owner) + ) + ), + safe=False + ) diff --git a/apps/readux/__init__.py b/apps/readux/__init__.py index 82190396f..fd24f3852 100644 --- a/apps/readux/__init__.py +++ b/apps/readux/__init__.py @@ -1 +1 @@ -__version__ = '2.3.0' +__version__ = "3.0" diff --git a/apps/readux/annotations.py b/apps/readux/annotations.py index 40282a6e0..a3f13bd00 100644 --- a/apps/readux/annotations.py +++ b/apps/readux/annotations.py @@ -1,58 +1,132 @@ """Django Views for USER Annotations.""" + +from __future__ import annotations import json -import uuid from django.core.exceptions import ObjectDoesNotExist -from django.core.serializers import serialize +from django.core.serializers import serialize, deserialize from django.http import JsonResponse from django.views import View from django.views.generic import ListView from django.contrib.auth import get_user_model +from apps.iiif.manifests.models import Manifest from apps.iiif.canvases.models import Canvas -from apps.iiif.canvases.models import Manifest from .models import UserAnnotation USER = get_user_model() -class Annotations(ListView): + +class Annotations(View): + """ + Display a list of UserAnnotations for a specific user. + :rtype: json + """ + + def get_queryset(self): + return Canvas.objects.filter(pid=self.kwargs["canvas"]) + + def get(self, request, *args, **kwargs): + username = kwargs["username"] + if "version" not in kwargs: + kwargs["version"] = "v2" + try: + owner = USER.objects.get(username=username) + + if self.request.user != owner and username != "ocr": + return JsonResponse( + status=401, + data={ + "Permission to see annotations not allowed for logged in account.": username + }, + ) + + queryset = self.get_queryset() + + if "2" in kwargs["version"]: + anno_serializer = ( + "user_annotation_list" if username != "ocr" else "annotation_list" + ) + if self.request.user == owner or username == "ocr": + return JsonResponse( + json.loads( + serialize(anno_serializer, queryset, owners=[owner]) + ), + safe=False, + ) + + if "3" in kwargs["version"]: + annotations = [] + + if username == "ocr": + annotations = queryset.first().annotation_set.all() + elif owner.username == username: + annotations = queryset.first().userannotation_set.filter( + owner=owner + ) + + return JsonResponse( + json.loads( + serialize( + "annotation_page_v3", queryset, annotations=annotations + ) + ) + ) + + return JsonResponse( + status=404, data={"Invalid IIIF Version": kwargs["version"]} + ) + + except (ObjectDoesNotExist, USER.DoesNotExist): + return JsonResponse(status=404, data={"USER not found.": username}) + + +class WebAnnotations(ListView): """ Display a list of UserAnnotations for a specific user. :rtype: json """ + def get_queryset(self): - return Canvas.objects.filter(pid=self.kwargs['canvas']) + return Canvas.objects.filter(pid=self.kwargs["canvas"]) def get(self, request, *args, **kwargs): - username = kwargs['username'] + username = kwargs["username"] try: + query_set = self.get_queryset() owner = USER.objects.get(username=username) if self.request.user == owner: return JsonResponse( json.loads( serialize( - 'user_annotation_list', - self.get_queryset(), - owners=[owner] + "annotation_page_v3", + query_set, + annotations=query_set.first().userannotation_set.filter( + owner=owner + ), ) ), - safe=False + safe=False, ) return JsonResponse( status=401, - data={"Permission to see annotations not allowed for logged in account.": username} + data={ + "Permission to see annotations not allowed for logged in account.": username + }, ) except ObjectDoesNotExist: return JsonResponse(status=404, data={"USER not found.": username}) + class AnnotationCrud(View): """Endpoint for User Annotation CRUD.""" + def dispatch(self, request, *args, **kwargs): # Don't do anything if no user is authenticated. - if hasattr(request, 'user') is False or request.user.is_authenticated is False: + if hasattr(request, "user") is False or request.user.is_authenticated is False: return self.__unauthorized() # Get the payload from the request body. - self.payload = json.loads(self.request.body.decode('utf-8')) + self.payload = json.loads(self.request.body.decode("utf-8")) return super(AnnotationCrud, self).dispatch(request, *args, **kwargs) def get_queryset(self): @@ -61,9 +135,7 @@ def get_queryset(self): :rtype: :class:`django.db.models.QuerySet` """ try: - return UserAnnotation.objects.get( - pk=self.payload['id'] - ) + return UserAnnotation.objects.get(pk=self.payload["id"]) except UserAnnotation.DoesNotExist: return None @@ -73,22 +145,24 @@ def post(self, request): :return: Newly created annotation as IIIF Annotation. :rtype: json """ - oa_annotation = json.loads(self.payload['oa_annotation']) - annotation = UserAnnotation() - annotation.oa_annotation = oa_annotation - annotation.owner = request.user - annotation.motivation = 'oa:commenting' - annotation.save() - # TODO: should we respond with the saved annotation? + annotation = None + if "oa_annotation" in self.payload: + annotation = UserAnnotation() + oa_annotation = json.loads(self.payload["oa_annotation"]) + annotation.oa_annotation = oa_annotation + annotation.owner = request.user + annotation.motivation = "oa:commenting" + annotation.save() + else: + deserialized_annotation, tags = deserialize("annotation_v3", self.payload) + annotation = UserAnnotation(**deserialized_annotation) + annotation.pre_save() + UserAnnotation.objects.bulk_create([annotation]) + annotation.refresh_from_db() + for tag in tags: + annotation.tags.add(tag) return JsonResponse( - json.loads( - serialize( - 'annotation', - [annotation] - ) - ), - safe=False, - status=201 + json.loads(serialize("annotation_v3", [annotation])), safe=False, status=201 ) def put(self, request): @@ -106,21 +180,22 @@ def put(self, request): if annotation is None: return self.__not_found() - elif hasattr(request, 'user') and annotation.owner == request.user: - annotation.oa_annotation = self.payload['oa_annotation'] + if hasattr(request, "user") and annotation.owner == request.user: + if "oa_annotation" in self.payload: + annotation.oa_annotation = self.payload["oa_annotation"] + else: + updated_annotation, tags = deserialize("annotation_v3", self.payload) + annotation.update(updated_annotation, tags) + annotation.save() + annotation.refresh_from_db() + return JsonResponse( - json.loads( - serialize( - 'annotation', - [annotation] - ) - ), + json.loads(serialize("annotation_v3", [annotation])), safe=False, - status=200 + status=200, ) - else: - return self.__unauthorized() + return self.__unauthorized() def delete(self, request): @@ -135,7 +210,40 @@ def delete(self, request): return self.__unauthorized() def __not_found(self): - return JsonResponse({'message': 'Annotation not found.'}, status=404) + return JsonResponse({"message": "Annotation not found."}, status=404) def __unauthorized(self): - return JsonResponse({'message': 'You are not the owner of this annotation.'}, status=401) + return JsonResponse( + {"message": "You are not the owner of this annotation."}, status=401 + ) + + +class AnnotationCountByCanvas(View): + """ + Return of list of UserAnnotation counts for each Canvas in a given Manifest. + :rtype: json + """ + + def get(self, request, *args, **kwargs): + username = kwargs["username"] + try: + manifest = Manifest.objects.get(pid=self.kwargs["manifest"]) + owner = USER.objects.get(username=username) + counts = [ + { + "canvas": c.pid, + "count": c.userannotation_set.filter(owner=owner).count(), + } + for c in manifest.canvas_set.all() + ] + if self.request.user == owner: + return JsonResponse(status=200, data=counts, safe=False) + return JsonResponse( + status=401, + data={ + "Permission to see annotations not allowed for logged in account.": username + }, + ) + + except ObjectDoesNotExist: + return JsonResponse(status=404, data={"USER not found.": username}) diff --git a/apps/readux/context_processors.py b/apps/readux/context_processors.py index c00b7fb1f..2b2a3ef5f 100644 --- a/apps/readux/context_processors.py +++ b/apps/readux/context_processors.py @@ -1,15 +1,83 @@ +"""Global template contexts.""" + from os import environ from git import Repo from django.conf import settings from . import __version__ -def current_version(request=None): + +def current_version(_=None): + """Display current version. On non-production instances, display Git information. + + Example of return: + + { + "DJANGO_ENV": "dev", + "APP_VERSION": "Readux 3.0", + "BRANCH": "develop", + "COMMIT": "deeb939f41c001658d9f3ce82d6fa6add6158d5c", + "COMMIT_DATE": "01/13/2025, 13:13:18" + } + + Args: + _ (WSGIRequest): Current request. Not used. + + Returns: + dict: Dict of version and Git info. + """ repo = Repo(settings.ROOT_DIR.path()) + version_info = {"DJANGO_ENV": environ["DJANGO_ENV"], "APP_VERSION": __version__} + + if environ["DJANGO_ENV"] == "production": + return version_info return { - 'DJANGO_ENV': environ['DJANGO_ENV'], - 'APP_VERSION': __version__, - 'BRANCH': repo.active_branch.name, - 'COMMIT': repo.active_branch.commit.hexsha, - 'COMMIT_DATE': repo.active_branch.commit.committed_datetime.strftime("%m/%d/%Y, %H:%M:%S") + **version_info, + "BRANCH": repo.active_branch.name, + "COMMIT": repo.active_branch.commit.hexsha, + "COMMIT_DATE": repo.active_branch.commit.committed_datetime.strftime( + "%m/%d/%Y, %H:%M:%S" + ), } + + +def footer_template(_): + """Use configured footer template for the footer content or default. + + Args: + _ (WSGIRequest): Current request. Not used. + + Returns: + dict: Dict with key 'FOOTER_CONTENT_TEMPLATE'. Value is str. + """ + return { + "FOOTER_CONTENT_TEMPLATE": getattr( + settings, "FOOTER_CONTENT_TEMPLATE", "_home/_footer_content.html" + ) + } + + +def site_meta(_): + """Use configured values for site meta tags. + + Args: + _ (WSGIRequest): Current request. Not used. + + Returns: + dict: Dict with keys 'META_KEYWORDS' and 'META_DESCRIPTION' for use in meta tags. + """ + return { + "META_KEYWORDS": getattr(settings, "META_KEYWORDS", ""), + "META_DESCRIPTION": getattr(settings, "META_DESCRIPTION", ""), + } + + +def matomo_id(_): + """Expose Matomo ID for analytics. + Args: + _ (WSGIRequest): Current request. Not used. + + Returns: + dict: Dict with key 'MATOMO_ID' for use in analytics script tag. + """ + return {"MATOMO_ID": getattr(settings, "MATOMO_ID", "")} diff --git a/apps/readux/documents.py b/apps/readux/documents.py new file mode 100644 index 000000000..8ae06a51d --- /dev/null +++ b/apps/readux/documents.py @@ -0,0 +1,52 @@ +"""Elasticsearch indexing rules for UserAnnotations""" + +from os import environ +from html import unescape +from django_elasticsearch_dsl import Document, fields +from django_elasticsearch_dsl.registries import registry +from django.utils.html import strip_tags +from django.conf import settings + +from apps.readux.models import UserAnnotation +from apps.iiif.manifests.documents import stemmer + + +@registry.register_document +class UserAnnotationDocument(Document): + """Elasticsearch Document class for Readux UserAnnotation""" + + # fields to map explicitly in Elasticsearch + canvas_index = fields.IntegerField() + canvas_pid = fields.KeywordField() + content = fields.TextField(analyzer=stemmer) + manifest_pid = fields.KeywordField() + owner_username = fields.KeywordField() + pid = fields.KeywordField() + + class Index: + """Settings for Elasticsearch""" + + name = f"{settings.INDEX_PREFIX}_annotations" + + class Django: + """Settings for automatically pulling data from Django""" + + model = UserAnnotation + + def prepare_content(self, instance): + """Strip HTML tags from content""" + return unescape(strip_tags(instance.content)) + + def prepare_canvas_index(self, instance): + return instance.canvas.position + + def prepare_canvas_pid(self, instance): + return instance.canvas.pid + + def prepare_manifest_pid(self, instance): + return instance.canvas.manifest.pid + + def prepare_owner_username(self, instance): + if instance.owner: + return instance.owner.username + return None diff --git a/apps/readux/fixtures/userannotation.json b/apps/readux/fixtures/userannotation.json index 4757b3323..cd0da206a 100644 --- a/apps/readux/fixtures/userannotation.json +++ b/apps/readux/fixtures/userannotation.json @@ -1,65 +1,63 @@ [ -{ - "model": "readux.userannotation", - "pk": "18f24705-398a-401d-a106-acfca6e72070", - "fields": { - "x": 1694, - "y": 1577, - "w": 785, - "h": 240, - "order": 0, - "content": "

Annotation number 4

", - "resource_type": "dctypes:Text", - "motivation": "sc:painting", - "format": "text/plain", - "canvas": "7261fae2-a24e-4a1c-9743-516f6c4ea0c9", - "language": "en", - "owner": 111, - "oa_annotation": { - "on": [ - { - "full": "https://0.0.0.0:3000/iiif/readux:t9vft/canvas/fedora:emory:5622$0", - "@type": "oa:SpecificResource", - "within": { - "@id": "https://0.0.0.0:3000/iiif/v2/readux:t9vft/manifest", - "@type": "sc:Manifest" - }, - "selector": { - "item": { - "@type": "oa:SvgSelector", - "value": "" + { + "model": "readux.userannotation", + "pk": "18f24705-398a-401d-a106-acfca6e72070", + "fields": { + "x": 1694, + "y": 1577, + "w": 785, + "h": 240, + "order": 0, + "content": "

Annotation number 4

", + "resource_type": "dctypes:Text", + "motivation": "sc:painting", + "format": "text/plain", + "canvas": "7261fae2-a24e-4a1c-9743-516f6c4ea0c9", + "language": "en", + "owner": 111, + "oa_annotation": { + "on": [ + { + "full": "https://0.0.0.0:3000/iiif/readux:t9vft/canvas/fedora:emory:5622$0", + "@type": "oa:SpecificResource", + "within": { + "@id": "https://0.0.0.0:3000/iiif/v2/readux:t9vft/manifest", + "@type": "sc:Manifest" }, - "@type": "oa:Choice", - "default": { - "@type": "oa:FragmentSelector", - "value": "xywh=1694,1577,785,240" + "selector": { + "item": { + "@type": "oa:SvgSelector", + "value": "" + }, + "@type": "oa:SvgSelector", + "default": { + "@type": "oa:FragmentSelector", + "value": "xywh=1694,1577,785,240" + } } } - } - ], - "@id": "0ace875c-033e-400a-a4a4-3cb857369239", - "@type": "oa:Annotation", - "@context": "http://iiif.io/api/presentation/2/context.json", - "resource": [ - { - "@type": "dctypes:Text", - "chars": "

Annotation number 4

", - "format": "text/html" - } - ], - "stylesheet": { - "value": ".anno-0ace875c-033e-400a-a4a4-3cb857369239 { background: rgba(0, 128, 0, 0.5); }", - "type": "CssStylesheet" + ], + "@id": "0ace875c-033e-400a-a4a4-3cb857369239", + "@type": "oa:Annotation", + "@context": "http://iiif.io/api/presentation/2/context.json", + "resource": [ + { + "@type": "dctypes:Text", + "chars": "

Annotation number 4

", + "format": "text/html" + } + ], + "stylesheet": { + "value": ".anno-0ace875c-033e-400a-a4a4-3cb857369239 { background: rgba(0, 128, 0, 0.5); }", + "type": "CssStylesheet" + }, + "motivation": ["oa:commenting"] }, - "motivation": [ - "oa:commenting" - ] - }, - "svg": "", - "start_selector": null, - "end_selector": null, - "start_offset": null, - "end_offset": null + "svg": "", + "start_selector": null, + "end_selector": null, + "start_offset": null, + "end_offset": null + } } -} -] \ No newline at end of file +] diff --git a/apps/readux/forms.py b/apps/readux/forms.py index b093a186c..9bd567870 100644 --- a/apps/readux/forms.py +++ b/apps/readux/forms.py @@ -2,11 +2,14 @@ from dateutil import parser from django import forms +from django.forms import widgets +from django.conf import settings from django.template.defaultfilters import truncatechars class MinMaxDateInput(forms.DateInput): """Widget extending DateInput to include an initial date in its attrs""" + date_initial = "" def get_context(self, name, value, attrs): @@ -26,6 +29,7 @@ def set_initial(self, date): class FacetedMultipleChoiceField(forms.MultipleChoiceField): """MultipleChoiceField populated by Elasticsearch facets""" + # adapted from Princeton-CDH/geniza project https://github.com/Princeton-CDH/geniza/ def populate_from_buckets(self, buckets): @@ -33,9 +37,11 @@ def populate_from_buckets(self, buckets): self.choices = ( ( bucket["key"], - f'{truncatechars(bucket["key"], 42)} ({bucket["doc_count"]})', + f'{truncatechars(bucket["key"], 42) if len(bucket["key"]) > 0 else "None"} ({bucket["doc_count"]})', ) - for bucket in sorted(buckets, key=lambda b: -b["doc_count"]) # sort choices by count + for bucket in sorted( + buckets, key=lambda b: -b["doc_count"] + ) # sort choices by count ) def valid_value(self, value): @@ -45,8 +51,15 @@ def valid_value(self, value): class ManifestSearchForm(forms.Form): """Django form for searching Manifests via Elasticsearch""" + + PER_PAGE_CHOICES = [ + ("20", "20"), + ("40", "40"), + ("60", "60"), + ("100", "100"), + ] q = forms.CharField( - label="Search volumes by keyword", + label="Search for individual whole keywords. Multiple words will be searched as 'or' (e.g. Rome London = Rome or London).", required=False, widget=forms.TextInput( attrs={ @@ -57,13 +70,24 @@ class ManifestSearchForm(forms.Form): }, ), ) + scope = forms.ChoiceField( + label="Limit search to", + required=False, + initial="all", + choices=( + ("all", "All"), + ("metadata", "Metadata only"), + ("text", "Textual contents only"), + ), + widget=forms.Select(attrs={"class": "uk-select"}), + ) language = FacetedMultipleChoiceField( label="Language", required=False, widget=forms.SelectMultiple( attrs={ "aria-label": "Filter volumes by language", - "class": "uk-input", + # "class": "uk-input", }, ), ) @@ -73,7 +97,7 @@ class ManifestSearchForm(forms.Form): widget=forms.SelectMultiple( attrs={ "aria-label": "Filter volumes by author", - "class": "uk-input", + # "class": "uk-input", }, ), ) @@ -83,7 +107,7 @@ class ManifestSearchForm(forms.Form): widget=forms.SelectMultiple( attrs={ "aria-label": "Filter volumes by collection", - "class": "uk-input", + # "class": "uk-input", }, ), ) @@ -95,13 +119,36 @@ class ManifestSearchForm(forms.Form): ("created_at", "Date added (oldest first)"), ("-date_sort_descending", "Date published (newest first)"), ("date_sort_ascending", "Date published (oldest first)"), - ("label_alphabetical", "Label (A-Z)"), - ("-label_alphabetical", "Label (Z-A)"), + ("label_alphabetical", "Title (A-Z)"), + ("-label_alphabetical", "Title (Z-A)"), ("_score", "Relevance"), ), + widget=forms.Select(attrs={"class": "uk-select"}), + ) + display = forms.ChoiceField( + label="View mode", + required=False, + choices=( + ("list", "List view"), + ("grid", "Grid view"), + ), + initial="list", + widget=forms.Select( + attrs={ + "class": "uk-select", + "aria-label": "Select view mode", + }, + ), + ) + per_page = forms.ChoiceField( + label="Items per page", + required=False, + choices=PER_PAGE_CHOICES, + initial="60", widget=forms.Select( attrs={ "class": "uk-select", + "aria-label": "Items per page", }, ), ) @@ -113,7 +160,7 @@ class ManifestSearchForm(forms.Form): "type": "date", }, format="%Y-%m-%d", - ) + ), ) end_date = MinMaxDateField( label="End date", @@ -121,15 +168,48 @@ class ManifestSearchForm(forms.Form): widget=MinMaxDateInput( attrs={"type": "date"}, format="%Y-%m-%d", - ) + ), ) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # pull additional facets from Elasticsearch + if ( + settings + and hasattr(settings, "CUSTOM_METADATA") + and isinstance(settings.CUSTOM_METADATA, dict) + ): + # should be a dict like {meta_key: {"multi": bool, "separator": str, "faceted": bool}} + for key in [ + k + for k in settings.CUSTOM_METADATA.keys() + if settings.CUSTOM_METADATA[k].get("faceted", False) + ]: + self.fields[ + # use django-friendly form field names + key.casefold().replace(" ", "_") + ] = FacetedMultipleChoiceField( + label=key, + required=False, + widget=forms.SelectMultiple( + attrs={ + "aria-label": f"Filter volumes by {key}", + "class": "custom-search-selectize", + }, + ), + ) + def set_facets(self, facets): """Use facets from Elasticsearch to populate form fields""" for name, buckets in facets.items(): if name in self.fields: # Assumes that name passed in the view's facets list matches form field name self.fields[name].populate_from_buckets(buckets) + elif name.casefold().replace(" ", "_") in self.fields: + self.fields[name.casefold().replace(" ", "_")].populate_from_buckets( + buckets + ) def set_date(self, min_date, max_date): """Use min and max aggregations from Elasticsearch to populate date range fields""" @@ -137,3 +217,97 @@ def set_date(self, min_date, max_date): self.fields["start_date"].set_initial(min_date_object.strftime("%Y-%m-%d")) max_date_object = parser.isoparse(max_date) self.fields["end_date"].set_initial(max_date_object.strftime("%Y-%m-%d")) + + +class CustomDropdownSelect(widgets.ChoiceWidget): + """A custom select widget that uses uk-navbar-dropdown to present its options, + and submits the form on any change""" + + input_type = "radio" + template_name = "widgets/custom_dropdown_select.html" + option_template_name = "django/forms/widgets/radio_option.html" + + def get_context(self, name, value, attrs): + """Add the label for the selected option to template context""" + context = super().get_context(name, value, attrs) + context["selected_value_label"] = dict(self.choices).get(value, None) + return context + + +class AllVolumesForm(forms.Form): + """Simple form for sorting Manifests""" + + SORT_CHOICES = [ + ("title", "Title"), + ("author", "Author"), + ("date", "Publication Year"), + ("added", "Date Added"), + ] + ORDER_CHOICES = [ + ("asc", "Ascending"), + ("desc", "Descending"), + ] + PER_PAGE_CHOICES = [ + ("20", "20"), + ("40", "40"), + ("60", "60"), + ("100", "100"), + ] + sort = forms.ChoiceField( + label="Sort by", + choices=SORT_CHOICES, + required=False, + widget=CustomDropdownSelect, + ) + order = forms.ChoiceField( + label="Order", + choices=ORDER_CHOICES, + required=False, + widget=CustomDropdownSelect, + ) + per_page = forms.ChoiceField( + label="Items per page", + choices=PER_PAGE_CHOICES, + required=False, + initial="60", + widget=CustomDropdownSelect, + ) + + +class AllCollectionsForm(forms.Form): + """Simple form for sorting Collections""" + + SORT_CHOICES = [ + ("title", "Title"), + ("added", "Date Added"), + ("volumes", "Number of Volumes"), + ] + ORDER_CHOICES = [ + ("asc", "Ascending"), + ("desc", "Descending"), + ] + PER_PAGE_CHOICES = [ + ("20", "20"), + ("40", "40"), + ("60", "60"), + ("100", "100"), + ] + sort = forms.ChoiceField( + label="Sort by", + choices=SORT_CHOICES, + required=False, + widget=CustomDropdownSelect, + ) + order = forms.ChoiceField( + label="Order", + choices=ORDER_CHOICES, + required=False, + widget=CustomDropdownSelect, + ) + per_page = forms.ChoiceField( + label="Items per page", + choices=PER_PAGE_CHOICES, + required=False, + initial="60", + widget=CustomDropdownSelect, + ) diff --git a/apps/readux/migrations/0009_auto_20220607_1407.py b/apps/readux/migrations/0009_auto_20220607_1407.py new file mode 100644 index 000000000..e93432cfd --- /dev/null +++ b/apps/readux/migrations/0009_auto_20220607_1407.py @@ -0,0 +1,51 @@ +# Generated by Django 3.2.13 on 2022-06-07 14:07 + +from django.db import migrations, models +from apps.iiif.annotations.choices import AnnotationSelector + +def assign_selector(apps, _): + Annotation = apps.get_model("readux", "UserAnnotation") # pylint: disable=invalid-name + for anno in Annotation.objects.all(): + if anno.svg: + anno.primary_selector = AnnotationSelector('SV') + + +class Migration(migrations.Migration): + + dependencies = [ + ('readux', '0008_auto_20210309_1840'), + ] + + operations = [ + migrations.AddField( + model_name='userannotation', + name='primary_selector', + field=models.CharField(choices=[('FR', 'FragmentSelector'), ('CS', 'CssSelector'), ('XP', 'XPathSelector'), ('TQ', 'TextQuoteSelector'), ('TP', 'TextPositionSelector'), ('DP', 'DataPositionSelector'), ('SV', 'SvgSelector'), ('RG', 'RangeSelector')], default='FR', max_length=2), + ), + migrations.AddField( + model_name='userannotation', + name='purpose', + field=models.CharField(choices=[('AS', 'assessing'), ('BM', 'bookmarking'), ('CL', 'classifying'), ('CM', 'commenting'), ('DS', 'describing'), ('ED', 'editing'), ('HL', 'highlighting'), ('ID', 'identifying'), ('LK', 'linking'), ('MO', 'moderating'), ('QT', 'questioning'), ('RE', 'replying'), ('TG', 'tagging')], default='CM', max_length=2), + ), + migrations.AlterField( + model_name='userannotation', + name='h', + field=models.IntegerField(default=0), + ), + migrations.AlterField( + model_name='userannotation', + name='w', + field=models.IntegerField(default=0), + ), + migrations.AlterField( + model_name='userannotation', + name='x', + field=models.IntegerField(default=0), + ), + migrations.AlterField( + model_name='userannotation', + name='y', + field=models.IntegerField(default=0), + ), + migrations.RunPython(assign_selector, migrations.RunPython.noop), + ] diff --git a/apps/readux/migrations/0010_auto_20220607_1453.py b/apps/readux/migrations/0010_auto_20220607_1453.py new file mode 100644 index 000000000..77e0223e8 --- /dev/null +++ b/apps/readux/migrations/0010_auto_20220607_1453.py @@ -0,0 +1,52 @@ +# Generated by Django 3.2.13 on 2022-06-07 14:53 + +from datetime import datetime +import apps.utils.noid +from django.db import migrations, models + +def add_dates(apps, _): + Annotation = apps.get_model("readux", "UserAnnotation") # pylint: disable=invalid-name + for anno in Annotation.objects.all(): + anno.created_at = datetime.now() + anno.updated_at = datetime.now() + + +class Migration(migrations.Migration): + + dependencies = [ + ('readux', '0009_auto_20220607_1407'), + ] + + operations = [ + migrations.AddField( + model_name='userannotation', + name='created_at', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='userannotation', + name='label', + field=models.CharField(default='', max_length=1000), + ), + migrations.AddField( + model_name='userannotation', + name='modified_at', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='userannotation', + name='pid', + field=models.CharField(default=apps.utils.noid.encode_noid, help_text="Unique ID. Do not use _'s or spaces in the pid.", max_length=255), + ), + migrations.AlterField( + model_name='userannotation', + name='primary_selector', + field=models.CharField(choices=[('FR', 'Fragmentselector'), ('CS', 'Cssselector'), ('XP', 'Xpathselector'), ('TQ', 'Textquoteselector'), ('TP', 'Textpositionselector'), ('DP', 'Datapositionselector'), ('SV', 'Svgselector'), ('RG', 'Rangeselector')], default='FR', max_length=2), + ), + migrations.AlterField( + model_name='userannotation', + name='purpose', + field=models.CharField(choices=[('AS', 'Assessing'), ('BM', 'Bookmarking'), ('CL', 'Classifying'), ('CM', 'Commenting'), ('DS', 'Describing'), ('ED', 'Editing'), ('HL', 'Highlighting'), ('ID', 'Identifying'), ('LK', 'Linking'), ('MO', 'Moderating'), ('QT', 'Questioning'), ('RE', 'Replying'), ('TG', 'Tagging')], default='CM', max_length=2), + ), + migrations.RunPython(add_dates, migrations.RunPython.noop), + ] diff --git a/apps/readux/migrations/0011_auto_20230925_2040.py b/apps/readux/migrations/0011_auto_20230925_2040.py new file mode 100644 index 000000000..9b665331b --- /dev/null +++ b/apps/readux/migrations/0011_auto_20230925_2040.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.15 on 2023-09-25 20:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('readux', '0010_auto_20220607_1453'), + ] + + operations = [ + migrations.AlterField( + model_name='userannotation', + name='oa_annotation', + field=models.JSONField(default=dict), + ), + migrations.AlterField( + model_name='userannotation', + name='purpose', + field=models.CharField(choices=[('AS', 'Assessing'), ('BM', 'Bookmarking'), ('CL', 'Classifying'), ('CM', 'Commenting'), ('DS', 'Describing'), ('ED', 'Editing'), ('HL', 'Highlighting'), ('ID', 'Identifying'), ('LK', 'Linking'), ('MO', 'Moderating'), ('PT', 'Painting'), ('QT', 'Questioning'), ('RE', 'Replying'), ('SP', 'Supplementing'), ('TG', 'Tagging')], default='SP', max_length=2), + ), + ] diff --git a/apps/readux/migrations/0012_userannotation_raw_content.py b/apps/readux/migrations/0012_userannotation_raw_content.py new file mode 100644 index 000000000..bd411d605 --- /dev/null +++ b/apps/readux/migrations/0012_userannotation_raw_content.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.25 on 2025-03-04 15:23 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('readux', '0011_auto_20230925_2040'), + ] + + operations = [ + migrations.AddField( + model_name='userannotation', + name='raw_content', + field=models.TextField(blank=True, default=' ', null=True), + ), + ] diff --git a/apps/readux/models.py b/apps/readux/models.py index 599c0c1b1..9a2a48ecb 100644 --- a/apps/readux/models.py +++ b/apps/readux/models.py @@ -1,27 +1,31 @@ """Django Models for Readux""" + import json import re from taggit.managers import TaggableManager from taggit.models import TaggedItemBase from django.db import models -from django.db.models import signals -from django.dispatch import receiver from apps.iiif.annotations.models import AbstractAnnotation, Annotation from apps.iiif.canvases.models import Canvas +from apps.iiif.manifests.documents import ManifestDocument + class TaggedUserAnnotations(TaggedItemBase): """Model for tagging :class:`UserAnnotation`s using Django Taggit.""" - content_object = models.ForeignKey('UserAnnotation', on_delete=models.CASCADE) + + content_object = models.ForeignKey("UserAnnotation", on_delete=models.CASCADE) + class UserAnnotation(AbstractAnnotation): """Model for User Annotations.""" - COMMENTING = 'oa:commenting' - PAINTING = 'sc:painting' - TAGGING = '%s,oa:tagging' % COMMENTING + + COMMENTING = "oa:commenting" + PAINTING = "sc:painting" + TAGGING = f"{COMMENTING},oa:tagging" MOTIVATION_CHOICES = ( - (COMMENTING, 'commenting'), - (PAINTING, 'painting'), - (TAGGING, 'tagging and commenting') + (COMMENTING, "commenting"), + (PAINTING, "painting"), + (TAGGING, "tagging and commenting"), ) start_selector = models.ForeignKey( @@ -29,16 +33,16 @@ class UserAnnotation(AbstractAnnotation): on_delete=models.CASCADE, null=True, blank=True, - related_name='start_selector', - default=None + related_name="start_selector", + default=None, ) end_selector = models.ForeignKey( Annotation, on_delete=models.CASCADE, null=True, blank=True, - related_name='end_selector', - default=None + related_name="end_selector", + default=None, ) start_offset = models.IntegerField(null=True, blank=True, default=None) end_offset = models.IntegerField(null=True, blank=True, default=None) @@ -46,75 +50,161 @@ class UserAnnotation(AbstractAnnotation): @property def item(self): + """_summary_ + + Returns: + _type_: _description_ + """ if self.__is_text_annotation(): return self.__text_anno_item() - elif self.__is_svg_annotation(): + if self.__is_svg_annotation(): return self.__svg_anno_item() - else: - return None + return None @property def tag_list(self): + """_summary_ + + Returns: + _type_: _description_ + """ if self.tags.exists(): return [tag.name for tag in self.tags.all()] - else: - return [] + return [] + + def pre_save(self): + """Prepare annotation to be saved.""" + if self.primary_selector == "RG": + self.__xywh_text_anno() + if isinstance(self.oa_annotation, str): + self.oa_annotation = json.loads(self.oa_annotation) + if "on" in self.oa_annotation.keys(): + self.parse_mirador_annotation() + + def post_save(self): + """ + Finds tags in the oa_annotation and applies them to + the annotation. + """ + if self.motivation == self.TAGGING: + incoming_tags = [] + # Get the tags from the incoming annotation. + tags = [ + res + for res in self.oa_annotation["resource"] + if res["@type"] == "oa:Tag" + ] + for tag in tags: + # Add the tag to the annotation + self.tags.add(tag["chars"]) + # Make a list of incoming tags to compare with list of saved tags. + incoming_tags.append(tag["chars"]) + + # Check if any tags have been removed + if len(self.tag_list) > 0: + for existing_tag in self.tag_list: + if existing_tag not in incoming_tags: + self.tags.remove(existing_tag) + + def save(self, *args, **kwargs): + self.pre_save() + if self.canvas: + index = ManifestDocument() + index.update(self.canvas.manifest, True, "index") + super().save(*args, **kwargs) + self.post_save() + + def delete(self, *args, **kwargs): + if self.canvas: + index = ManifestDocument() + index.update(self.canvas.manifest, True, "delete") + super().delete(*args, **kwargs) + + def update(self, attrs=None, tags=None): + """Method to update an annotation object with a dict of attributes and a list of tags + + :param attrs: _description_, defaults to None + :type attrs: dict, optional + :param tags: _description_, defaults to None + :type tags: list, optional + """ + if isinstance(attrs, dict): + for attr, value in attrs.items(): + setattr(self, attr, value) + + if isinstance(tags, list): + self.tags.clear() + for tag in tags: + self.tags.add(tag) + + self.save() def parse_mirador_annotation(self): - self.motivation = AbstractAnnotation.COMMENTING + """DEPRECATED + Parses annotation from mirador + """ + self.motivation = AbstractAnnotation.OA_COMMENTING - if type(self.oa_annotation) == str: - self.oa_annotation = json.loads(self.oa_annotation) + anno_on = None - if isinstance(self.oa_annotation['on'], list): - anno_on = self.oa_annotation['on'][0] - elif isinstance(self.oa_annotation['on'], dict): - anno_on = self.oa_annotation['on'] + if isinstance(self.oa_annotation["on"], list): + anno_on = self.oa_annotation["on"][0] + elif isinstance(self.oa_annotation["on"], dict): + anno_on = self.oa_annotation["on"] - if self.canvas == None: - self.canvas = Canvas.objects.get(pid=anno_on['full'].split('/')[-1]) + if self.canvas is None and anno_on is not None: + self.canvas = Canvas.objects.get(pid=anno_on["full"].split("/")[-1]) - mirador_item = anno_on['selector']['item'] + mirador_item = anno_on["selector"]["item"] - if mirador_item['@type'] == 'oa:SvgSelector': - self.svg = mirador_item['value'] + if mirador_item["@type"] == "oa:SvgSelector": + self.svg = mirador_item["value"] self.__set_xywh_svg_anno() - elif mirador_item['@type'] == 'RangeSelector': + elif mirador_item["@type"] == "RangeSelector": self.start_selector = Annotation.objects.get( - pk=mirador_item['startSelector']['value'].split("'")[1] + pk=mirador_item["startSelector"]["value"].split("'")[1] ) self.end_selector = Annotation.objects.get( - pk=mirador_item['endSelector']['value'].split("'")[1] + pk=mirador_item["endSelector"]["value"].split("'")[1] ) - self.start_offset = mirador_item['startSelector']['refinedBy']['start'] - self.end_offset = mirador_item['endSelector']['refinedBy']['end'] - self.__set_xywh_text_anno() - - if isinstance(self.oa_annotation['resource'], dict): - self.content = self.oa_annotation['resource']['chars'] - self.resource_type = self.oa_annotation['resource']['@type'] - elif isinstance(self.oa_annotation['resource'], list) and len(self.oa_annotation['resource']) == 1: - self.content = self.oa_annotation['resource'][0]['chars'] - self.resource_type = self.oa_annotation['resource'][0]['@type'] + self.start_offset = mirador_item["startSelector"]["refinedBy"]["start"] + self.end_offset = mirador_item["endSelector"]["refinedBy"]["end"] + self.__xywh_text_anno() + + if isinstance(self.oa_annotation["resource"], dict): + self.content = self.oa_annotation["resource"]["chars"] + self.resource_type = self.oa_annotation["resource"]["@type"] + elif ( + isinstance(self.oa_annotation["resource"], list) + and len(self.oa_annotation["resource"]) == 1 + ): + self.content = self.oa_annotation["resource"][0]["chars"] + self.resource_type = self.oa_annotation["resource"][0]["@type"] # Assume all tags have been removed. if self.tags.exists(): self.tags.clear() elif ( - isinstance(self.oa_annotation['resource'], list) and - len(self.oa_annotation['resource']) > 1 + isinstance(self.oa_annotation["resource"], list) + and len(self.oa_annotation["resource"]) > 1 ): # Assume tagging self.motivation = self.TAGGING - text = [res for res in self.oa_annotation['resource'] if res['@type'] == 'dctypes:Text'] - self.content = text[0]['chars'] + text = [ + res + for res in self.oa_annotation["resource"] + if res["@type"] == "dctypes:Text" + ] + self.content = text[0]["chars"] # Replace the ID given by Mirador with the Readux given ID - if 'stylesheet' in self.oa_annotation: + if "stylesheet" in self.oa_annotation: uuid_pattern = re.compile( - r'[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89aAbB][a-f0-9]{3}-[a-f0-9]{12}' + r"[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89aAbB][a-f0-9]{3}-[a-f0-9]{12}" + ) + self.style = uuid_pattern.sub( + str(self.id), self.oa_annotation["stylesheet"]["value"] ) - self.style = uuid_pattern.sub(str(self.id), self.oa_annotation['stylesheet']['value']) return True @@ -124,12 +214,14 @@ def __is_text_annotation(self): :return: True if annotation is for text. :rtype: bool """ - return all([ - isinstance(self.end_offset, int), - isinstance(self.start_offset, int), - isinstance(self.start_selector, Annotation), - isinstance(self.end_selector, Annotation) - ]) + return all( + [ + isinstance(self.end_offset, int), + isinstance(self.start_offset, int), + isinstance(self.start_selector, Annotation), + isinstance(self.end_selector, Annotation), + ] + ) def __is_svg_annotation(self): """Check if annotation is for image region. @@ -139,89 +231,73 @@ def __is_svg_annotation(self): """ return self.svg is not None - # pylint: disable = invalid-name - def __set_xywh_text_anno(self): + def __xywh_text_anno(self): start_position = self.start_selector.order end_position = self.end_selector.order - text = Annotation.objects.filter( - canvas=self.canvas, - order__lt=end_position, - order__gte=start_position - ).order_by('order') - self.x = min(text.values_list('x', flat=True)) - self.y = max(text.values_list('y', flat=True)) - self.h = max(text.values_list('h', flat=True)) - self.w = text.last().x + text.last().w - self.x - # pylint: enable = invalid-name + text = None + if self.start_selector == self.end_selector: + text = Annotation.objects.filter(id=str(self.start_selector.id)) + else: + text = Annotation.objects.filter( + canvas=self.canvas, order__lte=end_position, order__gte=start_position + ).order_by("order") + self.x = min(text.values_list("x", flat=True)) + self.y = min(text.values_list("y", flat=True)) + far_y_anno = text.order_by("y").last() + self.h = far_y_anno.y + far_y_anno.h - self.y + far_x_anno = text.order_by("x").last() + self.w = far_x_anno.x + far_x_anno.w - self.x + # self.w = max([anno.x + anno.w for anno in text]) - self.x + # self.h = max([anno.y + anno.h for anno in text]) - self.y def __text_anno_item(self): - return dict({ - "@type": "RangeSelector", - "startSelector": { - "@type": "XPathSelector", - "value": "//*[@id='%s']" % str(self.start_selector.pk), - "refinedBy" : { - "@type": "TextPositionSelector", - "start": self.start_offset - } - }, - "endSelector": { - "@type": "XPathSelector", - "value": "//*[@id='%s']" % str(self.end_selector.pk), - "refinedBy" : { - "@type": "TextPositionSelector", - "end": self.end_offset - } + return dict( + { + "@type": "RangeSelector", + "startSelector": { + "@type": "XPathSelector", + "value": f"//*[@id='{str(self.start_selector.pk)}']", + "refinedBy": { + "@type": "TextPositionSelector", + "start": self.start_offset, + }, + }, + "endSelector": { + "@type": "XPathSelector", + "value": f"//*[@id='{str(self.end_selector.pk)}']", + "refinedBy": { + "@type": "TextPositionSelector", + "end": self.end_offset, + }, + }, } - }) + ) def __svg_anno_item(self): - return dict({ - "@type": "oa:SvgSelector", - "value": self.svg, - "@type": "oa:Choice", - "default": { - "@type": "oa:FragmentSelector", - "value": "xywh=%s,%s,%s,%s" % (str(self.x), str(self.y), str(self.w), str(self.h)) + # pylint: disable=duplicate-key + return dict( + { + "@type": "oa:SvgSelector", + "value": self.svg, + "@type": "oa:SvgSelector", + "default": { + "@type": "oa:FragmentSelector", + "value": f"xywh={str(self.x)},{str(self.y)},{str(self.w)},{str(self.h)}", + }, } - }) + ) + # pylint: enable=duplicate-key def __set_xywh_svg_anno(self): dimensions = None - if 'default' in self.oa_annotation['on'][0]['selector'].keys(): - dimensions = self.oa_annotation['on'][0]['selector']['default']['value'].split('=')[-1].split(',') # pylint: disable = line-too-long + if "default" in self.oa_annotation["on"][0]["selector"].keys(): + dimensions = ( + self.oa_annotation["on"][0]["selector"]["default"]["value"] + .split("=")[-1] + .split(",") + ) if dimensions is not None: self.x = dimensions[0] self.y = dimensions[1] self.w = dimensions[2] self.h = dimensions[3] - -# TODO: Override the save method and move this there. -@receiver(signals.pre_save, sender=UserAnnotation) -def parse_payload(sender, instance, **kwargs): - # if service.validate_oa_annotation(instance.oa_annotation): - if isinstance(instance.oa_annotation, dict) and 'on' not in instance.oa_annotation.keys(): - return None - instance.parse_mirador_annotation() - -@receiver(signals.post_save, sender=UserAnnotation) -def set_tags(sender, instance, **kwargs): - """ - Finds tags in the oa_annotation and applies them to - the annotation. - """ - if instance.motivation == sender.TAGGING: - incoming_tags = [] - # Get the tags from the incoming annotation. - tags = [res for res in instance.oa_annotation['resource'] if res['@type'] == 'oa:Tag'] - for tag in tags: - # Add the tag to the annotation - instance.tags.add(tag['chars']) - # Make a list of incoming tags to compare with list of saved tags. - incoming_tags.append(tag['chars']) - - # Check if any tags have been removed - if len(instance.tag_list) > 0: - for existing_tag in instance.tag_list: - if existing_tag not in incoming_tags: - instance.tags.remove(existing_tag) diff --git a/apps/readux/search.py b/apps/readux/search.py index b847e8dc8..b3d46dd3c 100644 --- a/apps/readux/search.py +++ b/apps/readux/search.py @@ -1,12 +1,14 @@ """Search Endpoint(s)""" -import json +from itertools import groupby +import re from django.http import JsonResponse from django.views import View -from django.db.models import Count, Q -from django.contrib.postgres.search import SearchVector, SearchQuery, SearchRank -from ..iiif.annotations.models import Annotation -from ..iiif.manifests.models import Manifest -from .models import UserAnnotation +from elasticsearch_dsl import Q +from more_itertools import flatten + +from apps.iiif.manifests.documents import ManifestDocument +from apps.readux.documents import UserAnnotationDocument +from apps.readux.templatetags.readux_extras import has_inner_hits, group_by_canvas class SearchManifestCanvas(View): @@ -14,82 +16,179 @@ class SearchManifestCanvas(View): Endpoint for text search of manifest :rtype json """ + + # regex to match exact search terms in doublequotes + re_exact_match = re.compile(r'\B(".+?")\B') + def get_queryresults(self): """ Build query results. :return: [description] :rtype: JSON """ - manifest = Manifest.objects.get(pid=self.request.GET['volume']) - annotations = Annotation.objects.filter( - canvas__manifest__label=manifest.label - ) - user_annotations = UserAnnotation.objects.filter( - owner_id=self.request.user.id - ).filter( - canvas__manifest__label=manifest.label + # get search params + volume_pid = self.request.GET.get("volume_id") or "" + canvas_pid = self.request.GET.get("canvas_id") or "" + search_query = self.request.GET.get("keyword") or "" + + # find exact match queries (words or phrases in double quotes) + exact_queries = self.re_exact_match.findall(search_query) + # remove exact queries from the original search query to search separately + search_query = re.sub(self.re_exact_match, "", search_query).strip() + + # set up elasticsearch queries for searching the volume full text + vol_queries = [] + vol_queries_exact = [] + + # techcnically we are using the search for all volumes here, + # but we are filtering to only one volume and then getting inner hits + volumes = ManifestDocument.search() + # filter to only volume matching pid + volumes = volumes.filter("term", pid=volume_pid) + + # build query for nested fields (i.e. canvas position and text) + nested_kwargs = { + "path": "canvas_set", + "score_mode": "sum", + } + inner_hits_dict = { + "size": 100, # max number of pages shown in full-text results + "highlight": {"fields": {"canvas_set.result": {}}}, + } + + # get nested inner hits on canvas (for partial match portion of query) + nested_query = Q( + "nested", + query=Q( + "multi_match", + query=search_query, + fields=["canvas_set.result"], + ), + inner_hits={**inner_hits_dict, "name": "canvases"}, + **nested_kwargs, ) - fuzzy_search = Q() - query = SearchQuery('') - vector = SearchVector('content') - search_type = self.request.GET['type'] - search_strings = self.request.GET['query'].split() + vol_queries.append(nested_query) + + for i, exq in enumerate(exact_queries): + # separate exact searches so we can put them in "must" boolean query + nested_exact = Q( + "nested", + query=Q( + "multi_match", + query=exq.replace('"', "").strip(), + fields=["canvas_set.result"], + type="phrase", + ), + # each inner_hits set needs to have a different name in elasticsearch + inner_hits={**inner_hits_dict, "name": f"canvases_{i}"}, + **nested_kwargs, + ) + vol_queries_exact.append({"bool": {"should": [nested_exact]}}) + + # combine exact and partial with bool: { should, must } + q = Q("bool", should=vol_queries, must=vol_queries_exact) + volumes = volumes.query(q) + + # execute the search + response = volumes.execute() + + # group inner hits by canvas and collect highlighted context + volume_matches = [] + total_matches_on_canvas = 0 + total_matches_in_volume = 0 + if int(response.hits.total.value): + volume = response.hits[0] + if has_inner_hits(volume): + for canvas in group_by_canvas(volume.meta.inner_hits, limit=100): + volume_matches.append( + { + "canvas_index": canvas["position"], + "canvas_match_count": len(canvas["highlights"]), + "canvas_pid": canvas["pid"], + "context": canvas["highlights"], + } + ) + total_matches_in_volume += len(canvas["highlights"]) + if canvas_pid and canvas["pid"] == canvas_pid: + total_matches_on_canvas = len(canvas["highlights"]) + + # JSON-serializable results results = { - 'search_terms': search_strings, - 'ocr_annotations': [], - 'user_annotations': [] + "matches_in_text": { + "total_matches_on_canvas": total_matches_on_canvas, + "total_matches_in_volume": total_matches_in_volume, + "volume_matches": volume_matches, + } } - if search_strings: - if search_type == 'partial': - for search_string in search_strings: - query = query | SearchQuery(search_string) - fuzzy_search |= Q(content__icontains=search_string) - annotations = annotations.filter(fuzzy_search) - user_annotations = user_annotations.filter(fuzzy_search) - else: - for search_string in search_strings: - query = query | SearchQuery(search_string) - - annotations = annotations.annotate( - search=vector - ).filter( - search=query + # ------------------------------------------------------------ + # Now, search for UserAnnotations + annotations = UserAnnotationDocument.search() + + # filter to only owner matching user, volume matching pid + annotations = annotations.filter( + "term", owner_username=self.request.user.username + ).filter("term", manifest_pid=volume_pid) + + # search for partial matches + anno_queries = [Q("multi_match", query=search_query, fields=["content"])] + + # search for exact matches + anno_queries_exact = [] + for i, exq in enumerate(exact_queries): + # separate exact searches so we can put them in "must" boolean query + eq = exq.replace('"', "").strip() + nested_exact = Q("multi_match", query=eq, fields=["content"], type="phrase") + anno_queries_exact.append({"bool": {"should": [nested_exact]}}) + + # combine exact and partial with bool: { should, must } + q = Q("bool", should=anno_queries, must=anno_queries_exact) + annotations = annotations.query(q) + annotations = annotations.highlight("content") + + # execute the search + anno_response = annotations.execute() + + # collect metadata and highlighted context from hits + annotation_match_count = int(anno_response.hits.total.value) + annotation_matches = [] + if annotation_match_count: + for anno in anno_response.hits: + annotation_matches.append( + { + "canvas_index": anno["canvas_index"], + "canvas_pid": anno["canvas_pid"], + "context": list(anno.meta.highlight.content), + } ) - user_annotations = user_annotations.annotate( - search=vector - ).filter( - search=query - ) + # group annotations by canvas pid + # TODO: is there a way to do this without iterating over the same data multiple times? + # maybe some kind of aggregation in elastic? + group_key = lambda a: a["canvas_pid"] + annotation_matches.sort(key=group_key) + annotation_matches_grouped = [] + annotation_matches_on_canvas = 0 + annotation_matches_in_volume = 0 + for k, v in groupby(annotation_matches, key=group_key): + canvas_matches = list(flatten([match["context"] for match in v])) + annotation_matches_grouped.append( + { + "canvas_index": anno["canvas_index"], + "canvas_match_count": len(canvas_matches), + "canvas_pid": k, + "context": canvas_matches, + } + ) + annotation_matches_in_volume += len(canvas_matches) + if canvas_pid and k == canvas_pid: + annotation_matches_on_canvas = len(canvas_matches) - annotation_results = annotations.values( - 'canvas__position', - 'canvas__manifest__pid', - 'canvas__pid' - ).annotate( - Count('canvas__position') - ).order_by( - 'canvas__position' - ).exclude( - resource_type='dctypes:Text' - ).distinct() - - for annotation in annotation_results: - results['ocr_annotations'].append(json.dumps(annotation)) - - user_annotation_results = user_annotations.values( - 'canvas__position', - 'canvas__manifest__pid', - 'canvas__pid' - ).annotate( - rank=SearchRank(vector, query) - ).order_by( - '-rank' - ).distinct() - - for ua_annotation in user_annotation_results: - results['user_annotations'].append(json.dumps(ua_annotation)) + results["matches_in_annotations"] = { + "total_matches_on_canvas": annotation_matches_on_canvas, + "total_matches_in_volume": annotation_matches_in_volume, + "volume_matches": annotation_matches_grouped, + } return results @@ -98,7 +197,4 @@ def get(self, request, *args, **kwargs): # pylint: disable = unused-argument Respond to GET requests for search queries. :rtype: JsonResponse """ - return JsonResponse( - status=200, - data=self.get_queryresults() - ) + return JsonResponse(status=200, data=self.get_queryresults()) diff --git a/apps/readux/templates/widgets/custom_dropdown_select.html b/apps/readux/templates/widgets/custom_dropdown_select.html new file mode 100644 index 000000000..ea76cd5b4 --- /dev/null +++ b/apps/readux/templates/widgets/custom_dropdown_select.html @@ -0,0 +1,20 @@ +{# Adapted from django/forms/widgets/radio.html to use uk-navbar-dropdown #} + +{% with id=widget.attrs.id %} +{{ selected_value_label|default_if_none:widget.optgroups.0.1.0.label }} +
+
    + {% for group, options, index in widget.optgroups %} + {% for option in options %} +
  • + +
  • + {% endfor %} + {% endfor %} +
+
+{% endwith %} diff --git a/apps/readux/templatetags/readux_extras.py b/apps/readux/templatetags/readux_extras.py new file mode 100644 index 000000000..1e2912f50 --- /dev/null +++ b/apps/readux/templatetags/readux_extras.py @@ -0,0 +1,140 @@ +import re +from django.template import Library + +register = Library() + + +@register.filter +def dict_item(dictionary, key): + """'Template filter to allow accessing dictionary value by variable key. + Example use:: + + {{ mydict|dict_item:keyvar }} + """ + # adapted from Princeton-CDH/geniza project https://github.com/Princeton-CDH/geniza/ + try: + return dictionary[key] + except AttributeError: + # fail silently if something other than a dict is passed + return None + + +@register.filter +def attr(obj, attr_name): + """Safely access an attribute on an object without raising if missing.""" + try: + return getattr(obj, attr_name) + except AttributeError: + return None + + +@register.filter +def manifest_year(volume): + """Return a 4-digit published year from a manifest or hit object.""" + # try standard published_date first + published = getattr(volume, "published_date", None) + if published: + # handle datetime/date objects with a year attribute + year = getattr(published, "year", None) + if year: + return f"{int(year):04d}" + # fall back to parsing string content + match = re.search(r"\d{4}", str(published)) + if match: + return match.group(0) + return str(published)[:4] + + # fall back to elasticsearch stored string (may be missing on some hits) + edtf = getattr(volume, "published_date_edtf", None) + if edtf: + match = re.search(r"\d{4}", str(edtf)) + if match: + return match.group(0) + return str(edtf)[:4] + + return "" + + +@register.filter +def has_inner_hits(volume): + """Template filter to determine if there are any inner hits across the volume""" + try: + inner_hits = volume.meta.inner_hits + for key in inner_hits.to_dict().keys(): + if inner_hits[key].hits.total.value: + return True + except AttributeError: + pass + return False + + +@register.filter +def group_by_canvas(inner_hits, limit=3): + """Template filter to group inner hits by canvas #, then flatten into list""" + hits_dict = inner_hits.to_dict() + # dict keyed on canvas + canvases = {} + for key in hits_dict.keys(): + for canvas in hits_dict[key]: + if not canvas.position in canvases: + # only need to get some info once per canvas (position, pid) + canvases[canvas.position] = { + "pid": canvas.pid, + "position": canvas.position, + "highlights": [], + "search_terms": [key], + } + else: + # keep track of search term for exact match queries + canvases[canvas.position]["search_terms"] += [key] + # collect highlights per canvas + if canvas.meta and canvas.meta.highlight: + for result in canvas.meta.highlight["canvas_set.result"]: + canvases[canvas.position]["highlights"].append(result) + + # flatten values into list for display + grouped = [] + for canvas in canvases.values(): + # result should generally be of length "limit" or less, but if there are multiple exact + # queries in this search, ensure at least one highlighted page per exact query (i.e. if + # none of the search terms matched on this page are matched on any of the pages we've + # selected for display, ensure this page gets displayed too) + if len(grouped) < limit or not any( + [ + set(c["search_terms"]).intersection(canvas["search_terms"]) + for c in grouped + ] + ): + grouped.append(canvas) + return grouped + + +@register.filter +def get_headers(block_list): + # filter to get just the headers in a wagtail page, in order to render a table of contents + return list(filter(lambda b: b.value and b.block_type == "heading_block", block_list)) + + +@register.filter +def vimeo_embed_url(vimeo_url): + # get the embed url from a vimeo link + # i.e. https://vimeo.com/76979871 --> https://player.vimeo.com/video/76979871 + return re.sub(r"vimeo\.com\/(\d+)", r"player.vimeo.com/video/\1", vimeo_url) + + +@register.filter +def spaced_semicolons(value): + """Ensure a single space follows each semicolon in a metadata string.""" + try: + return re.sub(r";\s*", "; ", str(value)) + except TypeError: + return value + + +@register.filter +def strip_trailing_commas(value): + """Remove any trailing commas and surrounding whitespace.""" + try: + return re.sub(r",\s*$", "", str(value)) + except TypeError: + return value diff --git a/apps/readux/templatetags/split_to_bullets.py b/apps/readux/templatetags/split_to_bullets.py new file mode 100644 index 000000000..f1e0f8a10 --- /dev/null +++ b/apps/readux/templatetags/split_to_bullets.py @@ -0,0 +1,13 @@ +from django import template + +register = template.Library() + +@register.filter +def split_to_bullets(value, delimiter=";"): + """ + Splits a string into individual items based on a delimiter and returns it as a list. + Default delimiter is a semicolon (;). + """ + if not value: + return [] + return [item.strip() for item in value.split(delimiter)] \ No newline at end of file diff --git a/apps/readux/tests/factories.py b/apps/readux/tests/factories.py index 528a0a782..397d584fb 100755 --- a/apps/readux/tests/factories.py +++ b/apps/readux/tests/factories.py @@ -1,18 +1,24 @@ """Factory to create Annotations for Tests""" -from random import randrange + from factory import Faker, SubFactory +from apps.iiif.annotations.models import Annotation +from apps.iiif.annotations.choices import AnnotationPurpose +from apps.iiif.canvases.tests.factories import CanvasFactory from apps.users.tests.factories import UserFactory from ...iiif.annotations.tests.factories import AnnotationFactory from ..models import UserAnnotation + class UserAnnotationFactory(AnnotationFactory): """Creates UserAnnotation object for testing.""" - resource_type = 'oa:SvgSelector' - content = Faker('text') - motivation = "commenting" - format = "text/html" + + content = Faker("text") + motivation = Annotation.OA_COMMENTING owner = SubFactory(UserFactory) oa_annotation = {} + resource_type = UserAnnotation.TEXT + purpose = AnnotationPurpose("CM") + canvas = SubFactory(CanvasFactory) - class Meta: # pylint: disable=too-few-public-methods, missing-class-docstring + class Meta: # pylint: disable=too-few-public-methods, missing-class-docstring model = UserAnnotation diff --git a/apps/readux/tests/test_search.py b/apps/readux/tests/test_search.py index cfe8b1a63..4d8c8d9c5 100644 --- a/apps/readux/tests/test_search.py +++ b/apps/readux/tests/test_search.py @@ -5,6 +5,7 @@ from django.test import TestCase, Client from django.test import RequestFactory from django.urls import reverse +from django_elasticsearch_dsl.test import ESTestCase from apps.users.tests.factories import UserFactory from ...iiif.manifests.tests.factories import ManifestFactory from ...iiif.canvases.tests.factories import CanvasFactory @@ -13,11 +14,12 @@ from .factories import UserAnnotationFactory -class TestReaduxPageDetailSearch(TestCase): +class TestReaduxPageDetailSearch(ESTestCase, TestCase): """ Test page search. """ def setUp(self): + super().setUp() self.search_manifest_view = SearchManifestCanvas.as_view() self.request = RequestFactory() self.volume = ManifestFactory.create() @@ -33,9 +35,13 @@ def setUp(self): # # Delete the canvas created by the ManifestFactory to ensure a clean set. original_canvas.delete() for _ in [1, 2]: - self.add_annotations(self.volume.canvas_set.get(position=1)) + canvas = self.volume.canvas_set.get(position=1) + self.add_annotations(canvas) + canvas.save() for _ in [1, 2, 3]: - self.add_annotations(self.volume.canvas_set.get(position=2)) + canvas = self.volume.canvas_set.get(position=2) + self.add_annotations(canvas) + canvas.save() # pylint: enable = unused-variable @@ -46,12 +52,12 @@ def add_annotations(self, canvas): """Add OCR and User annotations to a canvas.""" AnnotationFactory.create( canvas=canvas, - content='stankonia', + content='stinking', owner=self.ocr_user ) UserAnnotationFactory.create( canvas=canvas, - content='Aquemini', + content='outcasts', owner=self.user ) @@ -65,80 +71,106 @@ def load_results(self, response): return json.loads(response.content.decode('UTF-8-sig')) def test_manifest_canvas_ocr_partial_search(self): - query_params = {'volume': self.volume.pid, 'type': 'partial', 'query': 'stank'} + query_params = {'volume_id': self.volume.pid, 'keyword': 'stink'} request = self.request.get( self.url, query_params ) request.user = UserFactory.create() response = self.search_manifest_view(request) search_results = self.load_results(response) - assert len(search_results['ocr_annotations']) == 2 - assert len(search_results['user_annotations']) == 0 - assert search_results['search_terms'] == 'stank'.split() - assert json.loads(search_results['ocr_annotations'][0])['canvas__position'] == 1 - assert json.loads(search_results['ocr_annotations'][1])['canvas__position'] == 2 - assert json.loads(search_results['ocr_annotations'][0])['canvas__position__count'] == 2 - assert json.loads(search_results['ocr_annotations'][1])['canvas__position__count'] == 3 + + # two hits in text, no hits in annotations + assert search_results['matches_in_text']['total_matches_in_volume'] == 2 + assert search_results['matches_in_annotations']['total_matches_in_volume'] == 0 + for match in search_results['matches_in_text']['volume_matches']: + # should be in canvas indices 1 and 2 + assert match["canvas_index"] in [1, 2] + if match["canvas_index"] == 1: + # 2 matches in first canvas + # NOTE: OCR annotations are indexed as a single block of text per canvas. in this + # case, the terms appeared so close together they are grouped into one result, but + # still highlighted individually, thus two s. + assert match["context"][0].count("") == 2 + elif match["canvas_index"] == 2: + # 3 matches in second canvas + assert match["context"][0].count("") == 3 def test_manifest_canvas_ocr_exact_search(self): - query_params = {'volume': self.volume.pid, 'type': 'exact', 'query': 'stankonia'} + query_params = {'volume_id': self.volume.pid, 'keyword': '"stinking"'} request = self.request.get( self.url, query_params ) request.user = UserFactory.create() response = self.search_manifest_view(request) search_results = self.load_results(response) - assert len(search_results['ocr_annotations']) == 2 - assert len(search_results['user_annotations']) == 0 - assert json.loads(search_results['ocr_annotations'][0])['canvas__position'] == 1 - assert json.loads(search_results['ocr_annotations'][1])['canvas__position'] == 2 - assert json.loads(search_results['ocr_annotations'][0])['canvas__position__count'] == 2 - assert json.loads(search_results['ocr_annotations'][1])['canvas__position__count'] == 3 + # two hits in text, no hits in annotations + assert search_results['matches_in_text']['total_matches_in_volume'] == 2 + assert search_results['matches_in_annotations']['total_matches_in_volume'] == 0 + for match in search_results['matches_in_text']['volume_matches']: + # should be in canvas indices 1 and 2 + assert match["canvas_index"] in [1, 2] + if match["canvas_index"] == 1: + # 2 matches in first canvas; so close together they are grouped into one result + assert match["context"][0].count("") == 2 + elif match["canvas_index"] == 2: + # 3 matches in second canvas; so close together they are grouped into one result + assert match["context"][0].count("") == 3 def test_manifest_canvas_ocr_exact_search_no_results(self): - query_params = {'volume': self.volume.pid, 'type': 'exact', 'query': 'Idlewild'} + query_params = {'volume_id': self.volume.pid, 'keyword': '"Idlewild"'} request = self.request.get( self.url, query_params ) request.user = UserFactory.create() response = self.search_manifest_view(request) search_results = self.load_results(response) - assert len(search_results['ocr_annotations']) == 0 - assert len(search_results['user_annotations']) == 0 + assert search_results['matches_in_text']['total_matches_in_volume'] == 0 + assert search_results['matches_in_annotations']['total_matches_in_volume'] == 0 def test_manifest_canvas_user_annotation_partial_search(self): - query_params = {'volume': self.volume.pid, 'type': 'partial', 'query': 'Aqu'} + query_params = {'volume_id': self.volume.pid, 'keyword': 'outcast'} request = self.request.get( self.url, query_params ) request.user = self.user response = self.search_manifest_view(request) search_results = self.load_results(response) - assert len(search_results['ocr_annotations']) == 0 - assert len(search_results['user_annotations']) == 2 - assert json.loads(search_results['user_annotations'][0])['canvas__position'] == 1 - assert json.loads(search_results['user_annotations'][1])['canvas__position'] == 2 + print(search_results) + assert search_results['matches_in_text']['total_matches_in_volume'] == 0 + # NOTE: since user annotations are indexed individually (unlike OCR annotations which + # are grouped by canvas), each one gets a separate hit, so the total matches is 5. + # however, in the search results, they will be grouped by canvas. thus, len(matches) is 2, + # one per canvas, while total matches remains 5. + assert search_results['matches_in_annotations']['total_matches_in_volume'] == 5 + assert len(search_results['matches_in_annotations']['volume_matches']) == 2 + for match in search_results['matches_in_annotations']['volume_matches']: + # should be in canvas indices 1 and 2 + assert match["canvas_index"] in [1, 2] def test_manifest_canvas_user_annotation_exact_search(self): - query_params = {'volume': self.volume.pid, 'type': 'exact', 'query': 'Aquemini'} + query_params = {'volume_id': self.volume.pid, 'keyword': '"outcasts"'} request = self.request.get( self.url, query_params ) request.user = self.user response = self.search_manifest_view(request) search_results = self.load_results(response) - assert len(search_results['ocr_annotations']) == 0 - assert len(search_results['user_annotations']) == 2 - assert json.loads(search_results['user_annotations'][0])['canvas__position'] == 1 - assert json.loads(search_results['user_annotations'][1])['canvas__position'] == 2 + print(search_results) + assert search_results['matches_in_text']['total_matches_in_volume'] == 0 + # NOTE: see above note about user annotations vs OCR annotations. + assert search_results['matches_in_annotations']['total_matches_in_volume'] == 5 + assert len(search_results['matches_in_annotations']['volume_matches']) == 2 + for match in search_results['matches_in_annotations']['volume_matches']: + # should be in canvas indices 1 and 2 + assert match["canvas_index"] in [1, 2] def test_manifest_canvas_user_annotation_exact_search_no_results(self): - query_params = {'volume': self.volume.pid, 'type': 'exact', 'query': 'Idlewild'} + query_params = {'volume_id': self.volume.pid, 'keyword': '"Idlewild"'} request = self.request.get( self.url, query_params ) request.user = self.user response = self.search_manifest_view(request) search_results = self.load_results(response) - assert len(search_results['ocr_annotations']) == 0 - assert len(search_results['user_annotations']) == 0 + assert search_results['matches_in_text']['total_matches_in_volume'] == 0 + assert search_results['matches_in_annotations']['total_matches_in_volume'] == 0 diff --git a/apps/readux/tests/test_views.py b/apps/readux/tests/test_views.py index 22ff53901..ec9c8df27 100644 --- a/apps/readux/tests/test_views.py +++ b/apps/readux/tests/test_views.py @@ -1,81 +1,94 @@ +"""Test for readux views""" + import os from unittest.mock import Mock, patch -import pytest from tempfile import gettempdir from pathlib import Path -from django.test import RequestFactory, TestCase +import pytest from django.http import HttpResponse +from django.test import RequestFactory, TestCase +from django_elasticsearch_dsl.test import ESTestCase from apps.readux import views from apps.iiif.manifests.models import Language, Manifest from apps.iiif.manifests.tests.factories import ManifestFactory +from apps.iiif.manifests.documents import ManifestDocument from apps.iiif.kollections.tests.factories import CollectionFactory from apps.iiif.kollections.models import Collection from apps.iiif.canvases.models import Canvas from apps.users.tests.factories import UserFactory -import config.settings.local as settings -from django_elasticsearch_dsl.test import ESTestCase pytestmark = pytest.mark.django_db + class TestReaduxViews: + """ + Test views for readux app + """ + def test_page_detail_context(self): + """Test""" factory = RequestFactory() user = UserFactory.create() assert not user._state.adding volume = ManifestFactory.create() - request = factory.get('/') + request = factory.get("/") request.user = user view = views.PageDetail(request=request) - data = view.get_context_data(volume=volume.pid, page=volume.canvas_set.all().first().pid) - assert data['volume'].pid == volume.pid - assert isinstance(data['volume'], Manifest) - assert isinstance(data['page'], Canvas) - assert isinstance(data['user_annotation_page_count'], int) - assert isinstance(data['user_annotation_count'], int) - assert data['mirador_url'] == settings.MIRADOR_URL + data = view.get_context_data( + volume=volume.pid, page=volume.canvas_set.all().first().pid + ) + assert data["volume"].pid == volume.pid + assert isinstance(data["volume"], Manifest) + assert isinstance(data["page"], Canvas) + assert isinstance(data["user_annotation_page_count"], int) + assert isinstance(data["user_annotation_count"], int) def test_page_detail_context_with_no_page_in_kwargs(self): + """Test""" factory = RequestFactory() - request = factory.get('/') + request = factory.get("/") user = UserFactory.create() request.user = user volume = ManifestFactory.create() view = views.PageDetail(request=request) data = view.get_context_data(volume=volume.pid) - assert data['volume'].pid == volume.pid - assert data['page'].pid == volume.canvas_set.all().first().pid + assert data["volume"].pid == volume.pid + assert data["page"].pid == volume.canvas_set.all().first().pid def test_manifests_sitemap(self): - for n in range(5): + """Test""" + for _ in range(5): ManifestFactory.create() view = views.ManifestsSitemap() manifest = Manifest.objects.all().first() assert view.items().count() == Manifest.objects.all().count() - assert view.location(manifest) == '/volume/{m}/page/all'.format(m=manifest.pid) + assert view.location(manifest) == f"/volume/{manifest.pid}/page/all" assert view.lastmod(manifest) == manifest.updated_at def test_collections_sitemap(self): + """Test""" for n in range(3): CollectionFactory.create() view = views.CollectionsSitemap() collection = Collection.objects.all().first() assert view.items().count() == Collection.objects.all().count() - assert view.location(collection) == '/collection/{c}/'.format(c=collection.pid) + assert view.location(collection) == f"/collection/{collection.pid}/" assert view.lastmod(collection) == collection.updated_at def test_export_download_zip(self): + """Test""" factory = RequestFactory() - request = factory.get('/') + request = factory.get("/") user = UserFactory.create() request.user = user - dummy_file = os.path.join(gettempdir(), 'dummy.txt') + dummy_file = os.path.join(gettempdir(), "dummy.txt") Path(dummy_file).touch() view = views.ExportDownloadZip(request=request) response = view.get(request, filename=dummy_file) assert isinstance(response, HttpResponse) assert response.status_code == 200 assert isinstance(response.serialize(), bytes) - assert 'jekyll_site_export.zip' in str(response.serialize()) + assert "jekyll_site_export.zip" in str(response.serialize()) class TestVolumeSearchView(ESTestCase, TestCase): @@ -94,7 +107,6 @@ def setUp(self): published_date_edtf="2022-04-14", ) self.volume1.save() - print(self.volume1.date_earliest) self.volume1.languages.add(lang_en) self.volume2 = Manifest( pid="uniquepid2", @@ -120,6 +132,9 @@ def setUp(self): self.volume1.collections.add(collection) self.volume3.collections.add(collection) + for manifest in Manifest.objects.all(): + ManifestDocument().update(manifest, True, "index") + def test_get_queryset(self): """Should be able to query by search term""" volume_search_view = views.VolumeSearchView() @@ -127,19 +142,25 @@ def test_get_queryset(self): volume_search_view.request.GET = {"q": "primary"} search_results = volume_search_view.get_queryset() response = search_results.execute(ignore_cache=True) - assert response.hits.total['value'] == 1 - assert response.hits[0]['pid'] == self.volume1.pid + assert response.hits.total["value"] == 1 + assert response.hits[0]["pid"] == self.volume1.pid # should get all volumes when request is empty volume_search_view.request.GET = {} search_results = volume_search_view.get_queryset() response = search_results.execute(ignore_cache=True) - assert response.hits.total['value'] == 3 + assert response.hits.total["value"] == 3 # should sort by label alphabetically by default - assert response.hits[0]['label'] == self.volume1.label + assert response.hits[0]["label"] == self.volume1.label def test_get_queryset_filters(self): """Should filter according to chosen filters""" + + # Reindex each volume + index = ManifestDocument() + for manifest in Manifest.objects.all(): + index.update(manifest, True, "index") + volume_search_view = views.VolumeSearchView() volume_search_view.request = Mock() @@ -147,7 +168,7 @@ def test_get_queryset_filters(self): volume_search_view.request.GET = {"author": ["Ben"]} search_results = volume_search_view.get_queryset() response = search_results.execute(ignore_cache=True) - assert response.hits.total['value'] == 2 + assert response.hits.total["value"] == 2 for hit in response.hits: assert "Ben" in hit["authors"] @@ -155,43 +176,49 @@ def test_get_queryset_filters(self): volume_search_view.request.GET = {"author": ["Ben", "An Author"]} search_results = volume_search_view.get_queryset() response = search_results.execute(ignore_cache=True) - assert response.hits.total['value'] == 3 + assert response.hits.total["value"] == 3 # should get 0 for bad author volume_search_view.request.GET = {"author": ["Bad Author"]} search_results = volume_search_view.get_queryset() response = search_results.execute(ignore_cache=True) - assert response.hits.total['value'] == 0 + assert response.hits.total["value"] == 0 # should filter on languages volume_search_view.request.GET = {"language": ["Latin"]} search_results = volume_search_view.get_queryset() response = search_results.execute(ignore_cache=True) - assert response.hits.total['value'] == 1 + assert response.hits.total["value"] == 1 # should get all manifests matching ANY passed language volume_search_view.request.GET = {"language": ["English", "Latin"]} search_results = volume_search_view.get_queryset() response = search_results.execute(ignore_cache=True) - assert response.hits.total['value'] == 2 + assert response.hits.total["value"] == 2 # should filter on collections label volume_search_view.request.GET = {"collection": ["test collection"]} search_results = volume_search_view.get_queryset() response = search_results.execute(ignore_cache=True) - assert response.hits.total['value'] == 2 + assert response.hits.total["value"] == 2 # should filter on start and end date - volume_search_view.request.GET = {"start_date": "2020-01-01", "end_date": "2024-01-01"} + volume_search_view.request.GET = { + "start_date": "2020-01-01", + "end_date": "2024-01-01", + } search_results = volume_search_view.get_queryset() response = search_results.execute(ignore_cache=True) - assert response.hits.total['value'] == 2 + assert response.hits.total["value"] == 2 # should filter on start and end date (fuzzy) - volume_search_view.request.GET = {"start_date": "1899-01-01", "end_date": "1910-01-01"} + volume_search_view.request.GET = { + "start_date": "1899-01-01", + "end_date": "1910-01-01", + } search_results = volume_search_view.get_queryset() response = search_results.execute(ignore_cache=True) - assert response.hits.total['value'] == 1 + assert response.hits.total["value"] == 1 def test_get_queryset_sorting(self): """Should sort according to default or chosen sort""" @@ -202,49 +229,49 @@ def test_get_queryset_sorting(self): volume_search_view.request.GET = {"sort": ""} search_results = volume_search_view.get_queryset() response = search_results.execute(ignore_cache=True) - assert response.hits[0]['label'] == self.volume1.label + assert response.hits[0]["label"] == self.volume1.label # should sort by label, in reverse alphabetical order volume_search_view.request.GET = {"sort": "-label_alphabetical"} search_results = volume_search_view.get_queryset() response = search_results.execute(ignore_cache=True) - assert response.hits[0]['label'] == self.volume3.label + assert response.hits[0]["label"] == self.volume3.label # should sort by relevance volume_search_view.request.GET = {"q": "test", "sort": "_score"} search_results = volume_search_view.get_queryset() response = search_results.execute(ignore_cache=True) - assert response.hits[0]['pid'] != self.volume3.pid + assert response.hits[0]["pid"] != self.volume3.pid # should sort by relevance volume_search_view.request.GET = {"q": "test", "sort": ""} search_results = volume_search_view.get_queryset() response = search_results.execute(ignore_cache=True) - assert response.hits[0]['pid'] != self.volume3.pid + assert response.hits[0]["pid"] != self.volume3.pid # should sort by date added (asc) volume_search_view.request.GET = {"sort": "created_at"} search_results = volume_search_view.get_queryset() response = search_results.execute(ignore_cache=True) - assert response.hits[0]['pid'] == self.volume1.pid + assert response.hits[0]["pid"] == self.volume1.pid # should sort by date added (desc) volume_search_view.request.GET = {"sort": "-created_at"} search_results = volume_search_view.get_queryset() response = search_results.execute(ignore_cache=True) - assert response.hits[0]['pid'] == self.volume3.pid + assert response.hits[0]["pid"] == self.volume3.pid # should sort by date published (asc) volume_search_view.request.GET = {"sort": "date_sort_ascending"} search_results = volume_search_view.get_queryset() response = search_results.execute(ignore_cache=True) - assert response.hits[0]['pid'] == self.volume3.pid + assert response.hits[0]["pid"] == self.volume3.pid # should sort by date published (desc) volume_search_view.request.GET = {"sort": "-date_sort_descending"} search_results = volume_search_view.get_queryset() response = search_results.execute(ignore_cache=True) - assert response.hits[0]['pid'] == self.volume2.pid + assert response.hits[0]["pid"] == self.volume2.pid def test_label_boost(self): """Should return the item matching label first, before matching summary""" @@ -254,9 +281,9 @@ def test_label_boost(self): search_results = volume_search_view.get_queryset() # with multiple keywords, should return all matches response = search_results.execute(ignore_cache=True) - assert response.hits.total['value'] == 3 + assert response.hits.total["value"] == 3 # should return "secondary" label match first (sort by relevance is default) - assert response.hits[0]['pid'] == self.volume2.pid + assert response.hits[0]["pid"] == self.volume2.pid def test_highlighting(self): """Should highlight text matching query""" @@ -290,12 +317,14 @@ def test_get_context_data(self, mock_set_date, mock_set_facets): del response.aggregations.author.inner volume_search_view.get_context_data() - mock_set_facets.assert_called_with({ - "language": response.aggregations.language.buckets, - "author": response.aggregations.author.buckets, - # collections IS nested, so it should have "inner" attribute - "collections": response.aggregations.collections.inner.buckets, - }) + mock_set_facets.assert_called_with( + { + "language": response.aggregations.language.buckets, + "author": response.aggregations.author.buckets, + # collections IS nested, so it should have "inner" attribute + "collections": response.aggregations.collections.inner.buckets, + } + ) # should call set_date with the aggregated min and max dates (as strings) mock_set_date.assert_called_with( diff --git a/apps/readux/tests/tests.py b/apps/readux/tests/tests.py index 3a85bba73..bf70d671e 100644 --- a/apps/readux/tests/tests.py +++ b/apps/readux/tests/tests.py @@ -1,33 +1,42 @@ -from django.test import TestCase, Client -from django.test import RequestFactory -from django.conf import settings -from ..annotations import Annotations, AnnotationCrud +import json +import uuid +from cssutils import parseString from django.contrib.auth import get_user_model from django.urls import reverse from django.core.serializers import serialize -from apps.iiif.manifests.models import Manifest +from django.test import TestCase, Client +from django.test import RequestFactory from apps import readux +from apps.iiif.manifests.models import Manifest +from apps.iiif.manifests.tests.factories import ManifestFactory +from apps.iiif.canvases.models import Canvas +from apps.iiif.canvases.tests.factories import CanvasFactory +from apps.iiif.annotations.tests.factories import AnnotationFactory +from apps.readux.views import ExportOptions, AnnotationCount +from apps.users.tests.factories import UserFactory +from ..annotations import Annotations, AnnotationCrud, AnnotationCountByCanvas from ..models import UserAnnotation from ..context_processors import current_version -from apps.readux.views import VolumesList, CollectionDetail, ExportOptions, AnnotationsCount -from urllib.parse import urlencode -from cssutils import parseString -import json -import re -import uuid +from .factories import UserAnnotationFactory -User = get_user_model() class AnnotationTests(TestCase): - fixtures = ['users.json', 'kollections.json', 'manifests.json', 'canvases.json', 'annotations.json'] + fixtures = [ + "users.json", + "kollections.json", + "manifests.json", + "canvases.json", + "annotations.json", + ] valid_mirador_annotations = { - 'svg': { 'oa_annotation': '''{ + "svg": { + "oa_annotation": """{ "on": [{ "full": "https://readux-dev.org:3000/iiif/readux:st7r6/canvas/fedora:emory:5622", "@type": "oa:SpecificResource", "selector": { - "@type": "oa:Choice", + "@type": "oa:SvgSelector", "item": { "@type": "oa:SvgSelector", "value": "" @@ -54,9 +63,10 @@ class AnnotationTests(TestCase): "chars": "

wfv3v3v3

" }], "motivation": ["oa:commenting"] - }'''}, - 'text': { - 'oa_annotation': '''{ + }""" + }, + "text": { + "oa_annotation": """{ "@type": "oa:Annotation", "motivation": ["oa:commenting"], "annotatedBy": { @@ -103,10 +113,10 @@ class AnnotationTests(TestCase): "@id": "https://readux-dev.org:3000/iiif/v2/readux:st7r6/manifest" } }] - }''' + }""" }, - 'tag':{ - 'oa_annotation': '''{ + "tag": { + "oa_annotation": """{ "@type": "oa:Annotation", "motivation": ["oa:commenting"], "annotatedBy": { @@ -163,8 +173,93 @@ class AnnotationTests(TestCase): "@id": "https://readux-dev.org:3000/iiif/v2/readux:st7r6/manifest" } }] - }''' - } + }""" + }, + } + + valid_v3_annotation = { + "svg": { + "type": "Annotation", + "body": [ + { + "purpose": "commenting", + "type": "TextualBody", + "value": "

box

", + "creator": {"id": "zaphod", "displayName": "Zaphod Beeblebrox"}, + } + ], + "target": { + "source": "https://readux-dev.org:3000/iiif/readux:st7r6/canvas/fedora:emory:5622", + "selector": { + "type": "FragmentSelector", + "conformsTo": "http://www.w3.org/TR/media-frags/", + "value": "xywh=pixel:928.5149536132812,2576.39990234375,583.6755981445312,510.496826171875", + }, + }, + "@context": "http://www.w3.org/ns/anno.jsonld", + "id": "3eaacc97-02c7-4e6f-a20f-afe706bdc6e1", + }, + "text": { + "type": "Annotation", + "body": [ + { + "type": "TextualBody", + "value": "

text

", + "purpose": "commenting", + "creator": {"id": "zaphod", "displayName": "Zaphod Beeblebrox"}, + } + ], + "bodies": [ + { + "type": "TextualBody", + "value": "

text

", + "purpose": "commenting", + "creator": {"id": "zaphod", "displayName": "Zaphod Beeblebrox"}, + } + ], + "target": { + "source": "https://readux-dev.org:3000/iiif/readux:st7r6/canvas/fedora:emory:5622", + "selector": { + "type": "RangeSelector", + "startSelector": { + "type": "XPathSelector", + "value": "//*[@id='1eee408f-b3c2-47c1-913e-913e491a92ea']", + "refinedBy": {"type": "TextPositionSelector", "start": 0}, + }, + "endSelector": { + "type": "XPathSelector", + "value": "//*[@id='1eee408f-b3c2-47c1-913e-913e491a92ea']", + "refinedBy": {"type": "TextPositionSelector", "end": 4}, + }, + }, + }, + "@context": "http://www.w3.org/ns/anno.jsonld", + "id": "3ed8195a-bd4d-4cda-9f5b-e35f245db1e8", + }, + "tag": { + "type": "Annotation", + "body": [ + { + "purpose": "commenting", + "type": "TextualBody", + "value": "

circle with tag

", + "creator": {"id": "zaphod", "displayName": "Zaphod Beeblebrox"}, + } + ], + "target": { + "source": "https://readux-dev.org:3000/iiif/readux:st7r6/canvas/fedora:emory:5622", + "selector": { + "type": "SvgSelector", + "value": '', + "refinedBy": { + "type": "FragmentSelector", + "value": "xywh=2348.71875,2447.84521484375,608.3798828125,608.3798828125", + }, + }, + }, + "@context": "http://www.w3.org/ns/anno.jsonld", + "id": "5f642af1-b76e-49ff-aead-c4ca72673943", + }, } def setUp(self): @@ -176,22 +271,25 @@ def setUp(self): self.view = Annotations.as_view() # self.volume_list_view = VolumeList.as_view() self.crud_view = AnnotationCrud.as_view() - self.manifest = Manifest.objects.get(pk='464d82f6-6ae5-4503-9afc-8e3cdd92a3f1') + self.manifest = Manifest.objects.get(pk="464d82f6-6ae5-4503-9afc-8e3cdd92a3f1") self.canvas = self.manifest.canvas_set.all().first() self.collection = self.manifest.collections.first() def create_user_annotations(self, count, user): - for anno in range(count): + for _ in range(count): text_anno = UserAnnotation( - oa_annotation=json.loads(self.valid_mirador_annotations['text']['oa_annotation']), - owner=user + oa_annotation=json.loads( + self.valid_mirador_annotations["text"]["oa_annotation"] + ), + owner=user, ) + print(text_anno.canvas) text_anno.save() def load_anno(self, response): - annotation_list = json.loads(response.content.decode('UTF-8-sig')) - if 'resources' in annotation_list: - return annotation_list['resources'] + annotation_list = json.loads(response.content.decode("UTF-8-sig")) + if "resources" in annotation_list: + return annotation_list["resources"] else: return annotation_list @@ -199,184 +297,337 @@ def rando_anno(self): return UserAnnotation.objects.order_by("?").first() def test_get_user_annotations_unauthenticated(self): + """Test""" self.create_user_annotations(5, self.user_a) - kwargs = {'username': 'readux', 'volume': self.manifest.pid, 'canvas': self.canvas.pid} - url = reverse('user_annotations', kwargs=kwargs) + kwargs = { + "username": "readux", + "volume": self.manifest.pid, + "canvas": self.canvas.pid, + } + url = reverse("user_annotations", kwargs=kwargs) response = self.client.get(url) assert response.status_code == 404 - kwargs = {'username': self.user_a.username, 'volume': 'readux:st7r6', 'canvas': 'fedora:emory:5622'} - url = reverse('user_annotations', kwargs=kwargs) + kwargs = { + "username": self.user_a.username, + "volume": "readux:st7r6", + "canvas": "fedora:emory:5622", + } + url = reverse("user_annotations", kwargs=kwargs) response = self.client.get(url) - annotation = self.load_anno(response) + # annotation = self.load_anno(response) assert response.status_code == 401 -# assert len(annotation) == 0 + # assert len(annotation) == 0 + + def test_user_annotation_count_by_canvas(self): + """Test""" + self.create_user_annotations(5, self.user_a) + kwargs = {"username": self.user_a.username, "manifest": self.manifest.pid} + url = reverse("annotation_count_by_canvas", kwargs=kwargs) + request = self.factory.get(url) + request.user = self.user_a + view = AnnotationCountByCanvas.as_view() + response = view( + request, + username=self.user_a.username, + manifest=self.manifest.pid, + ) + response_data = json.loads(response.content.decode("UTF-8-sig")) + assert response_data[0]["count"] == 5 def test_mirador_svg_annotation_creation(self): - request = self.factory.post('/annotations-crud/', data=json.dumps(self.valid_mirador_annotations['svg']), content_type="application/json") + """Test""" + request = self.factory.post( + "/annotations-crud/", + data=json.dumps(self.valid_mirador_annotations["svg"]), + content_type="application/json", + ) request.user = self.user_a response = self.crud_view(request) annotation = self.load_anno(response) - assert annotation['annotatedBy']['name'] == 'Zaphod Beeblebrox' - assert annotation['on']['selector']['value'] == 'xywh=535,454,681,425' + assert annotation["body"][0]["creator"]["name"] == "Zaphod Beeblebrox" + # assert annotation['on']['selector']['value'] == 'xywh=535,454,681,425' assert response.status_code == 201 - annotation_object = UserAnnotation.objects.get(pk=annotation['@id']) + annotation_object = UserAnnotation.objects.get( + pk=annotation["id"].replace("#", "") + ) assert annotation_object.x == 535 assert annotation_object.y == 454 assert annotation_object.w == 681 assert annotation_object.h == 425 + def test_v3_svg_annotation_creation(self): + """Test""" + request = self.factory.post( + "/annotations-crud/", + data=json.dumps(self.valid_v3_annotation["svg"]), + content_type="application/json", + ) + request.user = self.user_a + response = self.crud_view(request) + annotation = self.load_anno(response) + assert annotation["body"][0]["creator"]["name"] == "Zaphod Beeblebrox" + # assert annotation['on']['selector']['value'] == 'xywh=535,454,681,425' + assert response.status_code == 201 + annotation_object = UserAnnotation.objects.get( + pk=annotation["id"].replace("#", "") + ) + assert annotation_object.x == 928 + assert annotation_object.y == 2576 + assert annotation_object.w == 583 + assert annotation_object.h == 510 def test_mirador_text_annotation_creation(self): - request = self.factory.post('/annotations-crud/', data=json.dumps(self.valid_mirador_annotations['text']), content_type="application/json") + """Test""" + request = self.factory.post( + "/annotations-crud/", + data=json.dumps(self.valid_mirador_annotations["text"]), + content_type="application/json", + ) request.user = self.user_a response = self.crud_view(request) annotation = self.load_anno(response) - assert annotation['annotatedBy']['name'] == 'Zaphod Beeblebrox' - assert annotation['on']['selector']['value'] == 'xywh=468,2844,479,83' - assert re.match(r"http.*iiif/v2/readux:st7r6/canvas/fedora:emory:5622", annotation['on']['full']) + assert annotation["body"][0]["creator"]["name"] == "Zaphod Beeblebrox" assert response.status_code == 201 + def test_v3_text_annotation_creation(self): + """Test""" + manifest = Manifest.objects.get(pid="readux:st7r6") + canvas = Canvas.objects.get(pid="fedora:emory:5622", manifest=manifest) + AnnotationFactory.create( + id="1eee408f-b3c2-47c1-913e-913e491a92ea", + order=4, + canvas=canvas, + x=587, + y=2687, + h=104, + w=252, + ) + request = self.factory.post( + "/annotations-crud/", + data=json.dumps(self.valid_v3_annotation["text"]), + content_type="application/json", + ) + request.user = self.user_a + response = self.crud_view(request) + annotation = self.load_anno(response) + assert annotation["body"][0]["creator"]["name"] == "Zaphod Beeblebrox" + assert ( + annotation["target"]["selector"]["refinedBy"]["value"] + == "xywh=pixel:587,2687,252,104" + ) + assert response.status_code == 201 + + def test_v3_annotation_creation_with_tags(self): + """Test""" + request = self.factory.post( + "/annotations-crud/", + data=json.dumps(self.valid_mirador_annotations["tag"]), + content_type="application/json", + ) + request.user = self.user_a + response = self.crud_view(request) + annotation = self.load_anno(response) + assert annotation["body"][0]["creator"]["name"] == "Zaphod Beeblebrox" + # assert annotation['on']['selector']['value'] == 'xywh=535,454,681,425' + assert response.status_code == 201 + annotation_object = UserAnnotation.objects.get( + pk=annotation["id"].replace("#", "") + ) + assert annotation_object.tags.count() == 2 + def test_creating_annotation_from_string(self): - request = self.factory.post('/annotations-crud/', data=self.valid_mirador_annotations['text'], content_type="application/json") + """Test""" + request = self.factory.post( + "/annotations-crud/", + data=self.valid_mirador_annotations["text"], + content_type="application/json", + ) request.user = self.user_a response = self.crud_view(request) annotation = self.load_anno(response) - assert annotation['annotatedBy']['name'] == 'Zaphod Beeblebrox' - assert annotation['on']['selector']['value'] == 'xywh=468,2844,479,83' - assert re.match(r"http.*iiif/v2/readux:st7r6/canvas/fedora:emory:5622", annotation['on']['full']) + assert annotation["body"][0]["creator"]["name"] == "Zaphod Beeblebrox" + # assert annotation['on']['selector']['value'] == 'xywh=468,2844,479,83' + # assert re.match(r"http.*iiif/v2/readux:st7r6/canvas/fedora:emory:5622", annotation['on']['full']) assert response.status_code == 201 def test_get_user_annotations(self): + """Test""" self.create_user_annotations(4, self.user_a) - kwargs = {'username': self.user_a.username, 'volume': self.manifest.pid, 'canvas': self.canvas.pid} - url = reverse('user_annotations', kwargs=kwargs) + kwargs = { + "username": self.user_a.username, + "volume": self.manifest.pid, + "canvas": self.canvas.pid, + } + url = reverse("user_annotations", kwargs=kwargs) request = self.factory.get(url) request.user = self.user_a - response = self.view(request, username=self.user_a.username, volume=self.manifest.pid, canvas=self.canvas.pid) + response = self.view( + request, + username=self.user_a.username, + volume=self.manifest.pid, + canvas=self.canvas.pid, + ) annotation = self.load_anno(response) assert len(annotation) == 4 assert response.status_code == 200 def test_get_only_users_user_annotations(self): + """Test""" self.create_user_annotations(5, self.user_b) self.create_user_annotations(4, self.user_a) - kwargs = {'username': 'marvin', 'volume': self.manifest.pid, 'canvas': self.canvas.pid} - url = reverse('user_annotations', kwargs=kwargs) + kwargs = { + "username": "marvin", + "volume": self.manifest.pid, + "canvas": self.canvas.pid, + } + url = reverse("user_annotations", kwargs=kwargs) request = self.factory.get(url) request.user = self.user_b - response = self.view(request, username=self.user_b.username, volume=self.manifest.pid, canvas=self.canvas.pid) + response = self.view( + request, + username=self.user_b.username, + volume=self.manifest.pid, + canvas=self.canvas.pid, + ) annotation = self.load_anno(response) assert len(annotation) == 5 assert response.status_code == 200 assert len(UserAnnotation.objects.all()) == 9 - kwargs = {'username': self.user_a.username, 'volume': 'readux:st7r6', 'canvas': 'fedora:emory:5622'} - url = reverse('user_annotations', kwargs=kwargs) + kwargs = { + "username": self.user_a.username, + "volume": "readux:st7r6", + "canvas": "fedora:emory:5622", + } + url = reverse("user_annotations", kwargs=kwargs) response = self.client.get(url) annotation = self.load_anno(response) assert response.status_code == 401 -# assert len(annotation) == 0 + + # assert len(annotation) == 0 def test_update_user_annotation(self): + """Test""" self.create_user_annotations(1, self.user_a) existing_anno = UserAnnotation.objects.all()[0] - data = json.loads(self.valid_mirador_annotations['svg']['oa_annotation']) - data['@id'] = str(existing_anno.id) - data = { 'oa_annotation': data } - resource = data['oa_annotation']['resource'][0] - resource['chars'] = 'updated annotation' - data['oa_annotation']['resource'] = resource - data['id'] = str(existing_anno.id) - request = self.factory.put('/annotations-crud/', data=json.dumps(data), content_type="application/json") + data = json.loads(self.valid_mirador_annotations["svg"]["oa_annotation"]) + data["@id"] = str(existing_anno.id) + data = {"oa_annotation": data} + resource = data["oa_annotation"]["resource"][0] + resource["chars"] = "updated annotation" + data["oa_annotation"]["resource"] = resource + data["id"] = str(existing_anno.id) + request = self.factory.put( + "/annotations-crud/", data=json.dumps(data), content_type="application/json" + ) request.user = self.user_a response = self.crud_view(request) annotation = self.load_anno(response) assert response.status_code == 200 - assert annotation['resource']['chars'] == 'updated annotation' + assert annotation["body"][0]["value"] == "updated annotation" def test_update_non_existing_user_annotation(self): + """Test""" self.create_user_annotations(1, self.user_a) - data = json.loads(self.valid_mirador_annotations['svg']['oa_annotation']) + data = json.loads(self.valid_mirador_annotations["svg"]["oa_annotation"]) new_id = str(uuid.uuid4()) - data['@id'] = new_id - data['id'] = new_id - request = self.factory.put('/annotations-crud/', data=json.dumps(data), content_type="application/json") + data["@id"] = new_id + data["id"] = new_id + request = self.factory.put( + "/annotations-crud/", data=json.dumps(data), content_type="application/json" + ) request.user = self.user_a response = self.crud_view(request) - annotation = self.load_anno(response) assert response.status_code == 404 def test_update_someone_elses_annotation(self): + """Test""" self.create_user_annotations(4, self.user_a) rando_anno = self.rando_anno() - data = {'id': str(rando_anno.pk)} - request = self.factory.put('/annotations-crud/', data=json.dumps(data), content_type="application/json") + data = {"id": str(rando_anno.pk)} + request = self.factory.put( + "/annotations-crud/", data=json.dumps(data), content_type="application/json" + ) request.user = self.user_b response = self.crud_view(request) - annotation = self.load_anno(response) assert response.status_code == 401 def test_updating_annotation_unauthenticated(self): + """Test""" self.create_user_annotations(1, self.user_a) existing_anno = UserAnnotation.objects.all()[0] - data = json.loads(self.valid_mirador_annotations['svg']['oa_annotation']) - data['@id'] = str(existing_anno.id) - data = {'oa_annotation': data} - resource = data['oa_annotation']['resource'][0] - data['oa_annotation']['resource'] = resource - data['id'] = str(existing_anno.id) - request = self.factory.put('/annotations-crud/', data=json.dumps(data), content_type="application/json") + data = json.loads(self.valid_mirador_annotations["svg"]["oa_annotation"]) + data["@id"] = str(existing_anno.id) + data = {"oa_annotation": data} + resource = data["oa_annotation"]["resource"][0] + data["oa_annotation"]["resource"] = resource + data["id"] = str(existing_anno.id) + request = self.factory.put( + "/annotations-crud/", data=json.dumps(data), content_type="application/json" + ) response = self.crud_view(request) message = self.load_anno(response) assert response.status_code == 401 - assert message['message'] == 'You are not the owner of this annotation.' + assert message["message"] == "You are not the owner of this annotation." def test_delete_user_annotation_as_owner(self): + """Test""" self.create_user_annotations(1, self.user_a) - data = {'id': str(uuid.uuid4())} - request = self.factory.delete('/annotations-crud/', data=json.dumps(data), content_type="application/json") + data = {"id": str(uuid.uuid4())} + request = self.factory.delete( + "/annotations-crud/", data=json.dumps(data), content_type="application/json" + ) request.user = self.user_a response = self.crud_view(request) assert response.status_code == 404 def test_delete_non_existant_user_annotation(self): + """Test""" self.create_user_annotations(1, self.user_a) existing_anno = UserAnnotation.objects.all()[0] - data = {'id': str(existing_anno.pk)} - request = self.factory.delete('/annotations-crud/', data=json.dumps(data), content_type="application/json") + data = {"id": str(existing_anno.pk)} + request = self.factory.delete( + "/annotations-crud/", data=json.dumps(data), content_type="application/json" + ) request.user = self.user_a response = self.crud_view(request) - message = self.load_anno(response) assert response.status_code == 204 assert len(UserAnnotation.objects.all()) == 0 def test_delete_someone_elses_annotation(self): + """Test""" self.create_user_annotations(1, self.user_a) rando_anno = self.rando_anno() - data = {'id': str(rando_anno.pk)} - request = self.factory.delete('/annotations-crud/', data=json.dumps(data), content_type="application/json") + data = {"id": str(rando_anno.pk)} + request = self.factory.delete( + "/annotations-crud/", data=json.dumps(data), content_type="application/json" + ) request.user = self.user_b response = self.crud_view(request) message = self.load_anno(response) assert response.status_code == 401 - assert message['message'] == 'You are not the owner of this annotation.' + assert message["message"] == "You are not the owner of this annotation." def test_delete_annotation_unauthenticated(self): + """Test""" self.create_user_annotations(1, self.user_a) rando_anno = self.rando_anno() - data = {'id': str(rando_anno.pk)} - request = self.factory.delete('/annotations-crud/', data=json.dumps(data), content_type="application/json") + data = {"id": str(rando_anno.pk)} + request = self.factory.delete( + "/annotations-crud/", data=json.dumps(data), content_type="application/json" + ) response = self.crud_view(request) message = self.load_anno(response) assert response.status_code == 401 - assert message['message'] == 'You are not the owner of this annotation.' + assert message["message"] == "You are not the owner of this annotation." def test_user_annotations_on_canvas(self): + """Test""" # fetch a manifest with no user annotations - kwargs = {'manifest': self.manifest.pid, 'pid': self.canvas.pid} - url = reverse('RenderCanvasDetail', kwargs=kwargs) + kwargs = {"manifest": self.manifest.pid, "pid": self.canvas.pid} + url = reverse("RenderCanvasDetail", kwargs=kwargs) response = self.client.get(url, data=kwargs) - serialized_canvas = json.loads(response.content.decode('UTF-8-sig')) - assert len(serialized_canvas['otherContent']) == 1 + serialized_canvas = json.loads(response.content.decode("UTF-8-sig")) + assert len(serialized_canvas["otherContent"]) == 1 # add annotations to the manifest self.create_user_annotations(1, self.user_a) @@ -388,214 +639,306 @@ def test_user_annotations_on_canvas(self): # fetch a manifest with annotations by two users response = self.client.get(url) - serialized_canvas = json.loads(response.content.decode('UTF-8-sig')) + serialized_canvas = json.loads(response.content.decode("UTF-8-sig")) assert response.status_code == 200 - assert serialized_canvas['@id'] == self.canvas.identifier - assert serialized_canvas['label'] == str(self.canvas.position) - assert len(serialized_canvas['otherContent']) == 1 + assert serialized_canvas["@id"] == self.canvas.identifier + assert serialized_canvas["label"] == str(self.canvas.position) + assert len(serialized_canvas["otherContent"]) == 1 + + def test_collection_detail_view_sort_and_order(self): + """Test""" + descrizione = self.collection.manifests.first() + vol2 = Manifest.objects.create( + pid="test1", author="xyz", label="zyx", published_date_edtf="1000" + ) + self.collection.manifests.add(vol2) + url = reverse("collection", kwargs={"collection": self.collection.pid}) - def test_volume_list_view_no_kwargs(self): - response = self.client.get(reverse('volumes list')) + # test all different sort options: title + kwargs = {"sort": "title", "order": "asc"} + response = self.client.get(url, data=kwargs) + context = response.context_data + assert context["volumes"][0].pid == descrizione.pid + kwargs = {"sort": "title", "order": "desc"} + response = self.client.get(url, data=kwargs) context = response.context_data - assert context['order_url_params'] == urlencode({'sort': 'title', 'order': 'asc'}) - assert context['object_list'].count() == Manifest.objects.all().count() + assert context["volumes"][0].pid == vol2.pid - def test_volume_list_invalid_kwargs(self): - kwargs = {'blueberry': 'pizza', 'jay': 'awesome'} - response = self.client.get(reverse('volumes list'), data=kwargs) + # author + kwargs = {"sort": "author", "order": "asc"} + response = self.client.get(url, data=kwargs) context = response.context_data - assert context['order_url_params'] == urlencode({'sort': 'title', 'order': 'asc'}) - assert context['object_list'].count() == Manifest.objects.all().count() - - def test_volumes_list_view_sort_and_order(self): - view = VolumesList() - for sort in view.SORT_OPTIONS: - for order in view.ORDER_OPTIONS: - kwargs = {'sort': sort, 'order': order} - url = reverse('volumes list') - response = self.client.get(url, data=kwargs) - context = response.context_data - assert context['order_url_params'] == urlencode({'sort': sort, 'order': order}) - assert context['object_list'].count() == Manifest.objects.all().count() - assert view.get_queryset().ordered - - def test_collection_detail_view_no_kwargs(self): - response = self.client.get(reverse('volumes list')) + assert context["volumes"][0].pid == descrizione.pid + kwargs = {"sort": "author", "order": "desc"} + response = self.client.get(url, data=kwargs) context = response.context_data - assert context['order_url_params'] == urlencode({'sort': 'title', 'order': 'asc'}) - assert context['object_list'].count() == Manifest.objects.all().count() + assert context["volumes"][0].pid == vol2.pid - def test_collection_detail_invalid_kwargs(self): - kwargs = {'blueberry': 'pizza', 'jay': 'awesome'} - response = self.client.get(reverse('volumes list'), data=kwargs) + # edtf date published + kwargs = {"sort": "date", "order": "asc"} + response = self.client.get(url, data=kwargs) context = response.context_data - assert context['order_url_params'] == urlencode({'sort': 'title', 'order': 'asc'}) - assert context['object_list'].count() == Manifest.objects.all().count() + assert context["volumes"][0].pid == vol2.pid + # the other one doesn't have an edtf published date, so it should be sorted last + # (nulls last) and therefore vol2 should still be first + kwargs = {"sort": "date", "order": "desc"} + response = self.client.get(url, data=kwargs) + context = response.context_data + assert context["volumes"][0].pid == vol2.pid - # TODO are the volumes actually sorted? - def test_collection_detail_view_sort_and_order(self): - view = CollectionDetail() - for sort in view.SORT_OPTIONS: - for order in view.ORDER_OPTIONS: - kwargs = {'sort': sort, 'order': order } - url = reverse('collection', kwargs={ 'collection': self.collection.pid }) - response = self.client.get(url, data=kwargs) - context = response.context_data - assert context['sort'] == sort - assert context['order'] == order - assert context['order_url_params'] == urlencode({'sort': sort, 'order': order}) - assert context['manifest_query_set'].ordered + # date added + kwargs = {"sort": "added", "order": "asc"} + response = self.client.get(url, data=kwargs) + context = response.context_data + assert context["volumes"][0].pid == descrizione.pid + kwargs = {"sort": "added", "order": "desc"} + response = self.client.get(url, data=kwargs) + context = response.context_data + assert context["volumes"][0].pid == vol2.pid def test_collection_detail_view_with_no_sort_or_order_specified(self): - url = reverse('collection', kwargs={ 'collection': self.collection.pid }) + """Test""" + descrizione = self.collection.manifests.first() + vol2 = Manifest.objects.create( + pid="test1", author="xyz", label="zyx", published_date_edtf="1000" + ) + self.collection.manifests.add(vol2) + url = reverse("collection", kwargs={"collection": self.collection.pid}) response = self.client.get(url) context = response.context_data - assert context['sort'] == 'title' - assert context['order'] == 'asc' - assert context['manifest_query_set'].ordered + # should order by title, asc + assert context["volumes"][0].pid == descrizione.pid + assert context["volumes"][1].pid == vol2.pid def test_volume_detail_view(self): - url = reverse('volume', kwargs={'volume': self.manifest.pid}) + """Test""" + url = reverse("volume", kwargs={"volume": self.manifest.pid}) response = self.client.get(url) - assert response.context_data['volume'] == self.manifest + assert response.context_data["volume"] == self.manifest def test_export_options_view(self): - kwargs = {'volume': self.manifest.pid} - url = reverse('export', kwargs=kwargs) + """Test""" + kwargs = {"volume": self.manifest.pid} + url = reverse("export", kwargs=kwargs) request = self.factory.get(url) request.user = self.user_a - response = ExportOptions.as_view()(request, username=self.user_a.username, volume=self.manifest.pid) + response = ExportOptions.as_view()( + request, username=self.user_a.username, volume=self.manifest.pid + ) assert response.status_code == 200 def test_motivation_is_commeting_by_default(self): + """Test""" self.create_user_annotations(1, self.user_a) anno = UserAnnotation.objects.all().first() - assert anno.motivation == 'oa:commenting' + assert anno.motivation == "oa:commenting" def test_style_attribute_adds_id_to_class_selector(self): + """Test""" self.create_user_annotations(1, self.user_a) anno = UserAnnotation.objects.all().first() assert str(anno.id) in anno.style def test_style_attribute_is_valid_css(self): + """Test""" self.create_user_annotations(1, self.user_a) anno = UserAnnotation.objects.all().first() style = parseString(anno.style) assert style.cssRules[0].valid def test_stylesheet_is_serialized(self): + """Test""" self.create_user_annotations(1, self.user_a) - kwargs = {'username': self.user_a.username, 'volume': self.manifest.pid, 'canvas': self.canvas.pid} - url = reverse('user_annotations', kwargs=kwargs) + kwargs = { + "username": self.user_a.username, + "volume": self.manifest.pid, + "canvas": self.canvas.pid, + } + url = reverse("user_annotations", kwargs=kwargs) request = self.factory.get(url) request.user = self.user_a - response = self.view(request, username=self.user_a.username, volume=self.manifest.pid, canvas=self.canvas.pid) + response = self.view( + request, + username=self.user_a.username, + volume=self.manifest.pid, + canvas=self.canvas.pid, + ) annotation = self.load_anno(response)[0] - assert 'stylesheet' in annotation - assert 'value' in annotation['stylesheet'] - assert 'type' in annotation['stylesheet'] - assert annotation['@id'] in annotation['stylesheet']['value'] - assert annotation['stylesheet']['type'] == 'CssStylesheet' + assert "stylesheet" in annotation + assert "value" in annotation["stylesheet"] + assert "type" in annotation["stylesheet"] + assert annotation["@id"] in annotation["stylesheet"]["value"] + assert annotation["stylesheet"]["type"] == "CssStylesheet" def test_annotation_creation_with_tags(self): + """Test""" self.create_user_annotations(1, self.user_a) anno = UserAnnotation.objects.all().first() - anno.oa_annotation = json.loads(self.valid_mirador_annotations['tag']['oa_annotation']) + anno.oa_annotation = json.loads( + self.valid_mirador_annotations["tag"]["oa_annotation"] + ) anno.save() assert anno.tags.exists() assert anno.tags.count() == 2 assert anno.motivation == UserAnnotation.TAGGING - assert anno.content == '

mcoewmewom

' - assert 'tag' in anno.tag_list - assert 'other tag' in anno.tag_list + assert anno.content == "

mcoewmewom

" + assert "tag" in anno.tag_list + assert "other tag" in anno.tag_list def test_updating_annotation_with_tags(self): + """Test""" self.create_user_annotations(1, self.user_a) anno = UserAnnotation.objects.all().first() - anno.oa_annotation = json.loads(self.valid_mirador_annotations['tag']['oa_annotation']) + anno.oa_annotation = json.loads( + self.valid_mirador_annotations["tag"]["oa_annotation"] + ) anno.save() - anno.oa_annotation = json.loads(serialize('annotation', [anno])) + anno.oa_annotation = json.loads(serialize("annotation", [anno])) anno.save() anno = UserAnnotation.objects.get(pk=anno.pk) assert anno.tags.count() == 2 def test_deleting_tags(self): + """Test""" self.create_user_annotations(1, self.user_a) anno = UserAnnotation.objects.all().first() - anno.oa_annotation = json.loads(self.valid_mirador_annotations['tag']['oa_annotation']) + anno.oa_annotation = json.loads( + self.valid_mirador_annotations["tag"]["oa_annotation"] + ) anno.save() assert anno.tags.count() == 2 - assert len(anno.oa_annotation['resource']) == 3 + assert len(anno.oa_annotation["resource"]) == 3 # Remove one tag - for index, resource in enumerate(anno.oa_annotation['resource']): - if resource['@type'] == 'oa:Tag': - del anno.oa_annotation['resource'][index] + for index, resource in enumerate(anno.oa_annotation["resource"]): + if resource["@type"] == "oa:Tag": + del anno.oa_annotation["resource"][index] break anno.save() - serialized_anno = json.loads(serialize('annotation', [anno])) - assert isinstance(serialized_anno['resource'], list) + serialized_anno = json.loads(serialize("annotation", [anno])) + assert isinstance(serialized_anno["resource"], list) assert anno.tags.count() == 1 assert anno.motivation == UserAnnotation.TAGGING # Remove any remaining tags. - for index, resource in enumerate(anno.oa_annotation['resource']): - if resource['@type'] == 'oa:Tag': - del anno.oa_annotation['resource'][index] + for index, resource in enumerate(anno.oa_annotation["resource"]): + if resource["@type"] == "oa:Tag": + del anno.oa_annotation["resource"][index] # anno.oa_annotation['resource'] = [anno.oa_annotation['resource'][0]] anno.save() - serialized_anno = json.loads(serialize('annotation', [anno])) - assert isinstance(serialized_anno['resource'], dict) - assert isinstance(serialized_anno['motivation'], str) + serialized_anno = json.loads(serialize("annotation", [anno])) + assert isinstance(serialized_anno["resource"], dict) + assert isinstance(serialized_anno["motivation"], str) assert anno.tags.count() == 0 assert anno.tag_list == [] assert anno.motivation == UserAnnotation.COMMENTING def test_annotation_serialization_with_tags(self): + """Test""" self.create_user_annotations(1, self.user_a) anno = UserAnnotation.objects.all().first() - anno.oa_annotation = json.loads(self.valid_mirador_annotations['tag']['oa_annotation']) + anno.oa_annotation = json.loads( + self.valid_mirador_annotations["tag"]["oa_annotation"] + ) anno.save() - kwargs = {'username': self.user_a.username, 'volume': anno.canvas.manifest.pid, 'canvas': anno.canvas.pid} - url = reverse('user_annotations', kwargs=kwargs) + kwargs = { + "username": self.user_a.username, + "volume": anno.canvas.manifest.pid, + "canvas": anno.canvas.pid, + } + url = reverse("user_annotations", kwargs=kwargs) request = self.factory.get(url) request.user = self.user_a - response = self.view(request, username=self.user_a.username, volume=self.manifest.pid, canvas=self.canvas.pid) + response = self.view( + request, + username=self.user_a.username, + volume=self.manifest.pid, + canvas=self.canvas.pid, + ) annotation = self.load_anno(response)[0] - assert isinstance(annotation['resource'], list) - assert isinstance(annotation['motivation'], list) - assert 'oa:tagging' in annotation['motivation'] - assert 'oa:commenting' in annotation['motivation'] + assert isinstance(annotation["resource"], list) + assert isinstance(annotation["motivation"], list) + assert "oa:tagging" in annotation["motivation"] + assert "oa:commenting" in annotation["motivation"] def test_item_none(self): + """Test""" anno = UserAnnotation() assert anno.item is None def test_parse_mirador_anno_from_string(self): - anno = UserAnnotation(oa_annotation=self.valid_mirador_annotations['text']['oa_annotation']) + """Test""" + anno = UserAnnotation( + oa_annotation=self.valid_mirador_annotations["text"]["oa_annotation"] + ) anno.save() - assert anno.content == '

mcoewmewom

' + assert anno.content == "

mcoewmewom

" def test_parse_mirador_anno_when_on_is_dict(self): - oa_annotation = json.loads(self.valid_mirador_annotations['tag']['oa_annotation']) - oa_annotation['on'] = oa_annotation['on'][0] - assert isinstance(oa_annotation['on'], dict) + """Test""" + oa_annotation = json.loads( + self.valid_mirador_annotations["tag"]["oa_annotation"] + ) + oa_annotation["on"] = oa_annotation["on"][0] + assert isinstance(oa_annotation["on"], dict) anno = UserAnnotation(oa_annotation=oa_annotation) anno.save() - assert anno.content == '

mcoewmewom

' + assert anno.content == "

mcoewmewom

" def test_user_annotation_count(self): + """Test""" self.create_user_annotations(3, self.user_a) - kwargs = {'volume': self.manifest.pid, 'page': self.canvas.pid} - url = reverse('_anno_count', kwargs=kwargs) + kwargs = {"volume": self.manifest.pid, "page": self.canvas.pid} + url = reverse("_anno_count", kwargs=kwargs) request = self.factory.get(url) request.user = self.user_a - response = AnnotationsCount.as_view()( + response = AnnotationCount.as_view()( request, volume=self.manifest.pid, page=self.canvas.pid ) - assert response.context_data['volume'] == self.manifest - assert response.context_data['page'] == self.canvas - assert response.context_data['user_annotation_page_count'] == 3 - assert response.context_data['user_annotation_count'] == 3 + assert response.context_data["volume"] == self.manifest + assert response.context_data["page"] == self.canvas + assert response.context_data["user_annotation_page_count"] == 3 + assert response.context_data["user_annotation_count"] == 3 def test_current_version_context(self): - assert readux.__version__ == current_version()['APP_VERSION'] + """It should return the current version.""" + assert readux.__version__ == current_version()["APP_VERSION"] + + def test_text_anno_dimensions(self): + """It should set dimensions for text annotation.""" + ocr_user = UserFactory.create(username="ocr", name="OCR") + manifest = ManifestFactory.create() + manifest.canvas_set.all().delete() + canvas = CanvasFactory.create(manifest=manifest) + canvas.annotation_set.all().delete() + start = AnnotationFactory( + canvas=canvas, + x=4, + y=8, + h=10, + w=20, + order=4, + content="Comrade", + owner=ocr_user, + ) + end = AnnotationFactory( + canvas=canvas, + x=4, + y=28, + h=10, + w=40, + order=6, + content="Goldman", + owner=ocr_user, + ) + AnnotationFactory(canvas=canvas, x=24, y=5, h=14, w=12, order=5, content="Emma") + assert canvas.annotation_set.count() == 3 + ua = UserAnnotationFactory.create( + canvas=canvas, + primary_selector="RG", + start_selector=start, + end_selector=end, + ) + assert ua.x == 4 + assert ua.y == 5 + assert ua.h == 33 + assert ua.w == 32 diff --git a/apps/readux/urls.py b/apps/readux/urls.py index 706543d2b..3fbdcb056 100644 --- a/apps/readux/urls.py +++ b/apps/readux/urls.py @@ -1,36 +1,63 @@ """URL patterns for the Readux app""" + from django.urls import path +from django.contrib.staticfiles.urls import staticfiles_urlpatterns from . import views, annotations from .search import SearchManifestCanvas urlpatterns = [ - path('collection/', views.CollectionsList.as_view(), name='collections list'), - path('volume/', views.VolumesList.as_view(), name='volumes list'), - path('collection//', views.CollectionDetail.as_view(), name="collection"), - path('volume/', views.VolumeDetail.as_view(), name='volume'), - path('volume//page/all', views.PageDetail.as_view(), name='volumeall'), + path( + "collection//", views.CollectionDetail.as_view(), name="collection" + ), + path("volume/", views.VolumeDetail.as_view(), name="volume"), + path("volume//page/all", views.PageDetail.as_view(), name="volumeall"), # url for page altered to prevent conflict with Wagtail # TODO: find another way to resolve this conflict - path('volume//page/', views.PageDetail.as_view(), name='page'), - path('volume//export', views.ExportOptions.as_view(), name='export'), + path("volume//page/", views.PageDetail.as_view(), name="page"), + path("volume//export", views.ExportOptions.as_view(), name="export"), path( - 'volume///export_download', + "volume///export_download", views.ExportDownload.as_view(), - name='export_download' + name="export_download", ), path( - 'volume//export_download_zip', + "volume//export_download_zip", views.ExportDownloadZip.as_view(), - name='export_download_zip' + name="export_download_zip", + ), + path( + "annotations/", annotations.Annotations.as_view(), name="post_user_annotations" + ), + path( + "annotations///list/", + annotations.Annotations.as_view(), + name="user_annotations", + ), + # path( + # '/comments//', + # annotations.WebAnnotations.as_view(), + # name='user_comments' + # ), + path( + "annotations-crud/", + annotations.AnnotationCrud.as_view(), + name="crud_user_annotation", ), - path('annotations/', annotations.Annotations.as_view(), name='post_user_annotations'), + path("search/", views.VolumeSearchView.as_view(), name="search"), path( - 'annotations///list/', + "_anno_count//", + views.AnnotationCount.as_view(), + name="_anno_count", + ), + path("search/volume/pages", SearchManifestCanvas.as_view(), name="search_pages"), + path( + "iiif///annotations//", annotations.Annotations.as_view(), - name='user_annotations' + name="user_comments", + ), + path( + "annotation_count//", + annotations.AnnotationCountByCanvas.as_view(), + name="annotation_count_by_canvas", ), - path('annotations-crud/', annotations.AnnotationCrud.as_view(), name='crud_user_annotation'), - path('search/', views.VolumeSearchView.as_view(), name='search'), - path('_anno_count//', views.AnnotationsCount.as_view(), name='_anno_count'), - path('search/volume/pages', SearchManifestCanvas.as_view(), name='search_pages'), ] diff --git a/apps/readux/views.py b/apps/readux/views.py index 43bd8405e..b7ed06d84 100644 --- a/apps/readux/views.py +++ b/apps/readux/views.py @@ -1,18 +1,21 @@ """Django Views for the Readux app""" + +import re from os import path from urllib.parse import urlencode from django.http import HttpResponse from django.views.generic import ListView from django.views.generic.base import TemplateView, View +from django.views.generic.detail import DetailView from django.views.generic.edit import FormMixin from django.contrib.sitemaps import Sitemap -from django.db.models import Max, Count +from django.db.models import Max, Count, F from django.urls import reverse from elasticsearch_dsl import Q, NestedFacet, TermsFacet from elasticsearch_dsl.query import MultiMatch -from apps.iiif.manifests.documents import ManifestDocument -from apps.readux.forms import ManifestSearchForm import config.settings.local as settings +from apps.iiif.manifests.documents import ManifestDocument +from apps.readux.forms import AllVolumesForm, ManifestSearchForm from apps.export.export import JekyllSiteExport from apps.export.forms import JekyllExportForm from .models import UserAnnotation @@ -20,251 +23,246 @@ from ..iiif.kollections.models import Collection from ..iiif.canvases.models import Canvas from ..iiif.manifests.models import Manifest +from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator -SORT_OPTIONS = ['title', 'author', 'date published', 'date added'] -ORDER_OPTIONS = ['asc', 'desc'] +SORT_OPTIONS = ["title", "author", "date published", "date added"] +ORDER_OPTIONS = ["asc", "desc"] -class CollectionsList(ListView): - """Django List View for :class:`apps.iiif.kollections.models.Collection`s""" - template_name = "collections.html" - context_object_name = 'collections' - queryset = Collection.objects.all() +class CollectionDetail(DetailView, FormMixin): + """Django Template View for a :class:`apps.iiif.kollections.models.Collection`""" -class VolumesList(ListView): - """Django List View for :class:`apps.iiif.manifests.models.Manifest`s""" - template_name = "volumes.html" - SORT_OPTIONS = ['title', 'author', 'date published', 'date added'] - ORDER_OPTIONS = ['asc', 'desc'] - context_object_name = 'volumes' + template_name = "collection.html" + slug_field = "pid" + slug_url_kwarg = "collection" + form_class = AllVolumesForm + model = Collection + initial = {"sort": "title", "order": "asc", "display": "grid", "per_page": "60"} + sort_fields = { + "title": "label", + "author": "author", + "date": "date_sort_ascending", + "added": "created_at", + } - def get_queryset(self): - return Manifest.objects.all() + def get_form_kwargs(self): + """get form arguments from request and configured defaults""" + kwargs = super().get_form_kwargs() - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - sort = self.request.GET.get('sort', None) - order = self.request.GET.get('order', None) - - q = self.get_queryset() - - if sort not in SORT_OPTIONS: - sort = 'title' - if order not in ORDER_OPTIONS: - order = 'asc' - - if sort == 'title': - if order == 'asc': - q = q.order_by('label') - elif order == 'desc': - q = q.order_by('-label') - elif sort == 'author': - if order == 'asc': - q = q.order_by('author') - elif order == 'desc': - q = q.order_by('-author') - elif sort == 'date published': - if order == 'asc': - q = q.order_by('published_date') - elif order == 'desc': - q = q.order_by('-published_date') - elif sort == 'date added': - if order == 'asc': - q = q.order_by('created_at') - elif order == 'desc': - q = q.order_by('-created_at') - - sort_url_params = {'sort': sort, 'order': order} - order_url_params = {'sort': sort, 'order': order} - if 'sort' in sort_url_params: - del sort_url_params['sort'] - - context['volumes'] = q.all - context.update({ - 'sort_url_params': urlencode(sort_url_params), - 'order_url_params': urlencode(order_url_params), - 'sort': sort, 'SORT_OPTIONS': SORT_OPTIONS, - 'order': order, 'ORDER_OPTIONS': ORDER_OPTIONS, - }) - return context + # use GET instead of default POST/PUT for form data + form_data = self.request.GET.copy() -class CollectionDetail(ListView): - """Django Template View for a :class:`apps.iiif.kollections.models.Collection`""" - template_name = "collection.html" - SORT_OPTIONS = ['title', 'author', 'date published', 'date added'] - ORDER_OPTIONS = ['asc', 'desc'] - paginate_by = 10 + # set all form values to default + for key, val in self.initial.items(): + form_data.setdefault(key, val) - def get_queryset(self): - sort = self.request.GET.get('sort', None) - order = self.request.GET.get('order', None) - q = Collection.objects.filter(pid=self.kwargs['collection']).first().manifests.all() - - if sort is None: - sort = 'title' - if order is None: - order = 'asc' - - if sort == 'title': - if order == 'asc': - q = q.order_by('label') - elif order == 'desc': - q = q.order_by('-label') - elif sort == 'author': - if order == 'asc': - q = q.order_by('author') - elif order == 'desc': - q = q.order_by('-author') - elif sort == 'date published': - if order == 'asc': - q = q.order_by('published_date') - elif order == 'desc': - q = q.order_by('-published_date') - elif sort == 'date added': - if order == 'asc': - q = q.order_by('created_at') - elif order == 'desc': - q = q.order_by('-created_at') - - return q + kwargs["data"] = form_data + + return kwargs + + def get_volumes(self, form=None): + """Get the sorted set of volumes to display""" + form = form or self.get_form() + collection = self.get_object() + queryset = collection.manifests.all() + + # return empty queryset if not valid + if not form.is_valid(): + return queryset.none() + + # get sort and order selections from form + search_opts = form.cleaned_data + sort = search_opts.get("sort", "title") + if sort not in self.sort_fields: + sort = "title" + order = search_opts.get("order", "asc") + sign = "-" if order == "desc" else "" + + # build order_by query to sort results + if sort == "date" and order == "desc": + # special case for date, descending: need to use date_sort_descending field + # and sort nulls last + queryset = queryset.order_by( + F("date_sort_descending").desc(nulls_last=True) + ) + else: + queryset = queryset.order_by(f"{sign}{self.sort_fields[sort]}") + + return queryset def get_context_data(self, **kwargs): + """Context function.""" context = super().get_context_data(**kwargs) - sort = self.request.GET.get('sort', None) - order = self.request.GET.get('order', None) - - q = Collection.objects.filter(pid=self.kwargs['collection']).first().manifests - - if sort is None: - sort = 'title' - if order is None: - order = 'asc' - - if sort == 'title': - if order == 'asc': - q = q.order_by('label') - elif order == 'desc': - q = q.order_by('-label') - elif sort == 'author': - if order == 'asc': - q = q.order_by('author') - elif order == 'desc': - q = q.order_by('-author') - elif sort == 'date published': - if order == 'asc': - q = q.order_by('published_date') - elif order == 'desc': - q = q.order_by('-published_date') - elif sort == 'date added': - if order == 'asc': - q = q.order_by('created_at') - elif order == 'desc': - q = q.order_by('-created_at') - - sort_url_params = self.request.GET.copy() - order_url_params = self.request.GET.copy() -# if 'sort' in sort_url_params: -# del sort_url_params['sort'] - - context['collectionlink'] = Page.objects.type(CollectionsPage).first() - context['collection'] = Collection.objects.filter(pid=self.kwargs['collection']).first() - context['volumes'] = q.all - context['manifest_query_set'] = q - context['user_annotation'] = UserAnnotation.objects.filter(owner_id=self.request.user.id) - value = 0 - context['value'] = value - context.update({ - 'sort_url_params': urlencode(sort_url_params), - 'order_url_params': urlencode(order_url_params), - 'sort': sort, 'SORT_OPTIONS': SORT_OPTIONS, - 'order': order, 'ORDER_OPTIONS': ORDER_OPTIONS, - }) + form = self.get_form() + volumes = self.get_volumes(form=form) + + per_page_default = int(self.initial.get("per_page", 60)) + per_page = per_page_default + if form.is_valid(): + try: + per_page = int(form.cleaned_data.get("per_page") or per_page_default) + except (TypeError, ValueError): + per_page = per_page_default + + # add paginator manually since this isn't a ListView + paginator = Paginator(volumes, per_page) + + page = self.request.GET.get("page", 1) + try: + volumes = paginator.page(page) + except PageNotAnInteger: + # If page is not an integer, deliver first page. + volumes = paginator.page(1) + except EmptyPage: + # If page is out of range (e.g. 9999), deliver last page of results. + page = paginator.num_pages + volumes = paginator.page(page) + + context.update( + { + "volumes": volumes, + "form": form, + "user_annotation": UserAnnotation.objects.filter( + owner_id=self.request.user.id + ), + "paginator_range": paginator.get_elided_page_range( + page, on_each_side=2 + ), + "collectionlink": Page.objects.type(CollectionsPage).first(), + } + ) + return context + class VolumeDetail(TemplateView): """Django Template View for :class:`apps.iiif.manifest.models.Manifest`""" + template_name = "volume.html" def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - context['volume'] = Manifest.objects.filter(pid=kwargs['volume']).first() + context["volume"] = Manifest.objects.filter(pid=kwargs["volume"]).first() return context + # FIXME: What is this used for? The template does not exist. -class AnnotationsCount(TemplateView): +class AnnotationCount(TemplateView): """Django Template View for :class:`apps.readux.models.UserAnnotation`""" + template_name = "count.html" def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - canvas = Canvas.objects.filter(pid=kwargs['page']).first() - context['page'] = canvas - manifest = Manifest.objects.filter(pid=kwargs['volume']).first() - context['volume'] = manifest - context['user_annotation_page_count'] = UserAnnotation.objects.filter( - owner_id=self.request.user.id - ).filter( - canvas__id=canvas.id - ).count() - context['user_annotation_count'] = UserAnnotation.objects.filter( - owner_id=self.request.user.id - ).filter( - canvas__manifest__id=manifest.id - ).count() + canvas = Canvas.objects.filter(pid=kwargs["page"]).first() + context["page"] = canvas + manifest = Manifest.objects.filter(pid=kwargs["volume"]).first() + context["volume"] = manifest + context["user_annotation_page_count"] = ( + UserAnnotation.objects.filter(owner_id=self.request.user.id) + .filter(canvas__id=canvas.id) + .count() + ) + context["user_annotation_count"] = ( + UserAnnotation.objects.filter(owner_id=self.request.user.id) + .filter(canvas__manifest__id=manifest.id) + .count() + ) return context + class PageDetail(TemplateView): """Django Template View for :class:`apps.iiif.canvases.models.Canvas`""" + template_name = "page.html" + def get_metadatum(self, volume, key): + """Attempt to retrieve a value from a volume's metadata by key. If it cannot + be found, return an empty string.""" + if hasattr(volume, key): + # first try volume's model attributes + return getattr(volume, key) + elif isinstance(volume.metadata, dict): + # if not a model attr, attempt to get value from volume metadata; + # if metadata is a dict (rare), just lookup by key + return volume.metadata.get(key, "") + else: + # if metadata is a list (more common / correct IIIF spec), find the matching + # "label" attribute by key + meta_list = list(volume.metadata) + metadatum = filter(lambda m: m["label"] == key, meta_list) + try: + # filter() returns an iterator; try to retrieve the first matching entry's value + return next(metadatum).get("value", "") if metadatum else "" + except StopIteration: + return "" + def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - manifest = Manifest.objects.get(pid=kwargs['volume']) - if 'page' in kwargs: - canvas = Canvas.objects.filter(pid=kwargs['page']).first() + manifest = Manifest.objects.get(pid=kwargs["volume"]) + if "page" in kwargs: + canvas = Canvas.objects.filter(pid=kwargs["page"]).first() else: canvas = manifest.canvas_set.all().first() # if 'page' in kwargs and kwargs['page'] == 'all': # context['all'] = True - context['page'] = canvas - context['volume'] = manifest - context['pagelink'] = manifest.image_server - context['collectionlink'] = Page.objects.type(CollectionsPage).first() - context['volumelink'] = Page.objects.type(VolumesPage).first() - context['user_annotation_page_count'] = UserAnnotation.objects.filter( - owner_id=self.request.user.id - ).filter( - canvas__id=canvas.id - ).count() - context['user_annotation_count'] = UserAnnotation.objects.filter( - owner_id=self.request.user.id - ).filter( - canvas__manifest__id=manifest.id - ).count() - context['mirador_url'] = settings.MIRADOR_URL + context["page"] = canvas + context["volume"] = manifest + context["pagelink"] = manifest.image_server + context["collectionlink"] = Page.objects.type(CollectionsPage).first() + context["volumelink"] = Page.objects.type(VolumesPage).first() + context["user_annotation_page_count"] = ( + UserAnnotation.objects.filter(owner_id=self.request.user.id) + .filter(canvas__id=canvas.id) + .count() + ) + context["user_annotation_count"] = ( + UserAnnotation.objects.filter(owner_id=self.request.user.id) + .filter(canvas__manifest__id=manifest.id) + .count() + ) user_annotation_index = UserAnnotation.objects.all() - user_annotation_index = user_annotation_index.filter(canvas__manifest__label=manifest.label) + user_annotation_index = user_annotation_index.filter( + canvas__manifest__label=manifest.label + ) + + user_annotation_index = user_annotation_index.filter( + owner_id=self.request.user.id + ).distinct() + + user_annotation_index = ( + user_annotation_index.values( + "canvas__position", "canvas__manifest__label", "canvas__pid" + ) + .annotate(Count("canvas__position")) + .order_by("canvas__position") + ) + + context["user_annotation_index"] = user_annotation_index + context["json_data"] = {"json_data": list(user_annotation_index)} - user_annotation_index = user_annotation_index.filter(owner_id=self.request.user.id).distinct() + # add custom metadata from django settings to context + if hasattr(settings, "CUSTOM_METADATA"): + custom_metadata = {} + for key, metadata in settings.CUSTOM_METADATA.items(): + # Extract multi flag + multi = metadata.get("multi", False) # Default to False if not specified - user_annotation_index = user_annotation_index.values( - 'canvas__position', - 'canvas__manifest__label', - 'canvas__pid' - ).annotate( - Count( - 'canvas__position') - ).order_by('canvas__position') + # Attempt to get this manifest's value for each key + value = self.get_metadatum(manifest, key) + if value: + custom_metadata[key] = {"value": value, "multi": multi} - context['user_annotation_index'] = user_annotation_index - context['json_data'] = {'json_data': list(user_annotation_index)} + context["custom_metadata"] = custom_metadata return context + class ExportOptions(TemplateView, FormMixin): """Django Template View for Export""" + template_name = "export.html" form_class = JekyllExportForm @@ -272,34 +270,38 @@ def get_form_kwargs(self): # keyword arguments needed to initialize the form kwargs = super(ExportOptions, self).get_form_kwargs() # add user, which is used to determine available groups - kwargs['user'] = self.request.user + kwargs["user"] = self.request.user return kwargs def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - context['volume'] = Manifest.objects.filter(pid=kwargs['volume']).first() - context['export_form'] = self.get_form() + context["volume"] = Manifest.objects.filter(pid=kwargs["volume"]).first() + context["export_form"] = self.get_form() return context + class ExportDownload(TemplateView): """Django Template View for downloading an export.""" + template_name = "export_download.html" def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - context['volume'] = Manifest.objects.filter(pid=kwargs['volume']).first() - filename = kwargs['filename'] - context['filename'] = filename + context["volume"] = Manifest.objects.filter(pid=kwargs["volume"]).first() + filename = kwargs["filename"] + context["filename"] = filename # check to see if the file exists if path.exists(JekyllSiteExport.get_zip_path(filename)): - context['file_exists'] = True + context["file_exists"] = True else: - context['file_exists'] = False + context["file_exists"] = False return context + class ExportDownloadZip(View): """Django View for downloading the zipped up export.""" + def get(self, request, *args, **kwargs): """[summary] @@ -310,19 +312,28 @@ def get(self, request, *args, **kwargs): :return: [description] :rtype: [type] """ - jekyll_export = JekyllSiteExport(None, "v2", github_repo=None, deep_zoom=False, owners=[self.request.user.id], user=self.request.user); - zip = jekyll_export.get_zip_file(kwargs['filename']) - resp = HttpResponse(zip, content_type = "application/x-zip-compressed") - resp['Content-Disposition'] = 'attachment; filename=jekyll_site_export.zip' + jekyll_export = JekyllSiteExport( + None, + "v2", + github_repo=None, + deep_zoom=False, + owners=[self.request.user.id], + user=self.request.user, + ) + zip = jekyll_export.get_zip_file(kwargs["filename"]) + resp = HttpResponse(zip, content_type="application/x-zip-compressed") + resp["Content-Disposition"] = "attachment; filename=jekyll_site_export.zip" return resp + class VolumeSearchView(ListView, FormMixin): """View to search across all volumes with Elasticsearch""" + model = Manifest form_class = ManifestSearchForm template_name = "search_results.html" context_object_name = "volumes" - paginate_by = 25 + paginate_by = 20 # default fields to search when using query box; ^ with number indicates a boosted field query_search_fields = ["pid", "label^5", "summary^2", "author"] @@ -334,11 +345,39 @@ class VolumeSearchView(ListView, FormMixin): ("language", TermsFacet(field="languages", size=1000, min_doc_count=1)), # TODO: Determine a good size for authors or consider alternate approach (i.e. not faceted) ("author", TermsFacet(field="authors", size=2000, min_doc_count=1)), - ("collection", NestedFacet("collections", TermsFacet(field="collections.label", min_doc_count=1))) + ( + "collection", + NestedFacet( + "collections", + TermsFacet(field="collections.label", size=2000, min_doc_count=1), + ), + ), ] - defaults = { - "sort": "label_alphabetical" - } + defaults = {"sort": "label_alphabetical", "display": "list", "per_page": "60"} + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # pull additional facets from Elasticsearch + if ( + settings + and hasattr(settings, "CUSTOM_METADATA") + and isinstance(settings.CUSTOM_METADATA, dict) + ): + for key in settings.CUSTOM_METADATA.keys(): + self.facets.append( + ( + key, + NestedFacet( + "metadata", + TermsFacet( + field=f"metadata.{key}", size=2000, min_doc_count=1 + ), + ), + ) + ) + + # regex to match terms in doublequotes + re_exact_match = re.compile(r'\B(".+?")\B') def get_form_kwargs(self): # adapted from Princeton-CDH/geniza project https://github.com/Princeton-CDH/geniza/ @@ -363,21 +402,47 @@ def get_form_kwargs(self): kwargs["data"] = form_data return kwargs + def get_paginate_by(self, queryset): + """Allow per-page size selection via form.""" + form = self.get_form() + default_per_page = int(self.defaults.get("per_page", 20)) + if form.is_valid(): + try: + return int(form.cleaned_data.get("per_page") or default_per_page) + except (TypeError, ValueError): + return default_per_page + return default_per_page + def get_context_data(self, **kwargs): context_data = super().get_context_data(**kwargs) + # add configured custom metadata keys to context data + context_data["CUSTOM_METADATA_KEYS"] = ( + [ + # use django-friendly form field names + key.casefold().replace(" ", "_") + for key in settings.CUSTOM_METADATA.keys() + if settings.CUSTOM_METADATA[key].get("faceted", False) == True + ] + if hasattr(settings, "CUSTOM_METADATA") + and isinstance(settings.CUSTOM_METADATA, dict) + else [] + ) + volumes_response = self.get_queryset().execute() # populate a dict with "buckets" of extant categories for each facet facets = {} - for (facet, _) in self.facets: + for facet, _ in self.facets: if hasattr(volumes_response.aggregations, facet): aggs = getattr(volumes_response.aggregations, facet) # use "inner" to handle NestedFacet if hasattr(aggs, "inner"): aggs = getattr(aggs, "inner") - facets.update({ - # get buckets array from each facet in the aggregations dict - facet: getattr(aggs, "buckets"), - }) + facets.update( + { + # get buckets array from each facet in the aggregations dict + facet: getattr(aggs, "buckets"), + } + ) context_data["form"].set_facets(facets) # get min and max date aggregations and set on form @@ -385,9 +450,7 @@ def get_context_data(self, **kwargs): min_date = getattr(volumes_response.aggregations, "min_date") if hasattr(volumes_response.aggregations, "max_date"): max_date = getattr(volumes_response.aggregations, "max_date") - if hasattr( - min_date, "value_as_string" - ) and hasattr( + if hasattr(min_date, "value_as_string") and hasattr( max_date, "value_as_string" ): context_data["form"].set_date( @@ -395,6 +458,39 @@ def get_context_data(self, **kwargs): getattr(max_date, "value_as_string"), ) + # Attach start_canvas to each volume in the current page. + # Handle both: paginator page and raw list-like. + vol_page = context_data.get("volumes") + + if vol_page is not None: + # Get the underlying sequence (Paginator Page vs list) + items = getattr(vol_page, "object_list", vol_page) + + # ES hits should have a "pid"; collect them + pids = [getattr(v, "pid", None) for v in items if getattr(v, "pid", None)] + + if pids: + # Use the default reverse name: canvas_set + manifests = { + m.pid: m + for m in Manifest.objects + .filter(pid__in=pids) + .prefetch_related("canvas_set") + } + + for v in items: + pid = getattr(v, "pid", None) + m = manifests.get(pid) + if not m: + v.start_canvas = None + continue + + # Prefer an explicitly marked starting page, else first by position + start = m.canvas_set.filter(is_starting_page=True).order_by("position").first() + if start is None: + start = m.canvas_set.order_by("position").first() + v.start_canvas = start + return context_data def get_queryset(self): @@ -407,47 +503,137 @@ def get_queryset(self): form_data = form.cleaned_data # default to empty string if no query in form data - search_query = form_data.get("q", "") + search_query = form_data.get("q") or "" + scope = form_data.get("scope") or "all" if search_query: - multimatch_query = MultiMatch(query=search_query, fields=self.query_search_fields) - volumes = volumes.query(multimatch_query) + # find exact match queries (words or phrases in double quotes) + exact_queries = self.re_exact_match.findall(search_query) + # remove exact queries from the original search query to search separately + search_query = re.sub(self.re_exact_match, "", search_query).strip() + + es_queries = [] + es_queries_exact = [] + if scope in ["all", "metadata"]: + # query for root level fields + if search_query: + multimatch_query = Q( + "multi_match", + query=search_query, + fields=self.query_search_fields, + ) + es_queries.append(multimatch_query) + for exq in exact_queries: + # separate exact searches so we can put them in "must" boolean query + multimatch_exact = Q( + "multi_match", + query=exq.replace('"', "").strip(), # strip double quotes + fields=self.query_search_fields, + type="phrase", # type = "phrase" for exact phrase matches + ) + es_queries_exact.append({"bool": {"should": [multimatch_exact]}}) + + if scope in ["all", "text"]: + # query for nested fields (i.e. canvas position and text) + nested_kwargs = { + "path": "canvas_set", + # sum scores if in full text only search, so vols with most hits show up first. + # if also searching metadata, use avg (default) instead, to not over-inflate. + "score_mode": "sum" if scope == "text" else "avg", + } + inner_hits_dict = { + "size": 3, # max number of pages shown in full-text results + "highlight": {"fields": {"canvas_set.result": {}}}, + } + if search_query: + nested_query = Q( + "nested", + query=Q( + "multi_match", + query=search_query, + fields=["canvas_set.result"], + ), + inner_hits={**inner_hits_dict, "name": "canvases"}, + **nested_kwargs, + ) + es_queries.append(nested_query) + for i, exq in enumerate(exact_queries): + # separate exact searches so we can put them in "must" boolean query + nested_exact = Q( + "nested", + query=Q( + "multi_match", + query=exq.replace('"', "").strip(), + fields=["canvas_set.result"], + type="phrase", + ), + # each inner_hits set needs to have a different name in elasticsearch + inner_hits={**inner_hits_dict, "name": f"canvases_{i}"}, + **nested_kwargs, + ) + if scope == "all": + es_queries_exact[i]["bool"]["should"].append(nested_exact) + else: + es_queries_exact.append({"bool": {"should": [nested_exact]}}) + + # combine them with bool: { should, must } + q = Q("bool", should=es_queries, must=es_queries_exact) + volumes = volumes.query(q) # highlight volumes = volumes.highlight_options( require_field_match=False, fragment_size=200, number_of_fragments=10, - ).highlight( - "label", "author", "summary" - ) + max_analyzed_offset=999999, + ).highlight("label", "author", "summary") # filter on authors - author_filter = form_data.get("author", "") + author_filter = form_data.get("author") or "" if author_filter: volumes = volumes.filter("terms", authors=author_filter) # filter on languages - language_filter = form_data.get("language", "") + language_filter = form_data.get("language") or "" if language_filter: volumes = volumes.filter("terms", languages=language_filter) # filter on collections - collection_filter = form_data.get("collection", "") + collection_filter = form_data.get("collection") or "" if collection_filter: - volumes = volumes.filter("nested", path="collections", query=Q( - "terms", **{"collections.label": collection_filter} - )) + volumes = volumes.filter( + "nested", + path="collections", + query=Q("terms", **{"collections.label": collection_filter}), + ) # filter on date published - min_date_filter = form_data.get("start_date", "") + min_date_filter = form_data.get("start_date") or "" if min_date_filter: volumes = volumes.filter("range", date_earliest={"gte": min_date_filter}) - max_date_filter = form_data.get("end_date", "") + max_date_filter = form_data.get("end_date") or "" if max_date_filter: volumes = volumes.filter("range", date_latest={"lte": max_date_filter}) + # filter on custom metadata fields + if hasattr(settings, "CUSTOM_METADATA") and isinstance( + settings.CUSTOM_METADATA, dict + ): + for key in [ + k + for k in settings.CUSTOM_METADATA.keys() + if settings.CUSTOM_METADATA[k].get("faceted", False) + ]: + field_name = key.casefold().replace(" ", "_") + meta_filter = form_data.get(field_name) or "" + if meta_filter: + volumes = volumes.filter( + "nested", + path="metadata", + query=Q("terms", **{f"metadata.{key}": meta_filter}), + ) + # create aggregation buckets for facet fields - for (facet_name, facet) in self.facets: + for facet_name, facet in self.facets: volumes.aggs.bucket(facet_name, facet.get_aggregation()) # get min and max date published values @@ -463,25 +649,29 @@ def get_queryset(self): class ManifestsSitemap(Sitemap): """Django Sitemap for Manafests""" + limit = 5 + # priority unknown def items(self): return Manifest.objects.all() def location(self, item): - return reverse('volumeall', kwargs={'volume': item.pid}) + return reverse("volumeall", kwargs={"volume": item.pid}) def lastmod(self, item): return item.updated_at + class CollectionsSitemap(Sitemap): """Django Sitemap for Collections""" + # priority unknown def items(self): - return Collection.objects.all().annotate(modified_at=Max('manifests__updated_at')) + return Collection.objects.all() def location(self, item): - return reverse('collection', kwargs={'collection': item.pid}) + return reverse("collection", kwargs={"collection": item.pid}) def lastmod(self, item): return item.updated_at diff --git a/apps/static/css/components/collection.scss b/apps/static/css/components/collection.scss new file mode 100644 index 000000000..385b5a350 --- /dev/null +++ b/apps/static/css/components/collection.scss @@ -0,0 +1,128 @@ +@import '../partials/colors'; +@import '../partials/_mixin.scss'; + +#modal-full p { + color: $color-white; + ; +} + +#modal-full h2 { + font-size: xx-large; +} + +.full-width-bg { + background-size: cover; + background-position: center; + position: relative; + height: auto; + color: $color-white; +} + +.overlay { + background: linear-gradient(to right, rgba($color-black, 0.8) 0%, rgba($color-black, 0.6) 100%); + width: 33%; + position: relative; + height: auto; + min-height: 0; + padding: 50px; + box-sizing: border-box; + color: $color-white; +} + +.overlay h1 { + font-size: 2rem; + /* Reduced title size */ + margin-bottom: 15px; + color: $color-white; + +} + +.overlay p { + font-size: 1rem; + /* Adjusted font size */ + line-height: 1.5; + /* Reduced line-height */ + color: $color-white; +} + +.description-button { + margin-top: 20px; + background-color: $rx-color-mario-red; + /* Removed gradient background */ + border: none; + color: $color-white; + font-weight: bold; + height: 40px; + border-radius: 5px; + font-size: 1em; + display: flex; + align-items: center; + justify-content: center; + transition: background-color 0.3s ease; +} + +.modal-bg { + background-size: cover; + background-position: center; + position: relative; + height: 100%; + width: 100%; + color: $color-white; +} + +.modal-overlay { + background: rgba($color-black, 0.6); + /* Dark overlay */ + height: 100%; + width: 100%; + position: absolute; + top: 0; + left: 0; + padding: 50px; + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; +} + +.modal-overlay h2, +.modal-overlay p { + position: relative; + z-index: 1; +} + +.rx-collection-modal { + h1, + h2, + h3, + h4, + h5, + h6, + p { + color: $color-white; + } + + h2 { + margin-bottom: 1rem; + } + + a { + color: $rx-color-faded-mint; + text-decoration: underline; + @include link-variant($rx-color-faded-mint, 600, true); + &:hover, + &:active { + color: $color-white; + text-decoration: underline; + } + } + + .uk-close { + color: $color-white; + &:hover { + color: darken($color-white, 10%); + } + background: none; + border: none; + } +} diff --git a/apps/static/css/components/flatpage.scss b/apps/static/css/components/flatpage.scss new file mode 100644 index 000000000..af373fb8b --- /dev/null +++ b/apps/static/css/components/flatpage.scss @@ -0,0 +1,143 @@ +@import '../partials/colors'; + +.navigation { + background-color: $rx-color-faded-mint; + padding: 30px; + position: sticky; + top: 20px; + border-radius: 10px 10px; /* Rounded corners on the right-hand side */ + width: auto; /* Fit to content width */ +} + +.navigation a { + color: $rx-color-midnight-blue; + text-decoration: none; + display: block; + padding: 5px 15px !important; + position: relative; + font-weight: normal; + font-size: medium; +} + +.navigation a.active { + font-weight: bold; /* Bold text for active item */ + color: $rx-color-midnight-blue; + + &:hover { + color: darken($rx-color-midnight-blue, 10%) !important; + } +} + +.navigation a.active::before { + content: ''; + position: absolute; + left: 3px; + top: 50%; + transform: translateY(-50%); + height: 75%; + width: 4px; + background-color: $rx-color-midnight-blue; +} + +.content { + padding: 40px; +} + +.section { + padding-bottom: 40px; +} + +.static-content-title { + font-weight: bold; + margin: unset; +} + +.content p, .content ul { + margin-top: 0; + margin-bottom: 1rem; + color: $color-black; +} + +.content ol, .content li { + margin-top: 0; + color: $color-black; +} + +$content-heading-sizes: ( + h1: 2.5rem, + h2: 2rem, + h3: 1.5rem, + h4: 1.25rem, + h5: 1rem, + h6: 0.75rem +); + +.content { + h1, h2, h3, h4, h5, h6 { + margin-top: 1.25rem !important; + margin-bottom: 0.5rem !important; + } + + @each $heading, $size in $content-heading-sizes { + #{$heading} { + font-size: $size; + } + } +} + +.content li:first-of-type, +.content p:first-of-type { + margin-top: 0; +} + +.content ul:first-of-type { + margin-top: 0.5rem; +} + +.content li:last-of-type { + margin-bottom: 0; +} + +/* a series of indentation for the side nav headers */ +.indent-h1 a { + color: $rx-color-midnight-blue !important; +} + +.indent-h2 a { + color: $rx-color-midnight-blue !important; +} + +.indent-h3 a { + color: $rx-color-midnight-blue !important; + margin-left: 1.5rem; +} + +.indent-h4 a { + color: $rx-color-midnight-blue !important; + margin-left: 2.5rem; +} + +.content ul, .content ol { + padding-left: 1.75rem !important; + li { + margin-bottom: 0.5rem !important; + } +} + +.content ul { + list-style: disc !important; +} + +.content ol { + list-style: decimal !important; +} + +blockquote { + border-left: 4px solid $rx-color-linen-blue !important; + padding-left: 1rem !important; + color: darken($color-black, 10%) !important; + margin: 1rem 0 !important; +} + +/* prevent the sidebar from overlapping content */ +.sidebar-sticky { z-index: 0; } diff --git a/apps/static/css/components/footer.scss b/apps/static/css/components/footer.scss new file mode 100644 index 000000000..15b0a923f --- /dev/null +++ b/apps/static/css/components/footer.scss @@ -0,0 +1,10 @@ +@import '../partials/colors'; + +/* -------------------------------------------------------------------------- */ +/* FOOTER */ +/* -------------------------------------------------------------------------- */ +.footer { background-color: $rx-color-linen-blue; color: $color-white; padding: 20px; } +.footer-links { display: flex; flex-direction: column; font-weight: bold; } +.footer-links a { color: $color-white; text-decoration: none; &:hover { text-decoration: underline; } } +.footer-logo { display: flex; align-items: center; svg { width: 50px; height: 50px; } } +.footer-bottom { text-align: left; margin-top: 10px; color: $color-white; } \ No newline at end of file diff --git a/apps/static/css/components/login.scss b/apps/static/css/components/login.scss new file mode 100644 index 000000000..697503b52 --- /dev/null +++ b/apps/static/css/components/login.scss @@ -0,0 +1,65 @@ +@import '../partials/colors'; + +.custom-modal .uk-modal-dialog { + width: 400px; + padding: 20px; +} +.modal-header { + color: $rx-color-midnight-blue; + padding: 20px; + text-align: center; +} +.modal-content { + padding: 20px; +} +.uk-input-icon { + display: flex; + align-items: center; +} +.uk-input-icon input { + padding-left: 40px; +} +.uk-input-icon span { + position: absolute; + padding-left: 10px; + color: $rx-color-midnight-blue; +} +.uk-divider { + margin: 20px 0; +} +.sso-buttons .uk-button { + margin-bottom: 10px; + width: 100%; +} +.help-link { + color: $rx-color-midnight-blue; + display: block; + text-align: center; + margin-top: 20px; +} +.sso-text { + margin-bottom: 20px; + text-align: center; +} +.sso-buttons .uk-grid { + gap: 10px; +} + +.sso-button { + padding: 0 1rem; + text-align: center; +} + +.action-buttons { + margin-top: 20px; + display: flex; + justify-content: center; + gap: 10px; +} +.sign-in-btn { + background-color: $rx-color-midnight-blue; + color: $rx-color-faded-mint; + &:hover { + background-color: darken($rx-color-midnight-blue, 10%); + } +} \ No newline at end of file diff --git a/apps/static/css/components/menu-inverse.scss b/apps/static/css/components/menu-inverse.scss new file mode 100644 index 000000000..c03d6c0b6 --- /dev/null +++ b/apps/static/css/components/menu-inverse.scss @@ -0,0 +1,57 @@ +@import '../partials/colors'; + +/* Inverse the color for content pages */ +.menu-item { + color: rgba($rx-color-midnight-blue, 1.0) !important; + text-decoration: none !important; + transition: color 0.1s ease !important; + + &:hover { + color: rgba($rx-color-midnight-blue, 0.8) !important; // Slightly dim + } + + &:active { + color: rgba($rx-color-midnight-blue, 0.6) !important; // A bit darker + } +} + +.brand-logo { + font-size: large; + color: $rx-color-midnight-blue !important; + font-weight: bold; + + &:hover { + color: rgba($rx-color-midnight-blue, 0.8) !important; // Slightly dim + } + + &:active { + color: rgba($rx-color-midnight-blue, 0.6) !important; // A bit darker + } +} + +.brand-tagline { + font-size: small; + color: $rx-color-midnight-blue; +} + +.brand-readux { + color: $rx-color-midnight-blue; + + &:hover { + color: rgba($rx-color-midnight-blue, 0.8) !important; // Slightly dim + } + + &:active { + color: rgba($rx-color-midnight-blue, 0.6) !important; // A bit darker + } +} + +.brand-inline { + font-size: small; + font-weight: normal; + text-decoration: underline !important; +} + +.uk-navbar-container { + position: relative; +} \ No newline at end of file diff --git a/apps/static/css/components/reader.scss b/apps/static/css/components/reader.scss new file mode 100644 index 000000000..a95919787 --- /dev/null +++ b/apps/static/css/components/reader.scss @@ -0,0 +1,91 @@ +@import '../partials/colors'; + +.reader-navbar { + padding: 0 1rem; height: 36px; +} + +.rx-accordion-head { + background-color: $rx-color-faded-mint; + color: $rx-color-midnight-blue; + border: none; + &:hover { + color: $rx-color-midnight-blue !important; + background-color: darken($rx-color-faded-mint, 5%) !important; + border: none; + .rx-accordion-head::before { + color: $rx-color-midnight-blue !important; + } + } + &:active { + border: none; + } +} + +.rx-accordion-head::before { + color: $rx-color-midnight-blue !important; +} + +.rx-anchor { + color: $rx-color-midnight-blue !important; +} + +.uk-tab > .uk-active > a { + border-color: $rx-color-midnight-blue !important; + color: $rx-color-midnight-blue !important; +} + +.uk-search-default .uk-search-input:focus, .uk-input:focus, .uk-select:focus, .uk-textarea:focus { + border-color: $rx-color-midnight-blue; +} + +.scrollable-container { + max-height: 350px; + overflow-y: auto; + white-space: pre-wrap; + word-break: break-word; +} + +/* target success notifications */ +.uk-notification-message-success { + background-color: $rx-color-faded-mint; + font-weight: 700; + color: $rx-color-dark-charcoal; + border-radius: 4px; +} + +.uk-disabled, .disabled{ + opacity: 0.5; +} + +.ocr-notification { + background-color: $rx-color-faded-mint; + color: $rx-color-dark-charcoal; + border-radius: 4px; + padding: 0.5rem; +} + +.rx-collection-link { + color: $rx-color-midnight-blue !important; + font-weight: 500; + &:hover { + color: darken($rx-color-midnight-blue, 10%) !important; + } +} + +.rx-collection-separator { margin: 0 0.35rem; color: $rx-color-cement-gray; } + +.uk-modal-close-full { + margin: 25px 22px 0px 0px !important; + padding: 5px; + &:hover { + background-color: #e1f4da; + } +} + +.uk-tab::before { + right: 240px; +} + +.uk-search { + padding-right: 10px; +} \ No newline at end of file diff --git a/apps/static/css/components/search.scss b/apps/static/css/components/search.scss new file mode 100644 index 000000000..b6d5dcb05 --- /dev/null +++ b/apps/static/css/components/search.scss @@ -0,0 +1,265 @@ +@import '../partials/colors'; + +/* -------------------------------------------------------------------------- */ +/* SEARCH RESULTS PAGE */ +/* -------------------------------------------------------------------------- */ + +// ————————————————————————————————————————————— +// Layout: Info line +// ————————————————————————————————————————————— +.info-line { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; // wrap on smaller screens + margin-bottom: 20px; + font-weight: 600; + color: $rx-color-midnight-blue; + + .info-group, + .pagination-controls { + display: flex; + align-items: center; + gap: 5px; + flex-wrap: wrap; + } + + a, + select { + color: $rx-color-midnight-blue; + text-decoration: underline; + } + + .pagination-controls { + .uk-icon-button { + color: $rx-color-midnight-blue; + + &[disabled] { + color: $rx-color-light-pearl; + cursor: not-allowed; + } + } + } + + @media (max-width: 768px) { + flex-direction: column; + align-items: flex-start; + + .info-group, + .pagination-controls { + width: 100%; + justify-content: space-between; + margin-bottom: 10px; + } + + .info-group { gap: 10px; } + } +} + +// ————————————————————————————————————————————— +// Form normals & search form +// ————————————————————————————————————————————— +fieldset { + margin: inherit; + border: 0; + padding: inherit; +} + +form#search-form { + input[type="search"][name="q"] { width: 100%; } + + .uk-button-danger { + background-color: $rx-color-rose-red; + color: $color-white; + border: 1px solid transparent; + } + + .uk-button-secondary { + background-color: $rx-color-dark-charcoal; + color: $color-white; + border: 1px solid transparent; + } +} + +// ————————————————————————————————————————————— +// Filters +// ————————————————————————————————————————————— +#search-filters { + input[type="text"]#authors-filter { width: 100%; } + + select[multiple] { + height: 150px; + width: 100%; + overflow-y: scroll; + overflow-x: auto; + } + + .noUi-target { margin: 45px 21px 10px; } +} + +// ————————————————————————————————————————————— +// UI Kit tweaks +// ————————————————————————————————————————————— +.uk-button-primary { + background-color: $rx-color-midnight-blue; + + &:hover { + background-color: darken($rx-color-midnight-blue, 10%); // darker on hover + } +} + +.uk-container ul { list-style: none; } + +.uk-checkbox:checked, +.uk-checkbox:indeterminate, +.uk-radio:checked { + background-color: $rx-color-midnight-blue !important; +} + +.uk-search-default { + .uk-search-input:focus { + border-color: $rx-color-midnight-blue; + border-right: 0; + } +} + +.uk-input:focus, +.uk-select:focus, +.uk-textarea:focus { + border-color: $rx-color-midnight-blue; +} + +// ————————————————————————————————————————————— +// Grid +// ————————————————————————————————————————————— +#search-grid { + margin-left: 0; + gap: 1.5rem; +} + +// ————————————————————————————————————————————— +// Selectize +// ————————————————————————————————————————————— +.selectize-control.multi .selectize-input > div { + background: $rx-color-midnight-blue !important; + border: none !important; +} + +.selectize-dropdown .active:not(.selected) { + background-color: $rx-color-faded-mint !important; +} + +.selectize-control.plugin-clear_button .clear { + height: 85%; + top: -3px !important; +} + +// ————————————————————————————————————————————— +// noUi Slider (date slider) +// ————————————————————————————————————————————— +.noUi-connect { + background: $rx-color-midnight-blue !important; + + [disabled] & { + background: $rx-color-midnight-blue !important; + opacity: 0.2; + } +} + +.noUi-tooltip { + font-size: .875rem; + font-family: monospace; + font-weight: normal; + padding: 0.125rem 0.25rem !important; + background-color: none; + border: none !important; +} + +// ————————————————————————————————————————————— +// Typography +// ————————————————————————————————————————————— +.sui-item-heading { + letter-spacing: 1.5px; + text-transform: uppercase; + font-weight: 600; + font-family: -apple-system, 'Helvetica Neue', 'Helvetica', 'Arial', 'Segoe UI', 'Roboto', 'Ubuntu', sans-serif; + font-size: 13px; + margin-top: 0.65rem; + + a { color: $rx-color-midnight-blue; } +} + +// ————————————————————————————————————————————— +// Search results +// ————————————————————————————————————————————— +#search-results { + list-style: none; + + dl { margin-left: 2rem; } + + .result-volume-summary { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + overflow: hidden; // clamp to 3 lines + } + + // highlighting + em { + color: $rx-color-mario-red !important; + font-weight: bold; + font-style: normal; + } + + // result snippet within full text + .result-page { + background-color: $rx-color-faded-mint; + color: $rx-color-midnight-blue; + + a { + display: flex; + flex-flow: row nowrap; + justify-content: flex-start; + align-items: flex-start; + gap: 1rem; + margin: 0.5rem 0; + font-size: 0.8rem; + color: $rx-color-midnight-blue; + } + + .page-number { + min-width: 4rem; // keeps previous 50px/4rem intent consistent + max-width: 4rem; + font-size: large; + font-weight: 600; + letter-spacing: -1px; + text-transform: uppercase; + } + + ul li { list-style: disc; } + } +} + +.result-title { + color: $rx-color-midnight-blue; + + &:hover { + color: darken($rx-color-midnight-blue, 10%); + } +} + +// ————————————————————————————————————————————— +// Search actions +// ————————————————————————————————————————————— +.search-button { + background-color: $rx-color-mario-red; + color: $color-white; + padding: 4px 9px 0 9px; + border: none; + height: 40px; + font-size: 1.25rem; + transition: ease 0.1s; + + &:hover { background-color: darken($rx-color-mario-red, 10%); } + &:active { background-color: darken($rx-color-mario-red, 25%); } +} diff --git a/apps/static/css/components/social-auth.scss b/apps/static/css/components/social-auth.scss new file mode 100644 index 000000000..37db33d6f --- /dev/null +++ b/apps/static/css/components/social-auth.scss @@ -0,0 +1,16 @@ +@import '../partials/colors'; + +/* Social Auth Buttons */ +a.rdx-provider-button { + color: $color-white; + padding: 1rem; +} + +a.rdx-provider-button:hover { + color: $rx-color-light-pearl; +} + +.rdx-indented-help-block { + text-indent: 1.25rem; +} +/* End of Social Auth Buttons */ \ No newline at end of file diff --git a/apps/static/css/components/uk-switch.scss b/apps/static/css/components/uk-switch.scss new file mode 100644 index 000000000..9082caf20 --- /dev/null +++ b/apps/static/css/components/uk-switch.scss @@ -0,0 +1,55 @@ +@import '../partials/colors'; + +.uk-switch { + position: relative; + display: inline-block; + height: 17px; + width: 30px; +} + +/* Hide default HTML checkbox */ +.uk-switch input { + display: none; +} + +/* Slider */ +.uk-switch-slider { + background-color: $rx-color-light-pearl; + position: absolute; + top: 0; + left: 0; + right: 0; + border-radius: 500px; + bottom: 0; + cursor: pointer; + transition-property: background-color; + transition-duration: .2s; +} + +/* Switch pointer */ +.uk-switch-slider:before { + content: ''; + background-color: $color-white; + position: absolute; + width: 15px; + height: 15px; + left: 1px; + bottom: 1px; + border-radius: 50%; + transition-property: transform, box-shadow; + transition-duration: .2s; +} + +/* Slider active color */ +input:checked+.uk-switch-slider { + background-color: $rx-color-midnight-blue !important; +} + +/* Pointer active animation */ +input:checked+.uk-switch-slider:before { + transform: translateX(13px); +} + +input:checked+.uk-switch-slider.uk-switch-big:before { + transform: translateX(13px) scale(1.2); +} \ No newline at end of file diff --git a/apps/static/css/components/wagtail.scss b/apps/static/css/components/wagtail.scss new file mode 100644 index 000000000..1e8663564 --- /dev/null +++ b/apps/static/css/components/wagtail.scss @@ -0,0 +1,22 @@ +@import '../partials/colors'; + +/* Wagtail embedded objects */ +.rich-text img { + max-width: 100%; + height: auto; +} + +.responsive-object { + position: relative; +} + +.responsive-object iframe, +.responsive-object object, +.responsive-object embed { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; +} +/* end of Wagtail embedded objects */ \ No newline at end of file diff --git a/apps/static/css/ecds-annotator.min.css b/apps/static/css/ecds-annotator.min.css new file mode 120000 index 000000000..263f0de6e --- /dev/null +++ b/apps/static/css/ecds-annotator.min.css @@ -0,0 +1 @@ +../../../node_modules/ecds-annotator/dist/ecds-annotator.min.css \ No newline at end of file diff --git a/apps/static/css/ecds-annotator.min.css.map b/apps/static/css/ecds-annotator.min.css.map new file mode 120000 index 000000000..910fb9056 --- /dev/null +++ b/apps/static/css/ecds-annotator.min.css.map @@ -0,0 +1 @@ +../../../node_modules/ecds-annotator/dist/ecds-annotator.min.css.map \ No newline at end of file diff --git a/apps/static/css/partials/_colors.scss b/apps/static/css/partials/_colors.scss new file mode 100644 index 000000000..1bc4958b0 --- /dev/null +++ b/apps/static/css/partials/_colors.scss @@ -0,0 +1,23 @@ +// Theme +$rx-color-midnight-blue: #1D3557; +$rx-color-linen-blue: #457B9D; +$rx-color-mario-red: #E60000; +$rx-color-faded-mint: #F1FAEE; + +// ============================== +// Auxilary +// ============================== +$rx-color-rose-red: #f0506e; + +// ============================== +// Base +// ============================== +$color-black: #000000; +$color-white: #ffffff; + +// ============================== +// Black to White Scale +// ============================== +$rx-color-dark-charcoal: #333333; +$rx-color-cement-gray: #595959; +$rx-color-light-pearl: #cccccc; \ No newline at end of file diff --git a/apps/static/css/partials/_media.scss b/apps/static/css/partials/_media.scss new file mode 100644 index 000000000..2cecd4070 --- /dev/null +++ b/apps/static/css/partials/_media.scss @@ -0,0 +1,25 @@ +/* ========================================================================== */ +/* RESPONSIVE MEDIA QUERIES */ +/* ========================================================================== */ + +@media (max-width: 639px) { .uk-logo { padding: 0; } } + +@media (min-width: 640px) { + .rx-splash { display: flex; flex-direction: column; align-self: flex-start; position: sticky; top: 0; justify-content: space-between; height: calc(100vh - 200px); } +} + +@media (min-width: 768px) { .modal-dialog > .modal-content { top: 200px; } } + +@media (min-width: 960px) { .uk-navbar-right { flex-wrap: initial; } } + +@media (min-width: 320px) and (max-width: 959px) { + .rx-title-image { object-fit: cover; height: 150px; width: 100%; } + footer { padding: 2rem 0 0 0 !important; } + .content { padding: 0 !important; } +} + +@media only screen and (max-width: 480px) { #box { width: 100%; padding-bottom: 100%; } } +@media only screen and (max-width: 650px) and (min-width: 481px) { #box { width: 50%; padding-bottom: 50%; } } +@media only screen and (max-width: 1050px) and (min-width: 651px) { #box { width: 33.3%; padding-bottom: 33.3%; } } +@media only screen and (max-width: 1290px) and (min-width: 1051px) { #box { width: 25%; padding-bottom: 25%; } } +@media only screen and (max-width: 1250px) { ol { columns: 1; -webkit-columns: 1; -moz-columns: 1; } } \ No newline at end of file diff --git a/apps/static/css/partials/_mixin.scss b/apps/static/css/partials/_mixin.scss new file mode 100644 index 000000000..5feb178ee --- /dev/null +++ b/apps/static/css/partials/_mixin.scss @@ -0,0 +1,57 @@ +// ===================================== +// Helpers & Mixins +// ===================================== + +// a11y: visually hidden but focusable +@mixin sr-only() { + position: absolute !important; + width: 1px !important; + height: 1px !important; + padding: 0 !important; + margin: -1px !important; + overflow: hidden !important; + clip: rect(0 0 0 0) !important; + clip-path: inset(50%) !important; + border: 0 !important; + white-space: nowrap !important; +} + +// Generate hover/active shades for a given base color +@mixin interactive-color($base, $prop: color) { + #{$prop}: $base !important; + transition: #{$prop} 0.1s ease; + + &:hover { #{$prop}: if(type-of($base) == 'color', darken($base, 10%), $base) !important; } + &:active { #{$prop}: if(type-of($base) == 'color', darken($base, 25%), $base) !important; } +} + +// Border-only interactive (for outlines, etc.) +@mixin interactive-border($base) { + transition: border-color 0.1s ease; + &:hover { border-color: darken($base, 10%); } + &:active { border-color: darken($base, 25%); } +} + +// Link variant that underlines on hover and darkens progressively +@mixin link-variant($base, $weight: 600, $underline-on-hover: true) { + color: $base; + font-weight: $weight; + transition: color 0.1s ease; + + &:hover { + color: darken($base, 10%); + @if $underline-on-hover { text-decoration: underline; } + } + + &:active { + color: darken($base, 25%); + @if $underline-on-hover { text-decoration: underline; } + } +} + +// List reset +@mixin no-list-style { + list-style: none; + padding-left: 0; + margin-left: 0; +} \ No newline at end of file diff --git a/apps/static/css/project.css b/apps/static/css/project.css deleted file mode 100644 index 41961bcf2..000000000 --- a/apps/static/css/project.css +++ /dev/null @@ -1,1771 +0,0 @@ -:root { - --link-color: $rx-color-accent-1; - --contrast: 200%; -} - -.uk-input { - border-width: 3px; - width: 50%; -} - -.uk-link, a { - color: var(--link-color); - -webkit-text-decoration: underline dotted; - text-decoration: underline dotted; -} - -a.nav-link { - text-decoration: none; -} - -.uk-button-primary { - background-color: var(--link-color); -} - -.uk-button-primary:hover, -.uk-button-primary:active { - background-color: var(--link-color); - -webkit-filter: contrast(var(--contrast)); - filter: contrast(var(--contrast)); -} - -.uk-button-default { - font-size: 1.25rem; -} - -.uk-tab > .uk-active > a { - border-color: var(--link-color) !important; - color: var(--link-color) !important; -} - -.uk-checkbox, -.uk-radio { - border: 1px solid #ccc !important; -} - -.uk-navbar-right { - padding-top: 1rem; -} - -.uk-container ul { - list-style: none; -} - -.block-paragraph_block li { - list-style: circle; -} - -.uk-navbar-nav { - font-weight: bold; -} - -.alert-debug { - color: black; - background-color: white; - border-color: #d6e9c6; -} - -.alert-error { - color: #b94a48; - background-color: #f2dede; - border-color: #eed3d7; -} - -#viewer { - width: 100%; - height: calc(100% - 120px); - position: absolute; - left: 0; - bottom: 0px; - background: none !important; -} - -.manifest-info > a, -.manifest-info > h3 { - display: none !important; -} - -#ocr-layer { - position: absolute !important; - z-index: 999; -} - -.openseadragon-container span { - color: transparent; - display: -webkit-inline-box !important; - display: -ms-inline-flexbox !important; - display: inline-flex !important; - font-size: 100%; - line-height: initial; - white-space: nowrap; -} - -/* Social Auth Buttons */ -a.rdx-provider-button { - color: #fff; - padding: 1rem; -} - -a.rdx-provider-button:hover { - color: lightgrey; -} - -.rdx-provider-button.facebook { - background-color: #4267b2; -} - -.rdx-provider-button.google { - background-color: #ea4335; -} - -.rdx-provider-button.twitter { - background-color: #1da1f2; -} - -.rdx-provider-button.github { - background-color: #24292e; -} - -.rdx-indented-help-block { - text-indent: 1.25rem; -} - -/* End of Social Auth Buttons */ -/* Wagtail embedded objects */ -.rich-text img { - max-width: 100%; - height: auto; -} - -.responsive-object { - position: relative; -} - -.responsive-object iframe, -.responsive-object object, -.responsive-object embed { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; -} - -/* end of Wagtail embedded objects */ -#id_featured_collections { - height: 200px; - overflow: hidden; - overflow-y: scroll; -} - -.breadcrumbs { - font-size: 80%; - margin: 0; -} - -header.page-header { - position: relative; - z-index: 10; - width: 100%; -} - -header.page-header .container img { - width: 100%; -} - -h1, -h2, -h3, -p.text-lead { - color: #595959; -} - -h3 { - font-size: 2.2rem; -} - -header#page-bg .collection-image-info { - display: inline-block; - background: rgba(0, 0, 0, 0.62); - position: absolute; - bottom: 0; - right: 0; - padding: 1em 1.5em 5px 3.5em; - font-size: 12px; - color: #dedede; - z-index: 100; - -webkit-transition: bottom 0.6s ease, right 0.6s ease, background-color 0.6s ease, opacity 0.6s ease; - transition: bottom 0.6s ease, right 0.6s ease, background-color 0.6s ease, opacity 0.6s ease; - max-width: 300px; - min-height: 10px; - cursor: pointer; -} - -header#page-bg .collection-label h1 { - font-size: 3em; - color: #dedede; - width: 100%; - height: 100%; - position: absolute; - top: 0; - left: 0px; - -webkit-box-pack: center; - -ms-flex-pack: center; - justify-content: center; - -webkit-box-orient: vertical; - -webkit-box-direction: normal; - -ms-flex-direction: column; - flex-direction: column; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - display: -webkit-box; - display: -ms-flexbox; - display: flex; -} - -ol { - columns: 2; - -webkit-columns: 2; - -moz-columns: 2; -} - -ol > li { - break-inside: avoid-column; - -webkit-column-break-inside: avoid; - page-break-inside: avoid; -} - -ul.listing-thumbs > li > img.thumbnail-image { - height: 150px; -} - -ul > li > .collectionbox { - width: 100%; - padding-bottom: 15%; - position: relative; - border: 1px; - border-style: solid; -} - -ul > li > .collectionbox > .collectioncontainer { - position: absolute; - left: 0; - right: 0; - top: 0; - bottom: 0; - overflow: hidden; -} - -ul > li > .collectionbox > .collectioncontainer img { - width: 100%; - overflow: hidden; -} - -ul > li > .collectionbox > .collectioncontainer > .collection-label { - height: 0; -} - -ul > li > .collectionbox > .collectioncontainer > .collection-label a.nav-link { - font-size: 3em; - color: #dedede; - position: absolute; - left: 10px; - right: 10px; - top: 30%; - bottom: 10px; - text-align: center; - color: black; -} - -ul -> li -> .collectionbox -> .collectioncontainer -> .collection-label -a.nav-link -span { - background-color: rgba(255, 255, 255, 0.62); - padding: 0.3em; - border-radius: 20px; -} - -.count-icon { - display: inline-block; - position: relative; -} - -.count { - position: absolute; - top: 0; - right: 0; - padding-top: 20%; - padding-right: 20%; - height: 10px; - width: 10px; - font-size: 10px; - text-align: center; -} - -header#page-bg .collection-label h1 span { - background-color: rgba(0, 0, 0, 0.62); - padding: 0.3em; -} - -header#page-bg .collection-image-info:before { - content: "i"; - position: absolute; - border: 1px solid; - width: 20px; - text-align: center; - top: 0; - left: 0; - margin: 10px 10px; - height: 20px; - font-family: serif; -} - -header#page-bg .collection-image-info h3 { - margin: 0; - font-size: 1.5em; - color: #dedede; -} - -header#page-bg .collection-image-info p { - width: 100%; - color: #dedede !important; - text-align: left; - margin-bottom: 0px; - margin-top: 0px; -} - -header#page-bg .collection-image-info p.credit { - font-size: 0.9em; -} - -header#page-bg .collection-image-info.collasped { - padding: 1.5em 1.75em; - background-color: rgba(22, 22, 22, 0.33); - opacity: 0.8; - color: #dedede !important; -} - -header#page-bg .collection-image-info.collasped:hover { - opacity: 1; - background-color: rgba(0, 0, 0, 0.62); -} - -header#page-bg .collection-image-info.collasped h3, -header#page-bg .collection-image-info.collasped .info { - display: none; -} - -#wrap { - overflow: hidden; -} - -.box { - width: 25%; - padding-bottom: 25%; - position: relative; - float: left; -} - -.box a img { - width: 100%; - overflow: hidden; -} - -.boxInner { - position: absolute; - left: 0; - right: 0; - top: 0; - bottom: 0; - overflow: hidden; -} - -.boxInnerb { - position: absolute; - left: 0; - right: 0; - top: 0; - bottom: 0; - max-height: 225px; - overflow: hidden; -} - -.boxInner img { - -webkit-filter: blur(2px); - /* Safari 6.0 - 9.0 */ - filter: blur(2px); - width: 100%; -} - -.boxInnerb img { - -webkit-filter: blur(2px); - /* Safari 6.0 - 9.0 */ - filter: blur(2px); - width: 100%; -} - -.innerContent { - position: absolute; - left: 10px; - right: 10px; - top: 10px; - bottom: 10px; - text-align: center; - color: black; - -webkit-transition: 0.5s; - transition: 0.5s; - opacity: 0; -} - -.innerContent:hover { - opacity: 1; -} - -.innerContent p { - text-align: center; - background: rgba(255, 255, 255, 0.7); - color: #4f4f4f; - border-radius: 15px; -} - -.innerContent p em { - color: black; -} - -.innerContent a { - color: black; - background: rgba(255, 255, 255, 0.7); - text-decoration: underline; -} - -.innerContentb { - position: absolute; - left: 10px; - right: 10px; - top: 10px; - bottom: 10px; - text-align: center; - color: black; -} - -.innerContentc { - position: absolute; - left: 10px; - right: 10px; - top: 10px; - bottom: 10px; - text-align: center; - color: black; -} - -.innerContentb h2 { - text-align: center; - background: rgba(255, 255, 255, 0.7); - color: #4f4f4f; - border-radius: 15px; -} - -.innerContentb h2 em { - color: black; -} - -.innerContentb a { - color: black; - background: rgba(255, 255, 255, 0.7); - text-decoration: underline; -} - -.readux-home-tagline { - font-size: 3em; - font-weight: 700; -} - -.readux-home-ecds { - width: 20em; -} - -.readux-group-title { - font-size: 2em; - font-weight: 700; -} - -#rx-sort { - position: -webkit-sticky; - position: sticky; - top: 72px; -} - -.rx-card-title { - font-size: 1.25em; - font-weight: 700; - color: #595959; - line-height: 1.25em; -} - -a:hover { - text-decoration: initial !important; -} - -.rx-offcanvas-bar, -.rx-offcanvas-base { - background: white; - -webkit-box-shadow: 0 3px 6px rgba(0, 0, 0, 0.16), 0 3px 6px rgba(0, 0, 0, 0.23); - box-shadow: 0 3px 6px rgba(0, 0, 0, 0.16), 0 3px 6px rgba(0, 0, 0, 0.23); -} - -p { - color: #595959 !important; -} - -.rx-breadcrumb, -.rx-action-title, -.rx-action-btn, -.rx-page-breadcrumb-item, -.rx-page-breadcrumb { - color: var(--link-color); - -webkit-filter: brightness(0.4%); - filter: brightness(0.4%); - text-transform: uppercase; - font-weight: bold; - letter-spacing: 0.1em; - display: -webkit-inline-box; - display: -ms-inline-flexbox; - display: inline-flex; - padding: 0; - margin: 0; -} - -.rx-page-breadcrumb-item a { - color: var(--link-color); -} - -.rx-page-breadcrumb { - white-space: nowrap; -} - -.rx-page-logo { - min-width: 65px; -} - -.rx-breadcrumb-item:not(:last-child)::after, -.rx-page-breadcrumb-item:not(:last-child)::after { - content: "-"; - padding: 0 0.5em; -} - -.rx-page-title-container { - padding: 0 15px; - overflow-wrap: anywhere; - -ms-flex-item-align: center; - -ms-grid-row-align: center; - align-self: center; -} - -.rx-breadcrumb-item > a, -.rx-page-breadcrumb-item > a, -.rx-action-btn, -.rx-icon-btn, -.uk-dropdown-nav > li > a:hover { - color: var(--link-color) !important; - -webkit-filter: brightness(1); - filter: brightness(1); - opacity: 0.75; - -webkit-transition: all ease-in-out 100ms; - transition: all ease-in-out 100ms; -} - -.rx-breadcrumb-item > a:hover, -.rx-page-breadcrumb-item > a:hover, -.rx-action-btn:hover, -.rx-icon-btn:hover, -.uk-dropdown-nav > li.uk-active > a { - color: var(--link-color); - opacity: 1; - -webkit-transition: all ease-in-out 100ms; - transition: all ease-in-out 100ms; -} - -.rx-head-container { - margin: 0em 0 3em; -} - -.rx-info { - margin: 1rem 0; -} - -.rx-info-title { - border: 0.5px solid #e2e2e2; - padding: 0.7em 1em 0.5em; - color: #595959; - letter-spacing: 0.15em; - text-transform: uppercase; - font-size: 0.875em; - font-weight: bold; -} - -.rx-info-content-container { - border: 0.5px solid #e2e2e2; - padding: 1em 1em; - word-break: break-word; -} - -.rx-info-content { - padding: 0.5em 0; - font-size: 0.9rem; -} - -.rx-info-content-label { - color: #595959 !important; - font-weight: 600 !important; - line-height: 1em !important; - font-size: 0.9rem !important; -} - -.rx-info-content-value { - color: #595959; - line-height: 1.5; -} - -.rx-card-body { - text-align: left; - min-width: 0; - -webkit-box-flex: 1; - -ms-flex: 1 1 auto; - flex: 1 1 auto; - /* margin-left: 2rem; */ -} - -.rx-card-image-container { - /* width: 150px; */ - -webkit-box-flex: 0; - -ms-flex: 0 0 auto; - flex: 0 0 auto; -} - -.rx-card-image { - background-origin: border-box !important; - background-size: cover !important; - width: 100% !important; - height: 100% !important; - display: block !important; - color: inherit; - text-decoration: none; - background-color: none; -} - -.rx-card-text { - padding: 0; - color: #595959; - margin: 0.5em 0; - overflow: hidden; - text-overflow: ellipsis; - display: -webkit-box; - -webkit-line-clamp: 3; - /* number of lines to show */ - -webkit-box-orient: vertical; - font-size: 14px; -} - -.rx-card-title { - overflow: hidden; - text-overflow: ellipsis; - display: -webkit-box; - -webkit-line-clamp: 1; - /* number of lines to show */ - -webkit-box-orient: vertical; -} - -.rx-grid-right { - overflow: auto; -} - -#rx-nav { - /* position: fixed; - top: 0; - left: 5%; - right: 5%; - background: white; */ - /* position: sticky; - top: 0; */ - background: white; -} - -.rx-grid-right { - margin-left: 50%; -} - -.rx-btn { - border-radius: 3px; - border: solid 1px var(--link-color); - -webkit-filter: contrast(var(--contrast)); - filter: contrast(var(--contrast)); - color: var(--link-color); - filter: contrast(var(--contrast)); - font-family: HelveticaNeue, Helvetica, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; - font-size: 14px; - font-weight: 500; - line-height: 1.28; - letter-spacing: normal; - text-align: center; - padding: 5px 12px; - display: inline-block; -} - -.rx-btn:hover { - border: solid 1px var(--link-color); - color: white; - background: var(--link-color); - -webkit-filter: contrast(var(--contrast)); - filter: contrast(var(--contrast)); - -webkit-transition: all ease-in-out 100ms; - transition: all ease-in-out 100ms; - cursor: pointer; -} - -.rx-btn-extension { - border-radius: 3px; - border: solid 1px var(--link-color); - -webkit-filter: contrast(var(--contrast)); - filter: contrast(var(--contrast)); - color: white; - font-family: HelveticaNeue, Helvetica, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; - font-size: 15px; - font-weight: 600; - line-height: 1.28; - text-transform: uppercase; - letter-spacing: 0.7px; - text-align: center; - padding: 8px 16px; - display: inline-block; - background: var(--link-color); - color: white; - border: solid 1px var(--link-color); -} - -.rx-btn-extension:hover { - background: #5e0035; - color: white; - border: solid 1px #5e0035; - -webkit-transition: all ease-in-out 100ms; - transition: all ease-in-out 100ms; - opacity: 1; - cursor: pointer; -} - -.rx-annotation-badge { - border-radius: 3px; - background: white; - color: #595959 !important; - border: #595959 1px solid; - font-size: 0.75rem; - letter-spacing: 0.1rem; - font-weight: 600; - display: inline-block; - cursor: default; - text-transform: uppercase; - padding: 0.1rem 0.5rem; -} - -.rx-padding-bottom-10 { - padding-bottom: 10px; -} - -.uk-active > .rx-btn-annotation { - background-color: #950953 !important; - color: white; -} - -.uk-offcanvas-container { - top: -80px; - position: inherit; -} - -.readux-group-title:not(:first-child) { - margin-top: 3rem; -} - -.rx-footer { - display: -webkit-box; - display: -ms-flexbox; - display: flex; - list-style: none; - font-weight: bold; -} - -.rx-footer > a { - padding: 1rem; - color: #737373; - -webkit-transition: all ease-in-out 100ms; - transition: all ease-in-out 100ms; -} - -.rx-footer > a:first-child { - padding-left: 0; -} - -.rx-footer > a:hover { - color: #666; -} - -.rx-anchor { - color: var(--link-color) !important; - opacity: 0.75; - -webkit-transition: all ease-in-out 100ms; - transition: all ease-in-out 100ms; -} - -.rx-anchor:hover { - color: var(--link-color); - opacity: 1; -} - -::-moz-selection { - background-color: #595959; -} - -::selection { - background-color: #595959; -} - -.rx-label-copy { - background: white; - color: var(--link-color) !important; - border: var(--link-color) 1px solid; - font-size: 0.75rem; - letter-spacing: 0.1rem; - opacity: 0.75; - cursor: pointer; - -webkit-transition: all ease-in-out 100ms; - transition: all ease-in-out 100ms; - font-weight: 600; -} - -.rx-label-copy:active { - background-color: #510029 !important; - border: 1px solid #510029 !important; - opacity: 1; -} - -.rx-page-navbar-right > a { - margin-left: 1em; -} - -.rx-label-copy:hover { - background: var(--link-color); - color: white !important; - font-size: 0.75rem; - letter-spacing: 0.1rem; - cursor: pointer; - opacity: 1; -} - -.rx-flex { - display: -webkit-box; - display: -ms-flexbox; - display: flex; -} - -.rx-tooltip-hidden { - visibility: hidden; - opacity: 0; - -webkit-transition: visibility 0s 2s, opacity 2s linear; - transition: visibility 0s 2s, opacity 2s linear; -} - -.rx-grid { - margin: 40px auto; - max-width: 1024px; - display: -webkit-box; - display: -ms-flexbox; - display: flex; - -ms-flex-wrap: wrap; - flex-wrap: wrap; -} - -.rx-col { - border: 1px dashed gray; - -webkit-box-flex: 1; - -ms-flex: 1 1 100px; - flex: 1 1 100px; - text-align: center; - padding: 12px; -} - -.rx-top-left { - position: absolute; - top: 8px; - left: 16px; -} - -.rx-image-container > figure { - margin: auto; - background-position: center; - background-size: cover; - width: 100%; - height: 200px; -} - -.rx-volume-na { - background: #fbecf9; - padding: 15px; - font-size: 14px; - color: var(--link-color); -} - -a.rx-nav-item { - color: var(--link-color) !important; -} - -.rx-nav-item.uk-active { - color: var(--link-color); -} - -.rx-btn-small { - font-size: 14px; - font-weight: bold; - letter-spacing: normal; - text-align: center; - padding: 6px 12px; -} - -.rx-fieldset { - border: none; -} - -.rx-info-content .uk-tab a { - color: #737373; -} - -.rx-info-content .uk-tab a:hover { - color: #666; -} - -.rx-collection-title { - font-weight: bold; - color: #595959; -} - -/* Grid layout */ -.rx-grid { - display: -ms-grid; - display: grid; - margin: 15px auto; - max-width: 1400px; - -ms-grid-columns: (minmax(300px, 1fr))[auto-fill]; - grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); - grid-column-gap: 30px; - /* justify-content: space-between; */ -} - -.rx-grid-layout { - height: 425px; - width: 275px; - position: relative; - background: white; - margin-bottom: 75px; -} - -.rx-grid-image-container > img { - -o-object-fit: cover; - object-fit: cover; - height: 400px; - /* width: 200px; */ - width: 100%; - /* height: 100%; */ -} - -/* Banner Layout */ -.rx-banner-layout { - display: -ms-grid; - display: grid; - margin: 20px auto; - max-width: 100%; - /* grid-template-columns: repeat(auto-fill, minmax(100%, 1fr)); */ - -ms-grid-columns: 1fr; - grid-template-columns: 1fr; - /* grid-column-gap: 15px; */ - /* justify-content: space-between; */ -} - -.rx-volume-in-banner { - /* border: 1px solid black; */ - /* text-align: center; */ - /* height: 150px; */ - width: 100%; - margin: 15px 0; - position: relative; - background: black; -} - -.rx-banner-image-container > img { - -o-object-fit: cover; - object-fit: cover; - height: 150px; - width: 100%; - opacity: 0.4; -} - -.rx-banner-text-container { - position: absolute; - left: 0; - bottom: 0; - padding: 1rem; - color: #eeeeee; - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif; -} - -.rx-collection-title-banner { - color: white; - font-size: 1.5rem; -} - -.rx-sticky { - position: -webkit-sticky; - position: sticky; - top: 0; - z-index: 1; -} - -/*mirador overrides*/ -.mirador-container .manifest-info { - width: initial !important; -} - -.mirador-container .mirador-viewer a.mirador-icon-view-type, -.mirador-container .mirador-viewer a.mirador-icon-metadata-view, -.mirador-container .mirador-viewer a.mirador-osd-fullscreen { - padding: 5px; - margin: 0; -} - -.mirador-container .mirador-viewer { - top: -12px !important; -} - -.mirador-container .workspace-container { - left: -3px !important; -} - -.mirador-container .manifest-info { - width: auto !important; - float: right; - z-index: 2; - margin-top: 0px !important; -} - -.mirador-container .window-manifest-navigation { - float: right; - margin-left: 1em; -} - -.mirador-container .manifest-info { - padding: 0.5rem; - overflow: initial !important; -} - -.mirador-container .window-manifest-navigation { - margin-left: initial; -} - -.mirador-container .mirador-viewer a.mirador-icon-view-type, -.mirador-container .mirador-viewer a.mirador-icon-metadata-view, -.mirador-container .mirador-viewer a.mirador-osd-fullscreen { - display: -webkit-box; - display: -ms-flexbox; - display: flex; - -webkit-box-orient: horizontal; - -webkit-box-direction: normal; - -ms-flex-direction: row; - flex-direction: row; -} - -.mirador-container .mirador-viewer a.mirador-icon-view-type, -.mirador-container .mirador-viewer a.mirador-icon-metadata-view, -.mirador-container .mirador-viewer a.mirador-osd-fullscreen { - margin-right: 0 !important; - padding-bottom: 0 !important; -} - -.mirador-btn { - padding: 0.5rem; -} - -.window-manifest-navigation { - display: -webkit-box; - display: -ms-flexbox; - display: flex; - -webkit-box-pack: justify; - -ms-flex-pack: justify; - justify-content: space-between; - width: 55px; -} - -.tooltip { - position: relative; - display: inline-block; - border-bottom: 1px dotted black; -} - -.tooltip .tooltiptext { - visibility: hidden; - width: 120px; - background-color: black; - color: #fff; - text-align: center; - border-radius: 6px; - padding: 5px 0; - position: absolute; - z-index: 1; - top: 150%; - left: 50%; - margin-left: -60px; - font-size: 14px; -} - -.tooltip .tooltiptext::after { - content: ""; - position: absolute; - bottom: 100%; - left: 50%; - margin-left: -5px; - border-width: 5px; - border-style: solid; - border-color: transparent transparent black transparent; -} - -.tooltip:hover .tooltiptext { - visibility: visible; -} - -.rx-page-info-btn, -.rx-btn-secondary, -.rx-page-info-btn > .uk-icon-button { - color: var(--link-color) !important; - -webkit-filter: contrast(var(--contrast)); - filter: contrast(var(--contrast)); - opacity: 0.75; - font-size: 1rem; - font-weight: 600; -} - -.rx-page-info-btn:hover, -.rx-btn-secondary:hover, -.rx-page-info-btn > .uk-icon-button:hover { - color: white !important; -} - -.rx-offcanvas-close { - position: relative; - top: 0; - right: 0; - color: var(--link-color) !important; - -webkit-filter: contrast(var(--contrast)); - filter: contrast(var(--contrast)); -} - -.rx-offcanvas-close:hover { - color: var(--link-color) !important; - -webkit-filter: contrast(var(--contrast)); - filter: contrast(var(--contrast)); -} - -.rx-btn-secondary { - border: 3px solid var(--link-color); - -webkit-filter: contrast(var(--contrast)); - filter: contrast(var(--contrast)); - padding: 0.35em 1em; - white-space: nowrap; -} - -.rx-btn-secondary:hover { - color: white; - background-color: var(--link-color); - -webkit-filter: contrast(var(--contrast)); - filter: contrast(var(--contrast)); - opacity: 1; - -webkit-transition: all ease-in-out 100ms; - transition: all ease-in-out 100ms; -} - -.rx-page-info-btn:hover { - opacity: 1; - -webkit-transition: all ease-in-out 100ms; - transition: all ease-in-out 100ms; - color: var(--link-color); - -webkit-filter: contrast(var(--contrast)); - filter: contrast(var(--contrast)); -} - -.rx-btn-secondary:active { - background-color: var(--link-color); - border-color: var(--link-color); - -webkit-filter: brightness(75%); - filter: brightness(75%); -} - -.rx-info-container { - position: absolute; - background: white; - padding: 2rem; - z-index: 2; - right: 0; - width: 50%; - bottom: 0; - overflow: scroll; - top: 0; - -webkit-box-shadow: 0 3px 6px rgba(0, 0, 0, 0.16), 0 3px 6px rgba(0, 0, 0, 0.23); - box-shadow: 0 3px 6px rgba(0, 0, 0, 0.16), 0 3px 6px rgba(0, 0, 0, 0.23); -} - -fieldset { - margin: inherit; - border: 0; - padding: inherit; -} - -#sidenav { - background: white; - position: absolute; - top: 0; -} - -.rx-page-search-container { - width: 100%; - display: -webkit-box; - display: -ms-flexbox; - display: flex; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - -webkit-box-pack: justify; - -ms-flex-pack: justify; - justify-content: space-between; - color: #595959 !important; -} - -.rx-page-search-options, -.rx-page-search-option-label { - display: block; -} - -.flex-parent { - display: -webkit-box; - display: -ms-flexbox; - display: flex; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - padding: 10px; - margin: 30px 0; -} - -.long-and-truncated { - -webkit-box-flex: 1; - -ms-flex: 1; - flex: 1; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.short-and-fixed { - white-space: nowrap; -} - -.short-and-fixed > div { - width: 30px; - height: 30px; - border-radius: 10px; - background: lightgreen; - display: inline-block; -} - -.rx-search-icon { - color: #595959 !important; -} - -.rx-search-input { - border: none !important; - color: #595959 !important; -} - -.rx-search-input::-webkit-input-placeholder { - color: #737373 !important; -} - -.rx-search-input:-ms-input-placeholder { - color: #737373 !important; -} - -.rx-search-input::-ms-input-placeholder { - color: #737373 !important; -} - -.rx-search-input::placeholder { - color: #737373 !important; -} - -.rx-search-result { - font-size: 14px; -} - -.rx-search-result a { - color: var(--link-color) !important; - opacity: 0.7; -} - -.rx-search-result a:hover { - color: var(--link-color) !important; - opacity: 1; - -webkit-transition: all ease-in-out 100ms; - transition: all ease-in-out 100ms; -} - -.rx-title-container { - background: white; - padding: 1em 0; - position: -webkit-sticky; - position: sticky; - top: 0; - z-index: 3; -} - -.rx-title { - font-size: 2em; - font-weight: bold; - color: var(--link-color); -} - -.rx-title-2 { - font-size: 1.5em; - font-weight: bold; - color: #595959; -} - -.rx-title-tagline { - font-size: 14px; - color: #737373; -} - -.rx-title-tagline-banner { - color: #eeeeee; -} - -.rx-title-image { - -o-object-fit: cover; - object-fit: cover; - height: 150px; - width: 100%; -} - -@media (max-width: 395px) { - header#page-bg .collection-label h1 { - font-size: 0.7em; - } -} - -@media (max-width: 555px) { - header#page-bg .collection-label h1 { - font-size: 1em; - } -} - -@media (max-width: 750px) { - header#page-bg .collection-label h1 { - font-size: 1.5em; - } -} - -@media (max-width: 639px) { - .rx-offcanvas-bar { - width: 100%; - } - #offcanvas-usage { - width: 100%; - left: 0; - } - .rx-navbar-nav { - display: block; - } - .uk-logo { - padding: 0; - } -} - -@media (min-width: 640px) { - .rx-splash { - /* position: fixed; */ - display: -webkit-box; - display: -ms-flexbox; - display: flex; - -webkit-box-orient: vertical; - -webkit-box-direction: normal; - -ms-flex-direction: column; - flex-direction: column; - -ms-flex-item-align: start; - align-self: flex-start; - position: -webkit-sticky; - position: sticky; - top: 0; - -webkit-box-pack: justify; - -ms-flex-pack: justify; - justify-content: space-between; - height: calc(100vh - 200px); - } - .rx-offcanvas-bar { - width: 100%; - } - #offcanvas-usage { - width: 50%; - left: 50%; - } -} - -@media (max-width: 750px) { - .rx-page-logo { - display: none; - } - .rx-page-title-container { - padding: 0 15px 0 0; - font-size: 0.9rem; - } - .rx-btn-secondary { - border: none; - padding: none; - white-space: inherit; - margin-left: -1rem !important; - } - .rx-page-breadcrumb { - display: block; - white-space: normal; - } - .rx-page-title { - line-height: 1rem; - } -} - -@media (min-width: 768px) { - .modal-dialog > .modal-content { - top: 200px; - } -} - -@media (max-width: 960px) { - .rx-info-container { - width: 100%; - left: 0; - right: 0; - padding: 0; - } - .rx-volume-image { - margin-bottom: 1rem; - } -} - -@media (min-width: 960px) { - .rx-article { - display: -webkit-box; - display: -ms-flexbox; - display: flex; - -webkit-box-align: stretch; - -ms-flex-align: stretch; - align-items: stretch; - /* flex: 1 1 auto; */ - max-width: 680px; - width: 100%; - margin: 3rem 0; - } - .rx-article > .rx-card-image-container { - padding: 0; - } - .uk-navbar-right { - -ms-flex-wrap: initial; - flex-wrap: initial; - } -} - -@media (min-width: 320px) and (max-width: 959px) { - .readux-home-ecds { - display: none; - } - .readux-group-title { - font-size: 1.5rem; - } - .rx-splash-item { - font-size: 1.5rem; - } - .rx-title-image { - -o-object-fit: cover; - object-fit: cover; - height: 150px; - width: 100%; - } - .rx-card-image-container { - height: 150px; - width: 100%; - } - .rx-card-body { - margin: 1rem 0 2rem 0; - } - .rx-card-title { - overflow: inherit; - display: block; - } - .rx-card-text { - overflow: inherit; - display: block; - } - .rx-footer > a { - padding: 0 0 1rem 0; - } - footer { - padding: 2rem 0 0 0 !important; - } -} - -@media (max-width: 1095px) { - header#page-bg .collection-label h1 { - font-size: 2em; - } -} - -@media only screen and (max-width: 480px) { - /* Smartphone view: 1 tile */ - #box { - width: 100%; - padding-bottom: 100%; - } -} - -@media only screen and (max-width: 650px) and (min-width: 481px) { - /* Tablet view: 2 tiles */ - #box { - width: 50%; - padding-bottom: 50%; - } -} - -@media only screen and (max-width: 1050px) and (min-width: 651px) { - /* Small desktop / ipad view: 3 tiles */ - #box { - width: 33.3%; - padding-bottom: 33.3%; - } -} - -@media only screen and (max-width: 1290px) and (min-width: 1051px) { - /* Medium desktop: 4 tiles */ - #box { - width: 25%; - padding-bottom: 25%; - } -} - -@media only screen and (max-width: 1290px) and (min-width: 1051px) { - /* Medium desktop: 4 tiles */ - ul -> li -> .collectionbox -> .collectioncontainer -> .collection-label -a.nav-link { - font-size: 3em; - } -} - -@media only screen and (max-width: 1050px) and (min-width: 651px) { - /* Small desktop / ipad view: 3 tiles */ - ul -> li -> .collectionbox -> .collectioncontainer -> .collection-label -a.nav-link { - font-size: 2.5em; - } -} - -@media only screen and (max-width: 650px) and (min-width: 481px) { - /* Tablet view: 2 tiles */ - ul -> li -> .collectionbox -> .collectioncontainer -> .collection-label -a.nav-link { - font-size: 2em; - } -} - -@media only screen and (max-width: 480px) { - /* Smartphone view: 1 tile */ - ul -> li -> .collectionbox -> .collectioncontainer -> .collection-label -a.nav-link { - font-size: 1em; - } -} - -@media only screen and (max-width: 1250px) { - ol { - columns: 1; - -webkit-columns: 1; - -moz-columns: 1; - } -} - -.rx-accordion-handle { - color: var(--link-color) !important; -} - -.uk-accordion-content { - color: #595959 !important; -} - -.rx-fixed-width-100 { - min-width: 100px; - text-align: center; -} - -.rx-accordion-content { - margin: 0; - padding: 0 0 10px 0; -} - -.rx-accordion-container { - margin: 20px 0 0 0; -} - -.uk-offcanvas-bar .uk-open > .uk-accordion-title::before { - background-image: url("data:image/svg+xml;charset=UTF-8,%8Csvg+width%3D%2213%22+height%3D%2213%22+viewBox%3D%220+0+13+13%22+xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0D%0A++++%3Crect+fill%3D%22rgba%28149%2C+9%2C+83%2C+1%29%22+width%3D%2213%22+height%3D%221%22+x%3D%220%22+y%3D%226%22+%2F%3E%0D%0A%3C%2Fsvg%3E") !important; -} - -/* Search results page styles */ - -/* search form */ -form#search-form input[type="search"][name="q"] { - width: 100%; -} - -/* grid layout for filter panel and search results */ -div#search-grid { - margin-left: 0; -} -div#search-grid :first-child { - padding-left: 0; -} -div#search-grid :first-child button#reset-filters { - padding-left: 30px; -} - -/* Date published filter toggle - * Adapted from https://codepen.io/adamhutch/pen/JoKEPp - */ -div#search-grid input[type="checkbox"]#toggle-date { - font-size: 14px; - display: none; -} -div#search-grid input[type="checkbox"]#toggle-date + label { - -webkit-transition: 0.3s background; - -moz-transition: 0.3s background; - transition: 0.3s background; - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; - cursor: pointer; - position: relative; - display: inline-block; - height: 1.5em; - width: 3em; - background: #ECECEC; - border-radius: 20em; - vertical-align: middle; -} -div#search-grid input[type="checkbox"]#toggle-date + label:before, input[type=checkbox]#toggle-date + label:after { - position: absolute; - top: 0; - left: 0; -} -div#search-grid input[type="checkbox"]#toggle-date + label:before { - -webkit-transition: 0.3s left; - -moz-transition: 0.3s left; - transition: 0.3s left; - content: ""; - display: inline-block; - background: rgba(0, 0, 0, 0.5); - height: 1.125em; - width: 1.125em; - margin: 0.1875em; - border-radius: 50%; - z-index: 5; -} -div#search-grid input[type="checkbox"]#toggle-date + label:after { - color: #fff; - content: "\f00d"; - font: 1.125em "FontAwesome"; - top: 50%; - margin-top: -0.5em; - left: 1.5em; -} -div#search-grid input[type="checkbox"]#toggle-date:checked + label { - background: #3FB8AF; -} -div#search-grid input[type="checkbox"]#toggle-date:checked + label:before { - left: 1.5em; -} -div#search-grid input[type="checkbox"]#toggle-date:checked + label:after { - content: "\f00c"; - left: 0.375em; -} -div#search-grid input[type="checkbox"]#toggle-date + label { - margin: 16px 0 0; -} -div#search-grid input[type="checkbox"]#toggle-date + label span { - position: absolute; - top: 0; - left: 55px; - width: 170px; -} - -#search-filters input[type="text"]#authors-filter { - width: 100%; -} - -/* Multiselect filter */ -#search-filters select[multiple] { - height: 150px; - width: 100%; - overflow-y: scroll; - overflow-x: hidden; -} - -/* Date slider */ -#search-filters .noUi-target { - margin: 60px 20px 10px; -} - -/* Individual search results */ -ol#search-results { - list-style: none; -} -ol#search-results dl { - margin-left: 2rem; -} -/* Clamp summary to 3 lines */ -ol#search-results dd.result-volume-summary { - display: -webkit-box; - -webkit-box-orient: vertical; - -webkit-line-clamp: 3; - overflow: hidden; -} - -/*# sourceMappingURL=project.css.map */ \ No newline at end of file diff --git a/apps/static/css/project.css.map b/apps/static/css/project.css.map deleted file mode 100644 index d828da8b5..000000000 --- a/apps/static/css/project.css.map +++ /dev/null @@ -1,9 +0,0 @@ -{ - "version": 3, - "mappings": "AAOA,AAAA,KAAK,CAAC;EACJ,YAAY,CAAA,mBAAC;EACb,UAAU,CAAA,KAAC;CACZ;;AAED,AAAA,SAAS,CAAC;EACR,YAAY,EAAE,GAAG;EACjB,KAAK,EAAE,GAAG;CACX;;AAED,AAAA,QAAQ,EAAE,CAAC,CAAC;EACV,KAAK,EAAE,iBAAiB;EACxB,eAAe,EAAE,gBAAgB;CAClC;;AAED,AAAA,CAAC,AAAA,SAAS,CAAC;EACT,eAAe,EAAE,IAAI;CACtB;;AAED,AAAA,kBAAkB,CAAC;EACjB,gBAAgB,EAAE,iBAAiB;CACpC;;AAED,AAAA,kBAAkB,AAAA,MAAM;AACxB,kBAAkB,AAAA,OAAO,CAAC;EACxB,gBAAgB,EAAE,iBAAiB;EACnC,MAAM,EAAE,yBAAyB;CAClC;;AAED,AAAA,kBAAkB,CAAC;EACjB,SAAS,EAAE,OAAO;CACnB;;AAED,AAAA,OAAO,GAAG,UAAU,GAAG,CAAC,CAAC;EACvB,YAAY,EAAE,iBAAiB,CAAC,UAAU;EAC1C,KAAK,EAAE,iBAAiB,CAAC,UAAU;CACpC;;AAED,AAAA,YAAY;AACZ,SAAS,CAAC;EACR,MAAM,EAAE,yBAAyB;CAClC;;AAED,AAAA,gBAAgB,CAAC;EACf,WAAW,EAAE,IAAI;CAClB;;AAED,AAAA,aAAa,CAAC,EAAE,CAAC;EACf,UAAU,EAAE,IAAI;CACjB;;AAED,AAAA,cAAc,CAAC;EACb,WAAW,EAAE,IAAI;CAClB;;AAED,AAAA,YAAY,CAAC;EACX,KAAK,EAAE,KAAK;EACZ,gBAAgB,EAAE,KAAK;EACvB,YAAY,EAAE,OAAO;CACtB;;AAED,AAAA,YAAY,CAAC;EACX,KAAK,EAAE,OAAO;EACd,gBAAgB,EAAE,OAAO;EACzB,YAAY,EAAE,OAAO;CACtB;;AAED,AAAA,OAAO,CAAC;EACN,KAAK,EAAE,IAAI;EACX,MAAM,EAAE,kBAAkB;EAC1B,QAAQ,EAAE,QAAQ;EAClB,IAAI,EAAE,CAAC;EACP,MAAM,EAAE,GAAG;EACX,UAAU,EAAE,eAAe;CAC5B;;AAED,AAAA,cAAc,GAAG,CAAC;AAClB,cAAc,GAAG,EAAE,CAAC;EAClB,OAAO,EAAE,eAAe;CACzB;;AAED,AAAA,UAAU,CAAC;EACT,QAAQ,EAAE,mBAAmB;EAC7B,OAAO,EAAE,GAAG;CACb;;AAED,AAAA,wBAAwB,CAAC,IAAI,CAAC;EAC5B,KAAK,EAAE,WAAW;EAClB,OAAO,EAAE,sBAAsB;EAC/B,SAAS,EAAE,IAAI;EACf,WAAW,EAAE,OAAO;EACpB,WAAW,EAAE,MAAM;CACpB;;AAED,yBAAyB;AACzB,AAAA,CAAC,AAAA,oBAAoB,CAAC;EACpB,KAAK,EAAE,IAAI;EACX,OAAO,EAAE,IAAI;CACd;;AAED,AAAA,CAAC,AAAA,oBAAoB,AAAA,MAAM,CAAC;EAC1B,KAAK,EAAE,SAAS;CACjB;;AAED,AAAA,oBAAoB,AAAA,SAAS,CAAC;EAC5B,gBAAgB,EAAE,OAAO;CAC1B;;AAED,AAAA,oBAAoB,AAAA,OAAO,CAAC;EAC1B,gBAAgB,EAAE,OAAO;CAC1B;;AAED,AAAA,oBAAoB,AAAA,QAAQ,CAAC;EAC3B,gBAAgB,EAAE,OAAO;CAC1B;;AAED,AAAA,oBAAoB,AAAA,OAAO,CAAC;EAC1B,gBAAgB,EAAE,OAAO;CAC1B;;AAED,AAAA,wBAAwB,CAAC;EACvB,WAAW,EAAE,OAAO;CACrB;;AAED,gCAAgC;AAEhC,8BAA8B;AAC9B,AAAA,UAAU,CAAC,GAAG,CAAC;EACb,SAAS,EAAE,IAAI;EACf,MAAM,EAAE,IAAI;CACb;;AAED,AAAA,kBAAkB,CAAC;EACjB,QAAQ,EAAE,QAAQ;CACnB;;AAED,AAAA,kBAAkB,CAAC,MAAM;AACzB,kBAAkB,CAAC,MAAM;AACzB,kBAAkB,CAAC,KAAK,CAAC;EACvB,QAAQ,EAAE,QAAQ;EAClB,GAAG,EAAE,CAAC;EACN,IAAI,EAAE,CAAC;EACP,KAAK,EAAE,IAAI;EACX,MAAM,EAAE,IAAI;CACb;;AACD,qCAAqC;AAErC,AAAA,wBAAwB,CAAC;EACvB,MAAM,EAAE,KAAK;EACb,QAAQ,EAAE,MAAM;EAChB,UAAU,EAAE,MAAM;CACnB;;AAED,AAAA,YAAY,CAAC;EACX,SAAS,EAAE,GAAG;EACd,MAAM,EAAE,CAAC;CACV;;AAED,AAAA,MAAM,AAAA,YAAY,CAAC;EACjB,QAAQ,EAAE,QAAQ;EAClB,OAAO,EAAE,EAAE;EACX,KAAK,EAAE,IAAI;CACZ;;AAED,AAAA,MAAM,AAAA,YAAY,CAAC,UAAU,CAAC,GAAG,CAAC;EAChC,KAAK,EAAE,IAAI;CACZ;;AAED,AAAA,EAAE;AACF,EAAE;AACF,EAAE;AACF,CAAC,AAAA,UAAU,CAAC;EACV,KAAK,EAlLW,OAAO;CAmLxB;;AAED,AAAA,EAAE,CAAC;EACD,SAAS,EAAE,MAAM;CAClB;;AAED,AAAA,MAAM,AAAA,QAAQ,CAAC,sBAAsB,CAAC;EACpC,OAAO,EAAE,YAAY;EACrB,UAAU,EAAE,mBAAmB;EAC/B,QAAQ,EAAE,QAAQ;EAClB,MAAM,EAAE,CAAC;EACT,KAAK,EAAE,CAAC;EACR,OAAO,EAAE,mBAAmB;EAC5B,SAAS,EAAE,IAAI;EACf,KAAK,EAAE,OAAO;EACd,OAAO,EAAE,GAAG;EACZ,kBAAkB,EAAE,gFAC2B;EAC/C,eAAe,EAAE,gFACE;EACnB,UAAU,EAAE,gFACO;EACnB,SAAS,EAAE,KAAK;EAChB,UAAU,EAAE,IAAI;EAChB,MAAM,EAAE,OAAO;CAChB;;AAED,AAAA,MAAM,AAAA,QAAQ,CAAC,iBAAiB,CAAC,EAAE,CAAC;EAClC,SAAS,EAAE,GAAG;EACd,KAAK,EAAE,OAAO;EACd,KAAK,EAAE,IAAI;EACX,MAAM,EAAE,IAAI;EACZ,QAAQ,EAAE,QAAQ;EAClB,GAAG,EAAE,CAAC;EACN,IAAI,EAAE,GAAG;EACT,eAAe,EAAE,MAAM;EACvB,cAAc,EAAE,MAAM;EACtB,WAAW,EAAE,MAAM;EACnB,OAAO,EAAE,IAAI;CACd;;AAED,AAAA,EAAE,CAAC;EACD,OAAO,EAAE,CAAC;EACV,eAAe,EAAE,CAAC;EAClB,YAAY,EAAE,CAAC;CAChB;;AAED,AAAA,EAAE,GAAG,EAAE,CAAC;EACN,YAAY,EAAE,YAAY;EAC1B,2BAA2B,EAAE,KAAK;EAClC,iBAAiB,EAAE,KAAK;CACzB;;AAED,AAAA,EAAE,AAAA,eAAe,GAAG,EAAE,GAAG,GAAG,AAAA,gBAAgB,CAAC;EAC3C,MAAM,EAAE,KAAK;CACd;;AAED,AAAA,EAAE,GAAG,EAAE,GAAG,cAAc,CAAC;EACvB,KAAK,EAAE,IAAI;EACX,cAAc,EAAE,GAAG;EACnB,QAAQ,EAAE,QAAQ;EAClB,MAAM,EAAE,GAAG;EACX,YAAY,EAAE,KAAK;CACpB;;AAED,AAAA,EAAE,GAAG,EAAE,GAAG,cAAc,GAAG,oBAAoB,CAAC;EAC9C,QAAQ,EAAE,QAAQ;EAClB,IAAI,EAAE,CAAC;EACP,KAAK,EAAE,CAAC;EACR,GAAG,EAAE,CAAC;EACN,MAAM,EAAE,CAAC;EACT,QAAQ,EAAE,MAAM;CACjB;;AAED,AAAA,EAAE,GAAG,EAAE,GAAG,cAAc,GAAG,oBAAoB,CAAC,GAAG,CAAC;EAClD,KAAK,EAAE,IAAI;EACX,QAAQ,EAAE,MAAM;CACjB;;AAED,AAAA,EAAE,GAAG,EAAE,GAAG,cAAc,GAAG,oBAAoB,GAAG,iBAAiB,CAAC;EAClE,MAAM,EAAE,CAAC;CACV;;AAED,AAAA,EAAE,GAAG,EAAE,GAAG,cAAc,GAAG,oBAAoB,GAAG,iBAAiB,CAAC,CAAC,AAAA,SAAS,CAAC;EAC7E,SAAS,EAAE,GAAG;EACd,KAAK,EAAE,OAAO;EACd,QAAQ,EAAE,QAAQ;EAClB,IAAI,EAAE,IAAI;EACV,KAAK,EAAE,IAAI;EACX,GAAG,EAAE,GAAG;EACR,MAAM,EAAE,IAAI;EACZ,UAAU,EAAE,MAAM;EAClB,KAAK,EAAE,KAAK;CACb;;AAED,AAAA,EAAE;EACE,EAAE;EACF,cAAc;EACd,oBAAoB;EACpB,iBAAiB;AACnB,CAAC,AAAA,SAAS;AACV,IAAI,CAAC;EACL,gBAAgB,EAAE,yBAAyB;EAC3C,OAAO,EAAE,KAAK;EACd,aAAa,EAAE,IAAI;CACpB;;AAED,AAAA,WAAW,CAAC;EACV,OAAO,EAAE,YAAY;EACrB,QAAQ,EAAE,QAAQ;CACnB;;AAED,AAAA,MAAM,CAAC;EACL,QAAQ,EAAE,QAAQ;EAClB,GAAG,EAAE,CAAC;EACN,KAAK,EAAE,CAAC;EACR,WAAW,EAAE,GAAG;EAChB,aAAa,EAAE,GAAG;EAClB,MAAM,EAAE,IAAI;EACZ,KAAK,EAAE,IAAI;EACX,SAAS,EAAE,IAAI;EACf,UAAU,EAAE,MAAM;CACnB;;AAGD,AAAA,MAAM,AAAA,QAAQ,CAAC,iBAAiB,CAAC,EAAE,CAAC,IAAI,CAAC;EACvC,gBAAgB,EAAE,mBAAmB;EACrC,OAAO,EAAE,KAAK;CACf;;AAED,AAAA,MAAM,AAAA,QAAQ,CAAC,sBAAsB,AAAA,OAAO,CAAC;EAC3C,OAAO,EAAE,GAAG;EACZ,QAAQ,EAAE,QAAQ;EAClB,MAAM,EAAE,SAAS;EACjB,KAAK,EAAE,IAAI;EACX,UAAU,EAAE,MAAM;EAClB,GAAG,EAAE,CAAC;EACN,IAAI,EAAE,CAAC;EACP,MAAM,EAAE,SAAS;EACjB,MAAM,EAAE,IAAI;EACZ,WAAW,EAAE,KAAK;CACnB;;AAED,AAAA,MAAM,AAAA,QAAQ,CAAC,sBAAsB,CAAC,EAAE,CAAC;EACvC,MAAM,EAAE,CAAC;EACT,SAAS,EAAE,KAAK;EAChB,KAAK,EAAE,OAAO;CACf;;AACD,AAAA,MAAM,AAAA,QAAQ,CAAC,sBAAsB,CAAC,CAAC,CAAC;EACtC,KAAK,EAAE,IAAI;EACX,KAAK,EAAE,OAAO;EACd,UAAU,EAAE,IAAI;EAChB,aAAa,EAAE,GAAG;EAClB,UAAU,EAAE,GAAG;CAChB;;AAED,AAAA,MAAM,AAAA,QAAQ,CAAC,sBAAsB,CAAC,CAAC,AAAA,OAAO,CAAC;EAC7C,SAAS,EAAE,KAAK;CACjB;;AAED,AAAA,MAAM,AAAA,QAAQ,CAAC,sBAAsB,AAAA,UAAU,CAAC;EAC9C,OAAO,EAAE,YAAY;EACrB,gBAAgB,EAAE,sBAAsB;EACxC,OAAO,EAAE,GAAG;CACb;;AAED,AAAA,MAAM,AAAA,QAAQ,CAAC,sBAAsB,AAAA,UAAU,AAAA,MAAM,CAAC;EACpD,OAAO,EAAE,CAAC;EACV,gBAAgB,EAAE,mBAAmB;CACtC;;AAED,AAAA,MAAM,AAAA,QAAQ,CAAC,sBAAsB,AAAA,UAAU,CAAC,EAAE;AAClD,MAAM,AAAA,QAAQ,CAAC,sBAAsB,AAAA,UAAU,CAAC,KAAK,CAAC;EACpD,OAAO,EAAE,IAAI;CACd;;AAED,AAAA,KAAK,CAAC;EACJ,QAAQ,EAAE,MAAM;CACjB;;AACD,AAAA,IAAI,CAAC;EACH,KAAK,EAAE,GAAG;EACV,cAAc,EAAE,GAAG;EACnB,QAAQ,EAAE,QAAQ;EAClB,KAAK,EAAE,IAAI;CACZ;;AACD,AAAA,IAAI,CAAC,CAAC,CAAC,GAAG,CAAC;EACT,KAAK,EAAE,IAAI;EACX,QAAQ,EAAE,MAAM;CACjB;;AACD,AAAA,SAAS,CAAC;EACR,QAAQ,EAAE,QAAQ;EAClB,IAAI,EAAE,CAAC;EACP,KAAK,EAAE,CAAC;EACR,GAAG,EAAE,CAAC;EACN,MAAM,EAAE,CAAC;EACT,QAAQ,EAAE,MAAM;CACjB;;AACD,AAAA,UAAU,CAAC;EACT,QAAQ,EAAE,QAAQ;EAClB,IAAI,EAAE,CAAC;EACP,KAAK,EAAE,CAAC;EACR,GAAG,EAAE,CAAC;EACN,MAAM,EAAE,CAAC;EACT,UAAU,EAAE,KAAK;EACjB,QAAQ,EAAE,MAAM;CACjB;;AACD,AAAA,SAAS,CAAC,GAAG,CAAC;EACZ,cAAc,EAAE,SAAS;EAAE,sBAAsB;EACjD,MAAM,EAAE,SAAS;EACjB,KAAK,EAAE,IAAI;CACZ;;AAED,AAAA,UAAU,CAAC,GAAG,CAAC;EACb,cAAc,EAAE,SAAS;EAAE,sBAAsB;EACjD,MAAM,EAAE,SAAS;EACjB,KAAK,EAAE,IAAI;CACZ;;AAED,AAAA,aAAa,CAAC;EACZ,QAAQ,EAAE,QAAQ;EAClB,IAAI,EAAE,IAAI;EACV,KAAK,EAAE,IAAI;EACX,GAAG,EAAE,IAAI;EACT,MAAM,EAAE,IAAI;EACZ,UAAU,EAAE,MAAM;EAClB,KAAK,EAAE,KAAK;EACZ,UAAU,EAAE,IAAI;EAChB,OAAO,EAAE,CAAC;CACX;;AAED,AAAA,aAAa,AAAA,MAAM,CAAC;EAClB,OAAO,EAAE,CAAC;CACX;;AAED,AAAA,aAAa,CAAC,CAAC,CAAC;EACd,UAAU,EAAE,MAAM;EAClB,UAAU,EAAE,wBAAwB;EACpC,KAAK,EAAE,OAAO;EACd,aAAa,EAAE,IAAI;CACpB;;AAED,AAAA,aAAa,CAAC,CAAC,CAAC,EAAE,CAAC;EACjB,KAAK,EAAE,KAAK;CACb;;AAED,AAAA,aAAa,CAAC,CAAC,CAAC;EACd,KAAK,EAAE,KAAK;EACZ,UAAU,EAAE,wBAAwB;EACpC,eAAe,EAAE,SAAS;CAC3B;;AAED,AAAA,cAAc,CAAC;EACb,QAAQ,EAAE,QAAQ;EAClB,IAAI,EAAE,IAAI;EACV,KAAK,EAAE,IAAI;EACX,GAAG,EAAE,IAAI;EACT,MAAM,EAAE,IAAI;EACZ,UAAU,EAAE,MAAM;EAClB,KAAK,EAAE,KAAK;CACb;;AAED,AAAA,cAAc,CAAC;EACb,QAAQ,EAAE,QAAQ;EAClB,IAAI,EAAE,IAAI;EACV,KAAK,EAAE,IAAI;EACX,GAAG,EAAE,IAAI;EACT,MAAM,EAAE,IAAI;EACZ,UAAU,EAAE,MAAM;EAClB,KAAK,EAAE,KAAK;CACb;;AAED,AAAA,cAAc,CAAC,EAAE,CAAC;EAChB,UAAU,EAAE,MAAM;EAClB,UAAU,EAAE,wBAAwB;EACpC,KAAK,EAAE,OAAO;EACd,aAAa,EAAE,IAAI;CACpB;;AAED,AAAA,cAAc,CAAC,EAAE,CAAC,EAAE,CAAC;EACnB,KAAK,EAAE,KAAK;CACb;;AAED,AAAA,cAAc,CAAC,CAAC,CAAC;EACf,KAAK,EAAE,KAAK;EACZ,UAAU,EAAE,wBAAwB;EACpC,eAAe,EAAE,SAAS;CAC3B;;AAED,AAAA,oBAAoB,CAAC;EACnB,SAAS,EAAE,GAAG;EACd,WAAW,EAAE,GAAG;CACjB;;AAED,AAAA,iBAAiB,CAAC;EAChB,KAAK,EAAE,IAAI;CACZ;;AAED,AAAA,mBAAmB,CAAC;EAClB,SAAS,EAAE,GAAG;EACd,WAAW,EAAE,GAAG;CACjB;;AAED,AAAA,QAAQ,CAAC;EACP,QAAQ,EAAE,MAAM;EAChB,GAAG,EAAE,IAAI;CACV;;AAED,AAAA,cAAc,CAAC;EACb,SAAS,EAAE,MAAM;EACjB,WAAW,EAAE,GAAG;EAChB,KAAK,EAzeW,OAAO;EA0evB,WAAW,EAAE,MAAM;CACpB;;AAED,AAAA,CAAC,AAAA,MAAM,CAAC;EACN,eAAe,EAAE,kBAAkB;CACpC;;AAED,AAAA,iBAAiB;AACjB,kBAAkB,CAAC;EACjB,UAAU,EAAE,KAAK;EACjB,UAAU,EAAE,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC,mBAAmB,EAAE,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC,mBAAmB;CACzE;;AAED,AAAA,CAAC,CAAC;EACA,KAAK,EAxfW,OAAO,CAwfC,UAAU;CACnC;;AAED,AAAA,cAAc;AACd,gBAAgB;AAChB,cAAc;AACd,wBAAwB;AACxB,mBAAmB,CAAC;EAClB,KAAK,EAAE,iBAAiB;EACxB,MAAM,EAAE,gBAAgB;EACxB,cAAc,EAAE,SAAS;EACzB,WAAW,EAAE,IAAI;EACjB,cAAc,EAAE,KAAK;EACrB,OAAO,EAAE,WAAW;EACpB,OAAO,EAAE,CAAC;EACV,MAAM,EAAE,CAAC;CACV;;AAED,AAAA,wBAAwB,CAAC,CAAC,CAAC;EACzB,KAAK,EAAE,iBAAiB;CACzB;;AAED,AAAA,mBAAmB,CAAC;EAClB,WAAW,EAAE,MAAM;CACpB;;AAED,AAAA,aAAa,CAAC;EACZ,SAAS,EAAE,IAAI;CAChB;;AAED,AAAA,mBAAmB,AAAA,IAAK,CAAA,WAAW,CAAC,OAAO;AAC3C,wBAAwB,AAAA,IAAK,CAAA,WAAW,CAAC,OAAO,CAAC;EAC/C,OAAO,EAAE,GAAG;EACZ,OAAO,EAAE,OAAO;CACjB;;AAED,AAAA,wBAAwB,CAAC;EACvB,OAAO,EAAE,MAAM;EACf,aAAa,EAAE,QAAQ;EACvB,UAAU,EAAE,MAAM;CACnB;;AAED,AAAA,mBAAmB,GAAG,CAAC;AACvB,wBAAwB,GAAG,CAAC;AAC5B,cAAc;AACd,YAAY;AACZ,gBAAgB,GAAG,EAAE,GAAG,CAAC,AAAA,MAAM,CAAC;EAC9B,KAAK,EAAE,iBAAiB,CAAC,UAAU;EACnC,MAAM,EAAE,aAAa;EACrB,OAAO,EAAE,IAAI;EACb,UAAU,EAAE,qBAAqB;CAClC;;AAED,AAAA,mBAAmB,GAAG,CAAC,AAAA,MAAM;AAC7B,wBAAwB,GAAG,CAAC,AAAA,MAAM;AAClC,cAAc,AAAA,MAAM;AACpB,YAAY,AAAA,MAAM;AAClB,gBAAgB,GAAG,EAAE,AAAA,UAAU,GAAG,CAAC,CAAC;EAClC,KAAK,EAAE,iBAAiB;EACxB,OAAO,EAAE,CAAC;EACV,UAAU,EAAE,qBAAqB;CAClC;;AAED,AAAA,kBAAkB,CAAC;EACjB,MAAM,EAAE,SAAS;CAClB;;AAED,AAAA,QAAQ,CAAC;EACP,MAAM,EAAE,MAAM;CACf;;AAED,AAAA,cAAc,CAAC;EACb,MAAM,EAAE,mBAAmB;EAC3B,OAAO,EAAE,eAAe;EACxB,KAAK,EAlkBW,OAAO;EAmkBvB,cAAc,EAAE,MAAM;EACtB,cAAc,EAAE,SAAS;EACzB,SAAS,EAAE,OAAO;EAClB,WAAW,EAAE,IAAI;CAClB;;AAED,AAAA,0BAA0B,CAAC;EACzB,MAAM,EAAE,mBAAmB;EAC3B,OAAO,EAAE,OAAO;EAChB,UAAU,EAAE,UAAU;CACvB;;AAED,AAAA,gBAAgB,CAAC;EACf,OAAO,EAAE,OAAO;EAChB,SAAS,EAAE,MAAM;CAClB;;AAED,AAAA,sBAAsB,CAAC;EACrB,KAAK,EArlBW,OAAO,CAqlBC,UAAU;EAClC,WAAW,EAAE,cAAc;EAC3B,WAAW,EAAE,cAAc;EAC3B,SAAS,EAAE,iBAAiB;CAC7B;;AAED,AAAA,sBAAsB,CAAC;EACrB,KAAK,EA5lBW,OAAO;EA6lBvB,WAAW,EAAE,GAAG;CACjB;;AAED,AAAA,aAAa,CAAC;EACZ,UAAU,EAAE,IAAI;EAChB,SAAS,EAAE,CAAC;EACZ,IAAI,EAAE,QAAQ;EACd,wBAAwB;CACzB;;AAED,AAAA,wBAAwB,CAAC;EACvB,mBAAmB;EACnB,IAAI,EAAE,QAAQ;CACf;;AAED,AAAA,cAAc,CAAC;EACb,iBAAiB,EAAE,qBAAqB;EACxC,eAAe,EAAE,gBAAgB;EACjC,KAAK,EAAE,eAAe;EACtB,MAAM,EAAE,eAAe;EACvB,OAAO,EAAE,gBAAgB;EACzB,KAAK,EAAE,OAAO;EACd,eAAe,EAAE,IAAI;EACrB,gBAAgB,EAAE,IAAI;CACvB;;AAED,AAAA,aAAa,CAAC;EACZ,OAAO,EAAE,CAAC;EACV,KAAK,EAznBW,OAAO;EA0nBvB,MAAM,EAAE,OAAO;EACf,QAAQ,EAAE,MAAM;EAChB,aAAa,EAAE,QAAQ;EACvB,OAAO,EAAE,WAAW;EACpB,kBAAkB,EAAE,CAAC;EAAE,6BAA6B;EACpD,kBAAkB,EAAE,QAAQ;EAC5B,SAAS,EAAE,IAAI;CAChB;;AAED,AAAA,cAAc,CAAC;EACb,QAAQ,EAAE,MAAM;EAChB,aAAa,EAAE,QAAQ;EACvB,OAAO,EAAE,WAAW;EACpB,kBAAkB,EAAE,CAAC;EAAE,6BAA6B;EACpD,kBAAkB,EAAE,QAAQ;CAC7B;;AAED,AAAA,cAAc,CAAC;EACb,QAAQ,EAAE,IAAI;CACf;;AAED,AAAA,OAAO,CAAC;EACN;;;;yBAIuB;EACvB;cACY;EACZ,UAAU,EAAE,KAAK;CAClB;;AAED,AAAA,cAAc,CAAC;EACb,WAAW,EAAE,GAAG;CACjB;;AAED,AAAA,OAAO,CAAC;EACN,aAAa,EAAE,GAAG;EAClB,MAAM,EAAE,KAAK,CAAC,GAAG,CAAC,iBAAiB;EACnC,MAAM,EAAE,yBAAyB;EACjC,KAAK,EAAE,iBAAiB;EACxB,MAAM,EAAE,yBAAyB;EACjC,WAAW,EAAE,4JAEM;EACnB,SAAS,EAAE,IAAI;EACf,WAAW,EAAE,GAAG;EAChB,WAAW,EAAE,IAAI;EACjB,cAAc,EAAE,MAAM;EACtB,UAAU,EAAE,MAAM;EAClB,OAAO,EAAE,QAAQ;EACjB,OAAO,EAAE,YAAY;CAUtB;;AAzBD,AAiBE,OAjBK,AAiBJ,MAAM,CAAC;EACN,MAAM,EAAE,KAAK,CAAC,GAAG,CAAC,iBAAiB;EACnC,KAAK,EAAE,KAAK;EACZ,UAAU,EAAE,iBAAiB;EAC7B,MAAM,EAAE,yBAAyB;EACjC,UAAU,EAAE,qBAAqB;EACjC,MAAM,EAAE,OAAO;CAChB;;AAGH,AAAA,iBAAiB,CAAC;EAChB,aAAa,EAAE,GAAG;EAClB,MAAM,EAAE,KAAK,CAAC,GAAG,CAAC,iBAAiB;EACnC,MAAM,EAAE,yBAAyB;EACjC,KAAK,EAAE,KAAK;EACZ,WAAW,EAAE,4JAEM;EACnB,SAAS,EAAE,IAAI;EACf,WAAW,EAAE,GAAG;EAChB,WAAW,EAAE,IAAI;EACjB,cAAc,EAAE,SAAS;EACzB,cAAc,EAAE,KAAK;EACrB,UAAU,EAAE,MAAM;EAClB,OAAO,EAAE,QAAQ;EACjB,OAAO,EAAE,YAAY;EACrB,UAAU,EAAE,iBAAiB;EAC7B,KAAK,EAAE,KAAK;EACZ,MAAM,EAAE,KAAK,CAAC,GAAG,CAAC,iBAAiB;CAUpC;;AA5BD,AAoBE,iBApBe,AAoBd,MAAM,CAAC;EACN,UAAU,EAAE,OAAO;EACnB,KAAK,EAAE,KAAK;EACZ,MAAM,EAAE,iBAAiB;EACzB,UAAU,EAAE,qBAAqB;EACjC,OAAO,EAAE,CAAC;EACV,MAAM,EAAE,OAAO;CAChB;;AAeH,AAAA,oBAAoB,CAAC;EACnB,aAAa,EAAE,GAAG;EAClB,UAAU,EAAE,KAAK;EACjB,KAAK,EAtuBW,OAAO,CAsuBC,UAAU;EAClC,MAAM,EAvuBU,OAAO,CAuuBE,GAAG,CAAC,KAAK;EAClC,SAAS,EAAE,OAAO;EAClB,cAAc,EAAE,MAAM;EACtB,WAAW,EAAE,GAAG;EAChB,OAAO,EAAE,YAAY;EACrB,MAAM,EAAE,OAAO;EACf,cAAc,EAAE,SAAS;EACzB,OAAO,EAAE,aAAa;CACvB;;AAED,AAAA,qBAAqB,CAAC;EACpB,cAAc,EAAE,IAAI;CACrB;;AAED,AAAA,UAAU,GAAG,kBAAkB,CAAC;EAC9B,gBAAgB,EAnvBE,OAAO,CAmvBY,UAAU;EAC/C,KAAK,EAAE,KAAK;CACb;;AAED,AAAA,aAAa,CAAC;EACZ,GAAG,EAAE,KAAK;EACV,QAAQ,EAAE,OAAO;CAClB;;AAED,AAAA,mBAAmB,AAAA,IAAK,CAAA,YAAY,EAAE;EACpC,UAAU,EAAE,IAAI;CACjB;;AAED,AAAA,UAAU,CAAC;EACT,OAAO,EAAE,IAAI;EACb,UAAU,EAAE,IAAI;EAChB,WAAW,EAAE,IAAI;CAYlB;;AAfD,AAIE,UAJQ,GAIJ,CAAC,CAAC;EACJ,OAAO,EAAE,IAAI;EACb,KAAK,EAxwBS,OAAO;EAywBrB,UAAU,EAAE,qBAAqB;CAOlC;;AAdH,AAQI,UARM,GAIJ,CAAC,AAIF,YAAY,CAAC;EACZ,YAAY,EAAE,CAAC;CAChB;;AAVL,AAWI,UAXM,GAIJ,CAAC,AAOF,MAAM,CAAC;EACN,KAAK,EAAE,IAAI;CACZ;;AAIL,AAAA,UAAU,CAAC;EACT,KAAK,EAAE,iBAAiB,CAAC,UAAU;EACnC,OAAO,EAAE,IAAI;EACb,UAAU,EAAE,qBAAqB;CAMlC;;AATD,AAKE,UALQ,AAKP,MAAM,CAAC;EACN,KAAK,EAAE,iBAAiB;EACxB,OAAO,EAAE,CAAC;CACX;;AAGH,AAAA,WAAW,CAAC;EACV,gBAAgB,EAhyBA,OAAO;CAiyBxB;;AAED,AAAA,cAAc,CAAC;EACb,UAAU,EAAE,KAAK;EACjB,KAAK,EAAE,iBAAiB,CAAC,UAAU;EACnC,MAAM,EAAE,iBAAiB,CAAC,GAAG,CAAC,KAAK;EACnC,SAAS,EAAE,OAAO;EAClB,cAAc,EAAE,MAAM;EACtB,OAAO,EAAE,IAAI;EACb,MAAM,EAAE,OAAO;EACf,UAAU,EAAE,qBAAqB;EACjC,WAAW,EAAE,GAAG;CAOjB;;AAhBD,AAWE,cAXY,AAWX,OAAO,CAAC;EACP,gBAAgB,EA3yBA,OAAO,CA2yBc,UAAU;EAC/C,MAAM,EAAE,GAAG,CAAC,KAAK,CA5yBD,OAAO,CA4yBc,UAAU;EAC/C,OAAO,EAAE,CAAC;CACX;;AAGH,AAAA,qBAAqB,GAAG,CAAC,CAAC;EACxB,WAAW,EAAE,GAAG;CACjB;;AAED,AAAA,cAAc,AAAA,MAAM,CAAC;EACnB,UAAU,EAAE,iBAAiB;EAC7B,KAAK,EAAE,gBAAgB;EACvB,SAAS,EAAE,OAAO;EAClB,cAAc,EAAE,MAAM;EACtB,MAAM,EAAE,OAAO;EACf,OAAO,EAAE,CAAC;CACX;;AAED,AAAA,QAAQ,CAAC;EACP,OAAO,EAAE,IAAI;CACd;;AAED,AAAA,kBAAkB,CAAC;EACjB,UAAU,EAAE,MAAM;EAClB,OAAO,EAAE,CAAC;EACV,UAAU,EAAE,mCAAmC;CAChD;;AAED,AAAA,QAAQ,CAAC;EACP,MAAM,EAAE,SAAS;EACjB,SAAS,EAAE,MAAM;EACjB,OAAO,EAAE,IAAI;EACb,SAAS,EAAE,IAAI;CAChB;;AAED,AAAA,OAAO,CAAC;EACN,MAAM,EAAE,eAAe;EACvB,IAAI,EAAE,SAAS;EACf,UAAU,EAAE,MAAM;EAClB,OAAO,EAAE,IAAI;CACd;;AAED,AAAA,YAAY,CAAC;EACX,QAAQ,EAAE,QAAQ;EAClB,GAAG,EAAE,GAAG;EACR,IAAI,EAAE,IAAI;CACX;;AAED,AAAA,mBAAmB,GAAG,MAAM,CAAC;EAC3B,MAAM,EAAE,IAAI;EACZ,mBAAmB,EAAE,MAAM;EAC3B,eAAe,EAAE,KAAK;EACtB,KAAK,EAAE,IAAI;EACX,MAAM,EAAE,KAAK;CACd;;AAED,AAAA,aAAa,CAAC;EACZ,UAAU,EAAE,OAAO;EACnB,OAAO,EAAE,IAAI;EACb,SAAS,EAAE,IAAI;EACf,KAAK,EAAE,iBAAiB;CACzB;;AAED,AAAA,CAAC,AAAA,YAAY,CAAC;EACZ,KAAK,EAAE,iBAAiB,CAAC,UAAU;CACpC;;AAED,AAAA,YAAY,AAAA,UAAU,CAAC;EACrB,KAAK,EAAE,iBAAiB;CACzB;;AAED,AAAA,aAAa,CAAC;EACZ,SAAS,EAAE,IAAI;EACf,WAAW,EAAE,IAAI;EACjB,cAAc,EAAE,MAAM;EACtB,UAAU,EAAE,MAAM;EAClB,OAAO,EAAE,QAAQ;CAClB;;AAED,AAAA,YAAY,CAAC;EACX,MAAM,EAAE,IAAI;CACb;;AAED,AAAA,gBAAgB,CAAC,OAAO,CAAC,CAAC,CAAC;EACzB,KAAK,EAn4BW,OAAO;CAo4BxB;;AAED,AAAA,gBAAgB,CAAC,OAAO,CAAC,CAAC,AAAA,MAAM,CAAC;EAC/B,KAAK,EAAE,IAAI;CACZ;;AAID,AAAA,oBAAoB,CAAC;EACnB,WAAW,EAAE,IAAI;EACjB,KAAK,EA/4BW,OAAO;CAg5BxB;;AAED,iBAAiB;AAEjB,AAAA,QAAQ,CAAC;EACP,OAAO,EAAE,IAAI;EACb,MAAM,EAAE,SAAS;EACjB,SAAS,EAAE,MAAM;EACjB,qBAAqB,EAAE,qCAAqC;EAC5D,eAAe,EAAE,IAAI;EACrB,qCAAqC;CACtC;;AAED,AAAA,eAAe,CAAC;EACd,MAAM,EAAE,KAAK;EACb,KAAK,EAAE,KAAK;EACZ,QAAQ,EAAE,QAAQ;EAClB,UAAU,EAAE,KAAK;EACjB,aAAa,EAAE,IAAI;CACpB;;AAED,AAAA,wBAAwB,GAAG,GAAG,CAAC;EAC7B,UAAU,EAAE,KAAK;EACjB,MAAM,EAAE,KAAK;EACb,mBAAmB;EACnB,KAAK,EAAE,IAAI;EACX,mBAAmB;CACpB;;AAED,mBAAmB;AACnB,AAAA,iBAAiB,CAAC;EAChB,OAAO,EAAE,IAAI;EACb,MAAM,EAAE,SAAS;EACjB,SAAS,EAAE,IAAI;EACf,kEAAkE;EAClE,qBAAqB,EAAE,GAAG;EAC1B,4BAA4B;EAC5B,qCAAqC;CACtC;;AAED,AAAA,oBAAoB,CAAC;EACnB,8BAA8B;EAC9B,yBAAyB;EACzB,oBAAoB;EACpB,KAAK,EAAE,IAAI;EACX,MAAM,EAAE,MAAM;EACd,QAAQ,EAAE,QAAQ;EAClB,UAAU,EAAE,KAAK;CAClB;;AAED,AAAA,0BAA0B,GAAG,GAAG,CAAC;EAC/B,UAAU,EAAE,KAAK;EACjB,MAAM,EAAE,KAAK;EACb,KAAK,EAAE,IAAI;EACX,OAAO,EAAE,GAAG;CACb;;AAED,AAAA,yBAAyB,CAAC;EACxB,QAAQ,EAAE,QAAQ;EAClB,IAAI,EAAE,CAAC;EACP,MAAM,EAAE,CAAC;EACT,OAAO,EAAE,IAAI;EACb,KAAK,EA58BW,OAAO;EA68BvB,WAAW,EAAE,2HACiD;CAC/D;;AAED,AAAA,2BAA2B,CAAC;EAC1B,KAAK,EAAE,KAAK;EACZ,SAAS,EAAE,MAAM;CAClB;;AAED,AAAA,UAAU,CAAC;EACT,QAAQ,EAAE,MAAM;EAChB,GAAG,EAAE,CAAC;EACN,OAAO,EAAE,CAAC;CACX;;AAED,qBAAqB;AACrB,AAAA,kBAAkB,CAAC,cAAc,CAAC;EAChC,KAAK,EAAE,kBAAkB;CAC1B;;AAED,AAAA,kBAAkB,CAAC,eAAe,CAAC,CAAC,AAAA,uBAAuB;AAC3D,kBAAkB,CAAC,eAAe,CAAC,CAAC,AAAA,2BAA2B;AAC/D,kBAAkB,CAAC,eAAe,CAAC,CAAC,AAAA,uBAAuB,CAAC;EAC1D,OAAO,EAAE,GAAG;EACZ,MAAM,EAAE,CAAC;CACV;;AAED,AAAA,kBAAkB,CAAC,eAAe,CAAC;EACjC,GAAG,EAAE,gBAAgB;CACtB;;AACD,AAAA,kBAAkB,CAAC,oBAAoB,CAAC;EACtC,IAAI,EAAE,eAAe;CACtB;;AAED,AAAA,kBAAkB,CAAC,cAAc,CAAC;EAChC,KAAK,EAAE,eAAe;EACtB,KAAK,EAAE,KAAK;EACZ,OAAO,EAAE,CAAC;CACX;;AAED,AAAA,kBAAkB,CAAC,2BAA2B,CAAC;EAC7C,KAAK,EAAE,KAAK;EACZ,WAAW,EAAE,GAAG;CACjB;;AAED,AAAA,kBAAkB,CAAC,cAAc,CAAC;EAChC,OAAO,EAAE,MAAM;EACf,QAAQ,EAAE,kBAAkB;CAC7B;;AAED,AAAA,kBAAkB,CAAC,2BAA2B,CAAC;EAC7C,WAAW,EAAE,OAAO;CACrB;;AAED,AAAA,kBAAkB,CAAC,eAAe,CAAC,CAAC,AAAA,uBAAuB;AAC3D,kBAAkB,CAAC,eAAe,CAAC,CAAC,AAAA,2BAA2B;AAC/D,kBAAkB,CAAC,eAAe,CAAC,CAAC,AAAA,uBAAuB,CAAC;EAC1D,OAAO,EAAE,IAAI;EACb,cAAc,EAAE,GAAG;CACpB;;AAED,AAAA,kBAAkB,CAAC,eAAe,CAAC,CAAC,AAAA,uBAAuB;AAC3D,kBAAkB,CAAC,eAAe,CAAC,CAAC,AAAA,2BAA2B;AAC/D,kBAAkB,CAAC,eAAe,CAAC,CAAC,AAAA,uBAAuB,CAAC;EAC1D,YAAY,EAAE,YAAY;EAC1B,cAAc,EAAE,YAAY;CAC7B;;AAED,AAAA,YAAY,CAAC;EACX,OAAO,EAAE,MAAM;CAChB;;AAED,AAAA,2BAA2B,CAAC;EAC1B,OAAO,EAAE,IAAI;EACb,eAAe,EAAE,aAAa;EAC9B,KAAK,EAAE,IAAI;CACZ;;AAED,AAAA,QAAQ,CAAC;EACP,QAAQ,EAAE,QAAQ;EAClB,OAAO,EAAE,YAAY;EACrB,aAAa,EAAE,gBAAgB;CAChC;;AAED,AAAA,QAAQ,CAAC,YAAY,CAAC;EACpB,UAAU,EAAE,MAAM;EAClB,KAAK,EAAE,KAAK;EACZ,gBAAgB,EAAE,KAAK;EACvB,KAAK,EAAE,IAAI;EACX,UAAU,EAAE,MAAM;EAClB,aAAa,EAAE,GAAG;EAClB,OAAO,EAAE,KAAK;EACd,QAAQ,EAAE,QAAQ;EAClB,OAAO,EAAE,CAAC;EACV,GAAG,EAAE,IAAI;EACT,IAAI,EAAE,GAAG;EACT,WAAW,EAAE,KAAK;EAClB,SAAS,EAAE,IAAI;CAChB;;AAED,AAAA,QAAQ,CAAC,YAAY,AAAA,OAAO,CAAC;EAC3B,OAAO,EAAE,EAAE;EACX,QAAQ,EAAE,QAAQ;EAClB,MAAM,EAAE,IAAI;EACZ,IAAI,EAAE,GAAG;EACT,WAAW,EAAE,IAAI;EACjB,YAAY,EAAE,GAAG;EACjB,YAAY,EAAE,KAAK;EACnB,YAAY,EAAE,yCAAyC;CACxD;;AAED,AAAA,QAAQ,AAAA,MAAM,CAAC,YAAY,CAAC;EAC1B,UAAU,EAAE,OAAO;CACpB;;AAED,AAAA,iBAAiB;AACjB,iBAAiB;AACjB,iBAAiB,GAAG,eAAe,CAAC;EAClC,KAAK,EAAE,iBAAiB,CAAC,UAAU;EACnC,MAAM,EAAE,yBAAyB;EACjC,OAAO,EAAE,IAAI;EACb,SAAS,EAAE,IAAI;EACf,WAAW,EAAE,GAAG;CACjB;;AAED,AAAA,iBAAiB,AAAA,MAAM;AACvB,iBAAiB,AAAA,MAAM;AACvB,iBAAiB,GAAG,eAAe,AAAA,MAAM,CAAC;EACxC,KAAK,EAAE,gBAAgB;CACxB;;AAED,AAAA,mBAAmB,CAAC;EAClB,QAAQ,EAAE,QAAQ;EAClB,GAAG,EAAE,CAAC;EACN,KAAK,EAAE,CAAC;EACR,KAAK,EAAE,iBAAiB,CAAC,UAAU;EACnC,MAAM,EAAE,yBAAyB;CAClC;;AAED,AAAA,mBAAmB,AAAA,MAAM,CAAC;EACxB,KAAK,EAAE,iBAAiB,CAAC,UAAU;EACnC,MAAM,EAAE,yBAAyB;CAClC;;AAED,AAAA,iBAAiB,CAAC;EAChB,MAAM,EAAE,GAAG,CAAC,KAAK,CAAC,iBAAiB;EACnC,MAAM,EAAE,yBAAyB;EACjC,OAAO,EAAE,UAAU;EACnB,WAAW,EAAE,MAAM;CACpB;;AAED,AAAA,iBAAiB,AAAA,MAAM,CAAC;EACtB,KAAK,EAAE,KAAK;EACZ,gBAAgB,EAAE,iBAAiB;EACnC,MAAM,EAAE,yBAAyB;EACjC,OAAO,EAAE,CAAC;EACV,UAAU,EAAE,qBAAqB;CAClC;;AAED,AAAA,iBAAiB,AAAA,MAAM,CAAC;EACtB,OAAO,EAAE,CAAC;EACV,UAAU,EAAE,qBAAqB;EACjC,KAAK,EAAE,iBAAiB;EACxB,MAAM,EAAE,yBAAyB;CAClC;;AAED,AAAA,iBAAiB,AAAA,OAAO,CAAC;EACvB,gBAAgB,EAAE,iBAAiB;EACnC,YAAY,EAAE,iBAAiB;EAC/B,MAAM,EAAE,eAAe;CACxB;;AAED,AAAA,kBAAkB,CAAC;EACjB,QAAQ,EAAE,QAAQ;EAClB,UAAU,EAAE,KAAK;EACjB,OAAO,EAAE,IAAI;EACb,OAAO,EAAE,CAAC;EACV,KAAK,EAAE,CAAC;EACR,KAAK,EAAE,GAAG;EACV,MAAM,EAAE,CAAC;EACT,QAAQ,EAAE,MAAM;EAChB,GAAG,EAAE,CAAC;EACN,UAAU,EAAE,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC,mBAAmB,EAAE,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC,mBAAmB;CACzE;;AAID,AAAA,QAAQ,CAAC;EACP,MAAM,EAAE,OAAO;EACf,MAAM,EAAE,CAAC;EACT,OAAO,EAAE,OAAO;CACjB;;AAED,AAAA,QAAQ,CAAC;EACP,UAAU,EAAE,KAAK;EACjB,QAAQ,EAAE,QAAQ;EAClB,GAAG,EAAE,CAAC;CACP;;AAGD,AAAA,yBAAyB,CAAC;EACxB,KAAK,EAAE,IAAI;EACX,OAAO,EAAE,IAAI;EACb,WAAW,EAAE,MAAM;EACnB,eAAe,EAAE,aAAa;EAC9B,KAAK,EA5pCW,OAAO,CA4pCC,UAAU;CACnC;;AAED,AAAA,uBAAuB;AACvB,4BAA4B,CAAC;EAC3B,OAAO,EAAE,KAAK;CACf;;AAID,AAAA,YAAY,CAAC;EACX,OAAO,EAAE,WAAW;EACpB,OAAO,EAAE,IAAI;EACb,iBAAiB,EAAE,MAAM;EACzB,WAAW,EAAE,MAAM;EACnB,OAAO,EAAE,IAAI;EACb,MAAM,EAAE,MAAM;CACf;;AAED,AAAA,mBAAmB,CAAC;EAClB,gBAAgB,EAAE,CAAC;EACnB,IAAI,EAAE,CAAC;EACP,WAAW,EAAE,MAAM;EACnB,QAAQ,EAAE,MAAM;EAChB,aAAa,EAAE,QAAQ;CACxB;;AAED,AAAA,gBAAgB,CAAC;EACf,WAAW,EAAE,MAAM;CACpB;;AAED,AAAA,gBAAgB,GAAG,GAAG,CAAC;EACrB,KAAK,EAAE,IAAI;EACX,MAAM,EAAE,IAAI;EACZ,aAAa,EAAE,IAAI;EACnB,UAAU,EAAE,UAAU;EACtB,OAAO,EAAE,YAAY;CACtB;;AAID,AAAA,eAAe,CAAC;EACd,KAAK,EAtsCW,OAAO,CAssCC,UAAU;CACnC;;AAED,AAAA,gBAAgB,CAAC;EACf,MAAM,EAAE,eAAe;EACvB,KAAK,EA3sCW,OAAO,CA2sCC,UAAU;CAKnC;;AAPD,AAGE,gBAHc,AAGb,aAAa,CAAC;EACb,KAAK,EA5sCS,OAAO,CA4sCG,UAAU;CACnC;;AAIH,AAAA,iBAAiB,CAAC;EAChB,SAAS,EAAE,IAAI;CAUhB;;AAXD,AAEE,iBAFe,CAEf,CAAC,CAAC;EACA,KAAK,EAAE,iBAAiB,CAAC,UAAU;EACnC,OAAO,EAAE,GAAG;CAMb;;AAVH,AAKI,iBALa,CAEf,CAAC,AAGE,MAAM,CAAC;EACN,KAAK,EAAE,iBAAiB,CAAC,UAAU;EACnC,OAAO,EAAE,CAAC;EACV,UAAU,EAAE,qBAAqB;CAClC;;AAML,AAAA,mBAAmB,CAAC;EAClB,UAAU,EAAE,KAAK;EACjB,OAAO,EAAE,KAAK;EACd,QAAQ,EAAE,MAAM;EAChB,GAAG,EAAE,CAAC;EACN,OAAO,EAAE,CAAC;CACX;;AAED,AAAA,SAAS,CAAC;EACR,SAAS,EAAE,GAAG;EACd,WAAW,EAAE,IAAI;EACjB,KAAK,EAAE,iBAAiB;CACzB;;AAED,AAAA,WAAW,CAAC;EACV,SAAS,EAAE,KAAK;EAChB,WAAW,EAAE,IAAI;EACjB,KAAK,EAlvCW,OAAO;CAmvCxB;;AAED,AAAA,iBAAiB,CAAC;EAChB,SAAS,EAAE,IAAI;EACf,KAAK,EAtvCW,OAAO;CAuvCxB;;AAED,AAAA,wBAAwB,CAAC;EACvB,KAAK,EAAE,OAAO;CACf;;AAED,AAAA,eAAe,CAAC;EACd,UAAU,EAAE,KAAK;EACjB,MAAM,EAAE,KAAK;EACb,KAAK,EAAE,IAAI;CACZ;;AAGD,MAAM,EAAE,SAAS,EAAE,KAAK;EACtB,AAAA,MAAM,AAAA,QAAQ,CAAC,iBAAiB,CAAC,EAAE,CAAC;IAClC,SAAS,EAAE,KAAK;GACjB;;;AAGH,MAAM,EAAE,SAAS,EAAE,KAAK;EACtB,AAAA,MAAM,AAAA,QAAQ,CAAC,iBAAiB,CAAC,EAAE,CAAC;IAClC,SAAS,EAAE,GAAG;GACf;;;AAGH,MAAM,EAAE,SAAS,EAAE,KAAK;EACtB,AAAA,MAAM,AAAA,QAAQ,CAAC,iBAAiB,CAAC,EAAE,CAAC;IAClC,SAAS,EAAE,KAAK;GACjB;;;AAGH,MAAM,EAAE,SAAS,EAAE,KAAK;EACtB,AAAA,iBAAiB,CAAC;IAChB,KAAK,EAAE,IAAI;GACZ;EACD,AAAA,gBAAgB,CAAC;IACf,KAAK,EAAE,IAAI;IACX,IAAI,EAAE,CAAC;GACR;EAED,AAAA,cAAc,CAAC;IACb,OAAO,EAAE,KAAK;GACf;EAED,AAAA,QAAQ,CAAC;IACP,OAAO,EAAE,CAAC;GACX;;;AAGH,MAAM,EAAE,SAAS,EAAE,KAAK;EACtB,AAAA,UAAU,CAAC;IACT,sBAAsB;IACtB,OAAO,EAAE,IAAI;IACb,cAAc,EAAE,MAAM;IACtB,UAAU,EAAE,UAAU;IACtB,QAAQ,EAAE,MAAM;IAChB,GAAG,EAAE,CAAC;IACN,eAAe,EAAE,aAAa;IAC9B,MAAM,EAAE,mBAAmB;GAC5B;EAED,AAAA,iBAAiB,CAAC;IAChB,KAAK,EAAE,IAAI;GACZ;EAED,AAAA,gBAAgB,CAAC;IACf,KAAK,EAAE,GAAG;IACV,IAAI,EAAE,GAAG;GACV;;;AAGH,MAAM,EAAE,SAAS,EAAE,KAAK;EACtB,AAAA,aAAa,CAAC;IACZ,OAAO,EAAE,IAAI;GACd;EACD,AAAA,wBAAwB,CAAC;IACvB,OAAO,EAAE,UAAU;IACnB,SAAS,EAAE,MAAM;GAClB;EAED,AAAA,iBAAiB,CAAC;IAChB,MAAM,EAAE,IAAI;IACZ,OAAO,EAAE,IAAI;IACb,WAAW,EAAE,OAAO;IACpB,WAAW,EAAE,gBAAgB;GAC9B;EAED,AAAA,mBAAmB,CAAC;IAClB,OAAO,EAAE,KAAK;IACd,WAAW,EAAE,MAAM;GACpB;EAED,AAAA,cAAc,CAAC;IACb,WAAW,EAAE,IAAI;GAClB;;;AAGH,MAAM,EAAE,SAAS,EAAE,KAAK;EACtB,AAAA,aAAa,GAAG,cAAc,CAAC;IAC7B,GAAG,EAAE,KAAK;GACX;;;AAGH,MAAM,EAAE,SAAS,EAAE,KAAK;EACtB,AAAA,kBAAkB,CAAC;IACjB,KAAK,EAAE,IAAI;IACX,IAAI,EAAE,CAAC;IACP,KAAK,EAAE,CAAC;IACR,OAAO,EAAE,CAAC;GACX;EAED,AAAA,gBAAgB,CAAC;IACf,aAAa,EAAE,IAAI;GACpB;;;AAGH,MAAM,EAAE,SAAS,EAAE,KAAK;EACtB,AAAA,WAAW,CAAC;IACV,OAAO,EAAE,IAAI;IACb,WAAW,EAAE,OAAO;IACpB,qBAAqB;IACrB,SAAS,EAAE,KAAK;IAChB,KAAK,EAAE,IAAI;IACX,MAAM,EAAE,MAAM;GACf;EAED,AAAA,WAAW,GAAG,wBAAwB,CAAC;IACrC,OAAO,EAAE,CAAC;GACX;EAED,AAAA,gBAAgB,CAAC;IACf,SAAS,EAAE,OAAO;GACnB;;;AAGH,MAAM,EAAE,SAAS,EAAE,KAAK,OAAO,SAAS,EAAE,KAAK;EAC7C,AAAA,iBAAiB,CAAC;IAChB,OAAO,EAAE,IAAI;GACd;EAED,AAAA,mBAAmB,CAAC;IAClB,SAAS,EAAE,MAAM;GAClB;EAED,AAAA,eAAe,CAAC;IACd,SAAS,EAAE,MAAM;GAClB;EAED,AAAA,eAAe,CAAC;IACd,UAAU,EAAE,KAAK;IACjB,MAAM,EAAE,KAAK;IACb,KAAK,EAAE,IAAI;GACZ;EAED,AAAA,wBAAwB,CAAC;IACvB,MAAM,EAAE,KAAK;IACb,KAAK,EAAE,IAAI;GACZ;EAED,AAAA,aAAa,CAAC;IACZ,MAAM,EAAE,aAAa;GACtB;EAED,AAAA,cAAc,CAAC;IACb,QAAQ,EAAE,OAAO;IACjB,OAAO,EAAE,KAAK;GACf;EAED,AAAA,aAAa,CAAC;IACZ,QAAQ,EAAE,OAAO;IACjB,OAAO,EAAE,KAAK;GACf;EAED,AAAA,UAAU,GAAG,CAAC,CAAC;IACb,OAAO,EAAE,UAAU;GACpB;EAED,AAAA,MAAM,CAAC;IACL,OAAO,EAAE,qBAAqB;GAC/B;;;AAGH,MAAM,EAAE,SAAS,EAAE,MAAM;EACvB,AAAA,MAAM,AAAA,QAAQ,CAAC,iBAAiB,CAAC,EAAE,CAAC;IAClC,SAAS,EAAE,GAAG;GACf;;;AAGH,MAAM,MAAM,MAAM,MAAM,SAAS,EAAE,KAAK;EACtC,6BAA6B;EAC7B,AAAA,IAAI,CAAC;IACH,KAAK,EAAE,IAAI;IACX,cAAc,EAAE,IAAI;GACrB;;;AAGH,MAAM,MAAM,MAAM,MAAM,SAAS,EAAE,KAAK,OAAO,SAAS,EAAE,KAAK;EAC7D,0BAA0B;EAC1B,AAAA,IAAI,CAAC;IACH,KAAK,EAAE,GAAG;IACV,cAAc,EAAE,GAAG;GACpB;;;AAGH,MAAM,MAAM,MAAM,MAAM,SAAS,EAAE,MAAM,OAAO,SAAS,EAAE,KAAK;EAC9D,wCAAwC;EACxC,AAAA,IAAI,CAAC;IACH,KAAK,EAAE,KAAK;IACZ,cAAc,EAAE,KAAK;GACtB;;;AAGH,MAAM,MAAM,MAAM,MAAM,SAAS,EAAE,MAAM,OAAO,SAAS,EAAE,MAAM;EAC/D,6BAA6B;EAC7B,AAAA,IAAI,CAAC;IACH,KAAK,EAAE,GAAG;IACV,cAAc,EAAE,GAAG;GACpB;;;AAGH,MAAM,MAAM,MAAM,MAAM,SAAS,EAAE,MAAM,OAAO,SAAS,EAAE,MAAM;EAC/D,6BAA6B;EAC7B,AAAA,EAAE;EACE,EAAE;EACF,cAAc;EACd,oBAAoB;EACpB,iBAAiB;AACnB,CAAC,AAAA,SAAS,CAAC;IACX,SAAS,EAAE,GAAG;GACf;;;AAGH,MAAM,MAAM,MAAM,MAAM,SAAS,EAAE,MAAM,OAAO,SAAS,EAAE,KAAK;EAC9D,wCAAwC;EACxC,AAAA,EAAE;EACE,EAAE;EACF,cAAc;EACd,oBAAoB;EACpB,iBAAiB;AACnB,CAAC,AAAA,SAAS,CAAC;IACX,SAAS,EAAE,KAAK;GACjB;;;AAGH,MAAM,MAAM,MAAM,MAAM,SAAS,EAAE,KAAK,OAAO,SAAS,EAAE,KAAK;EAC7D,0BAA0B;EAC1B,AAAA,EAAE;EACE,EAAE;EACF,cAAc;EACd,oBAAoB;EACpB,iBAAiB;AACnB,CAAC,AAAA,SAAS,CAAC;IACX,SAAS,EAAE,GAAG;GACf;;;AAGH,MAAM,MAAM,MAAM,MAAM,SAAS,EAAE,KAAK;EACtC,6BAA6B;EAC7B,AAAA,EAAE;EACE,EAAE;EACF,cAAc;EACd,oBAAoB;EACpB,iBAAiB;AACnB,CAAC,AAAA,SAAS,CAAC;IACX,SAAS,EAAE,GAAG;GACf;;;AAGH,MAAM,MAAM,MAAM,MAAM,SAAS,EAAE,MAAM;EACvC,AAAA,EAAE,CAAC;IACD,OAAO,EAAE,CAAC;IACV,eAAe,EAAE,CAAC;IAClB,YAAY,EAAE,CAAC;GAChB;;;AAGH,AAAA,oBAAoB,CAAC;EACnB,KAAK,EAAE,iBAAiB,CAAC,UAAU;CACpC;;AAED,AAAA,qBAAqB,CAAC;EACpB,KAAK,EAAC,kBAAkB;CACzB;;AAED,AAAA,mBAAmB,CAAC;EAClB,SAAS,EAAE,KAAK;EAChB,UAAU,EAAE,MAAM;CACnB;;AAED,AAAA,qBAAqB,CAAC;EACpB,MAAM,EAAE,CAAC;EACT,OAAO,EAAE,UAAU;CACpB;;AAED,AAAA,uBAAuB,CAAC;EACtB,MAAM,EAAE,UAAU;CACnB;;AAED,AAAA,iBAAiB,CAAC,QAAQ,GAAC,mBAAmB,AAAA,QAAQ,CAAC;EACrD,gBAAgB,EAAE,gTAAgT,CAAC,UAAU;CAC9U", - "sources": [ - "project.scss" - ], - "names": [], - "file": "project.css" -} \ No newline at end of file diff --git a/apps/static/css/project.scss b/apps/static/css/project.scss deleted file mode 100644 index de3796d49..000000000 --- a/apps/static/css/project.scss +++ /dev/null @@ -1,1580 +0,0 @@ -// Colors -$rx-color-grey-1: #595959; -$rx-color-grey-2: #737373; -$rx-color-grey-3: #eeeeee; -$rx-color-accent-1: #950953; -$rx-color-accent-2: #510029; - -:root { - --link-color: $rx-color-accent-1; - --contrast: 200%; -} - -.uk-input { - border-width: 3px; - width: 50%; -} - -.uk-link, a { - color: var(--link-color); - text-decoration: underline dotted; -} - -a.nav-link { - text-decoration: none; -} - -.uk-button-primary { - background-color: var(--link-color); -} - -.uk-button-primary:hover, -.uk-button-primary:active { - background-color: var(--link-color); - filter: contrast(var(--contrast)); -} - -.uk-button-default { - font-size: 1.25rem; -} - -.uk-tab > .uk-active > a { - border-color: var(--link-color) !important; - color: var(--link-color) !important; -} - -.uk-checkbox, -.uk-radio { - border: 1px solid #ccc !important; -} - -.uk-navbar-right { - padding-top: 1rem; -} - -.uk-container ul { - list-style: none; -} - -.block-paragraph_block li { - list-style: circle; -} - -.uk-navbar-nav { - font-weight: bold; -} - -.alert-debug { - color: black; - background-color: white; - border-color: #d6e9c6; -} - -.alert-error { - color: #b94a48; - background-color: #f2dede; - border-color: #eed3d7; -} - -#viewer { - width: 100%; - height: calc(100% - 120px); - position: absolute; - left: 0; - bottom: 0px; - background: none !important; -} - -.manifest-info > a, -.manifest-info > h3 { - display: none !important; -} - -#ocr-layer { - position: absolute !important; - z-index: 999; -} - -.openseadragon-container span { - color: transparent; - display: inline-flex !important; - font-size: 100%; - line-height: initial; - white-space: nowrap; -} - -/* Social Auth Buttons */ -a.rdx-provider-button { - color: #fff; - padding: 1rem; -} - -a.rdx-provider-button:hover { - color: lightgrey; -} - -.rdx-provider-button.facebook { - background-color: #4267b2; -} - -.rdx-provider-button.google { - background-color: #ea4335; -} - -.rdx-provider-button.twitter { - background-color: #1da1f2; -} - -.rdx-provider-button.github { - background-color: #24292e; -} - -.rdx-indented-help-block { - text-indent: 1.25rem; -} - -/* End of Social Auth Buttons */ - -/* Wagtail embedded objects */ -.rich-text img { - max-width: 100%; - height: auto; -} - -.responsive-object { - position: relative; -} - -.responsive-object iframe, -.responsive-object object, -.responsive-object embed { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; -} -/* end of Wagtail embedded objects */ - -#id_featured_collections { - height: 200px; - overflow: hidden; - overflow-y: scroll; -} - -.breadcrumbs { - font-size: 80%; - margin: 0; -} - -header.page-header { - position: relative; - z-index: 10; - width: 100%; -} - -header.page-header .container img { - width: 100%; -} - -h1, -h2, -h3, -p.text-lead { - color: $rx-color-grey-1; -} - -h3 { - font-size: 2.2rem; -} - -header#page-bg .collection-image-info { - display: inline-block; - background: rgba(0, 0, 0, 0.62); - position: absolute; - bottom: 0; - right: 0; - padding: 1em 1.5em 5px 3.5em; - font-size: 12px; - color: #dedede; - z-index: 100; - -webkit-transition: bottom 0.6s ease, right 0.6s ease, - background-color 0.6s ease, opacity 0.6s ease; - -moz-transition: bottom 0.6s ease, right 0.6s ease, background-color 0.6s ease, - opacity 0.6s ease; - transition: bottom 0.6s ease, right 0.6s ease, background-color 0.6s ease, - opacity 0.6s ease; - max-width: 300px; - min-height: 10px; - cursor: pointer; -} - -header#page-bg .collection-label h1 { - font-size: 3em; - color: #dedede; - width: 100%; - height: 100%; - position: absolute; - top: 0; - left: 0px; - justify-content: center; - flex-direction: column; - align-items: center; - display: flex; -} - -ol { - columns: 2; - -webkit-columns: 2; - -moz-columns: 2; -} - -ol > li { - break-inside: avoid-column; - -webkit-column-break-inside: avoid; - page-break-inside: avoid; -} - -ul.listing-thumbs > li > img.thumbnail-image { - height: 150px; -} - -ul > li > .collectionbox { - width: 100%; - padding-bottom: 15%; - position: relative; - border: 1px; - border-style: solid; -} - -ul > li > .collectionbox > .collectioncontainer { - position: absolute; - left: 0; - right: 0; - top: 0; - bottom: 0; - overflow: hidden; -} - -ul > li > .collectionbox > .collectioncontainer img { - width: 100%; - overflow: hidden; -} - -ul > li > .collectionbox > .collectioncontainer > .collection-label { - height: 0; -} - -ul > li > .collectionbox > .collectioncontainer > .collection-label a.nav-link { - font-size: 3em; - color: #dedede; - position: absolute; - left: 10px; - right: 10px; - top: 30%; - bottom: 10px; - text-align: center; - color: black; -} - -ul - > li - > .collectionbox - > .collectioncontainer - > .collection-label - a.nav-link - span { - background-color: rgba(255, 255, 255, 0.62); - padding: 0.3em; - border-radius: 20px; -} - -.count-icon { - display: inline-block; - position: relative; -} - -.count { - position: absolute; - top: 0; - right: 0; - padding-top: 20%; - padding-right: 20%; - height: 10px; - width: 10px; - font-size: 10px; - text-align: center; -} - - -header#page-bg .collection-label h1 span { - background-color: rgba(0, 0, 0, 0.62); - padding: 0.3em; -} - -header#page-bg .collection-image-info:before { - content: "i"; - position: absolute; - border: 1px solid; - width: 20px; - text-align: center; - top: 0; - left: 0; - margin: 10px 10px; - height: 20px; - font-family: serif; -} - -header#page-bg .collection-image-info h3 { - margin: 0; - font-size: 1.5em; - color: #dedede; -} -header#page-bg .collection-image-info p { - width: 100%; - color: #dedede !important; - text-align: left; - margin-bottom: 0px; - margin-top: 0px; -} - -header#page-bg .collection-image-info p.credit { - font-size: 0.9em; -} - -header#page-bg .collection-image-info.collasped { - padding: 1.5em 1.75em; - background-color: rgba(22, 22, 22, 0.33); - opacity: 0.8; - color: #dedede !important; -} - -header#page-bg .collection-image-info.collasped:hover { - opacity: 1; - background-color: rgba(0, 0, 0, 0.62); -} - -header#page-bg .collection-image-info.collasped h3, -header#page-bg .collection-image-info.collasped .info { - display: none; -} - -#wrap { - overflow: hidden; -} -.box { - width: 25%; - padding-bottom: 25%; - position: relative; - float: left; -} -.box a img { - width: 100%; - overflow: hidden; -} -.boxInner { - position: absolute; - left: 0; - right: 0; - top: 0; - bottom: 0; - overflow: hidden; -} -.boxInnerb { - position: absolute; - left: 0; - right: 0; - top: 0; - bottom: 0; - max-height: 225px; - overflow: hidden; -} -.boxInner img { - -webkit-filter: blur(2px); /* Safari 6.0 - 9.0 */ - filter: blur(2px); - width: 100%; -} - -.boxInnerb img { - -webkit-filter: blur(2px); /* Safari 6.0 - 9.0 */ - filter: blur(2px); - width: 100%; -} - -.innerContent { - position: absolute; - left: 10px; - right: 10px; - top: 10px; - bottom: 10px; - text-align: center; - color: black; - transition: 0.5s; - opacity: 0; -} - -.innerContent:hover { - opacity: 1; -} - -.innerContent p { - text-align: center; - background: rgba(255, 255, 255, 0.7); - color: #4f4f4f; - border-radius: 15px; -} - -.innerContent p em { - color: black; -} - -.innerContent a { - color: black; - background: rgba(255, 255, 255, 0.7); - text-decoration: underline; -} - -.innerContentb { - position: absolute; - left: 10px; - right: 10px; - top: 10px; - bottom: 10px; - text-align: center; - color: black; -} - -.innerContentc { - position: absolute; - left: 10px; - right: 10px; - top: 10px; - bottom: 10px; - text-align: center; - color: black; -} - -.innerContentb h2 { - text-align: center; - background: rgba(255, 255, 255, 0.7); - color: #4f4f4f; - border-radius: 15px; -} - -.innerContentb h2 em { - color: black; -} - -.innerContentb a { - color: black; - background: rgba(255, 255, 255, 0.7); - text-decoration: underline; -} - -.readux-home-tagline { - font-size: 3em; - font-weight: 700; -} - -.readux-home-ecds { - width: 20em; -} - -.readux-group-title { - font-size: 2em; - font-weight: 700; -} - -#rx-sort { - position: sticky; - top: 72px; -} - -.rx-card-title { - font-size: 1.25em; - font-weight: 700; - color: $rx-color-grey-1; - line-height: 1.25em; -} - -a:hover { - text-decoration: initial !important; -} - -.rx-offcanvas-bar, -.rx-offcanvas-base { - background: white; - box-shadow: 0 3px 6px rgba(0, 0, 0, 0.16), 0 3px 6px rgba(0, 0, 0, 0.23); -} - -p { - color: $rx-color-grey-1 !important; -} - -.rx-breadcrumb, -.rx-action-title, -.rx-action-btn, -.rx-page-breadcrumb-item, -.rx-page-breadcrumb { - color: var(--link-color); - filter: brightness(0.4%); - text-transform: uppercase; - font-weight: bold; - letter-spacing: 0.1em; - display: inline-flex; - padding: 0; - margin: 0; -} - -.rx-page-breadcrumb-item a { - color: var(--link-color); -} - -.rx-page-breadcrumb { - white-space: nowrap; -} - -.rx-page-logo { - min-width: 65px; -} - -.rx-breadcrumb-item:not(:last-child)::after, -.rx-page-breadcrumb-item:not(:last-child)::after { - content: "-"; - padding: 0 0.5em; -} - -.rx-page-title-container { - padding: 0 15px; - overflow-wrap: anywhere; - align-self: center; -} - -.rx-breadcrumb-item > a, -.rx-page-breadcrumb-item > a, -.rx-action-btn, -.rx-icon-btn, -.uk-dropdown-nav > li > a:hover { - color: var(--link-color) !important; - filter: brightness(1); - opacity: 0.75; - transition: all ease-in-out 100ms; -} - -.rx-breadcrumb-item > a:hover, -.rx-page-breadcrumb-item > a:hover, -.rx-action-btn:hover, -.rx-icon-btn:hover, -.uk-dropdown-nav > li.uk-active > a { - color: var(--link-color); - opacity: 1; - transition: all ease-in-out 100ms; -} - -.rx-head-container { - margin: 0em 0 3em; -} - -.rx-info { - margin: 1rem 0; -} - -.rx-info-title { - border: 0.5px solid #e2e2e2; - padding: 0.7em 1em 0.5em; - color: $rx-color-grey-1; - letter-spacing: 0.15em; - text-transform: uppercase; - font-size: 0.875em; - font-weight: bold; -} - -.rx-info-content-container { - border: 0.5px solid #e2e2e2; - padding: 1em 1em; - word-break: break-word; -} - -.rx-info-content { - padding: 0.5em 0; - font-size: 0.9rem; -} - -.rx-info-content-label { - color: $rx-color-grey-1 !important; - font-weight: 600 !important; - line-height: 1em !important; - font-size: 0.9rem !important; -} - -.rx-info-content-value { - color: $rx-color-grey-1; - line-height: 1.5; -} - -.rx-card-body { - text-align: left; - min-width: 0; - flex: 1 1 auto; - /* margin-left: 2rem; */ -} - -.rx-card-image-container { - /* width: 150px; */ - flex: 0 0 auto; -} - -.rx-card-image { - background-origin: border-box !important; - background-size: cover !important; - width: 100% !important; - height: 100% !important; - display: block !important; - color: inherit; - text-decoration: none; - background-color: none; -} - -.rx-card-text { - padding: 0; - color: $rx-color-grey-1; - margin: 0.5em 0; - overflow: hidden; - text-overflow: ellipsis; - display: -webkit-box; - -webkit-line-clamp: 3; /* number of lines to show */ - -webkit-box-orient: vertical; - font-size: 14px; -} - -.rx-card-title { - overflow: hidden; - text-overflow: ellipsis; - display: -webkit-box; - -webkit-line-clamp: 1; /* number of lines to show */ - -webkit-box-orient: vertical; -} - -.rx-grid-right { - overflow: auto; -} - -#rx-nav { - /* position: fixed; - top: 0; - left: 5%; - right: 5%; - background: white; */ - /* position: sticky; - top: 0; */ - background: white; -} - -.rx-grid-right { - margin-left: 50%; -} - -.rx-btn { - border-radius: 3px; - border: solid 1px var(--link-color); - filter: contrast(var(--contrast)); - color: var(--link-color); - filter: contrast(var(--contrast)); - font-family: HelveticaNeue, Helvetica, -apple-system, BlinkMacSystemFont, - "Segoe UI", Roboto, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", - "Segoe UI Symbol"; - font-size: 14px; - font-weight: 500; - line-height: 1.28; - letter-spacing: normal; - text-align: center; - padding: 5px 12px; - display: inline-block; - - &:hover { - border: solid 1px var(--link-color); - color: white; - background: var(--link-color); - filter: contrast(var(--contrast)); - transition: all ease-in-out 100ms; - cursor: pointer; - } -} - -.rx-btn-extension { - border-radius: 3px; - border: solid 1px var(--link-color); - filter: contrast(var(--contrast)); - color: white; - font-family: HelveticaNeue, Helvetica, -apple-system, BlinkMacSystemFont, - "Segoe UI", Roboto, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", - "Segoe UI Symbol"; - font-size: 15px; - font-weight: 600; - line-height: 1.28; - text-transform: uppercase; - letter-spacing: 0.7px; - text-align: center; - padding: 8px 16px; - display: inline-block; - background: var(--link-color); - color: white; - border: solid 1px var(--link-color); - - &:hover { - background: #5e0035; - color: white; - border: solid 1px #5e0035; - transition: all ease-in-out 100ms; - opacity: 1; - cursor: pointer; - } -} - -// .rx-annotation-badge { -// border-radius: 3px; -// background: $rx-color-grey-1; -// color: white; -// text-transform: uppercase; -// font-weight: bold; -// padding: 0.1rem 0.5rem; -// font-size: 0.75rem; -// display: inline-block; -// cursor: default; -// } - -.rx-annotation-badge { - border-radius: 3px; - background: white; - color: $rx-color-grey-1 !important; - border: $rx-color-grey-1 1px solid; - font-size: 0.75rem; - letter-spacing: 0.1rem; - font-weight: 600; - display: inline-block; - cursor: default; - text-transform: uppercase; - padding: 0.1rem 0.5rem; -} - -.rx-padding-bottom-10 { - padding-bottom: 10px; -} - -.uk-active > .rx-btn-annotation { - background-color: $rx-color-accent-1 !important; - color: white; -} - -.uk-offcanvas-container { - top: -80px; - position: inherit; -} - -.readux-group-title:not(:first-child) { - margin-top: 3rem; -} - -.rx-footer { - display: flex; - list-style: none; - font-weight: bold; - & > a { - padding: 1rem; - color: $rx-color-grey-2; - transition: all ease-in-out 100ms; - &:first-child { - padding-left: 0; - } - &:hover { - color: #666; - } - } -} - -.rx-anchor { - color: var(--link-color) !important; - opacity: 0.75; - transition: all ease-in-out 100ms; - - &:hover { - color: var(--link-color); - opacity: 1; - } -} - -::selection { - background-color: $rx-color-grey-1; -} - -.rx-label-copy { - background: white; - color: var(--link-color) !important; - border: var(--link-color) 1px solid; - font-size: 0.75rem; - letter-spacing: 0.1rem; - opacity: 0.75; - cursor: pointer; - transition: all ease-in-out 100ms; - font-weight: 600; - - &:active { - background-color: $rx-color-accent-2 !important; - border: 1px solid $rx-color-accent-2 !important; - opacity: 1; - } -} - -.rx-page-navbar-right > a { - margin-left: 1em; -} - -.rx-label-copy:hover { - background: var(--link-color); - color: white !important; - font-size: 0.75rem; - letter-spacing: 0.1rem; - cursor: pointer; - opacity: 1; -} - -.rx-flex { - display: flex; -} - -.rx-tooltip-hidden { - visibility: hidden; - opacity: 0; - transition: visibility 0s 2s, opacity 2s linear; -} - -.rx-grid { - margin: 40px auto; - max-width: 1024px; - display: flex; - flex-wrap: wrap; -} - -.rx-col { - border: 1px dashed gray; - flex: 1 1 100px; - text-align: center; - padding: 12px; -} - -.rx-top-left { - position: absolute; - top: 8px; - left: 16px; -} - -.rx-image-container > figure { - margin: auto; - background-position: center; - background-size: cover; - width: 100%; - height: 200px; -} - -.rx-volume-na { - background: #fbecf9; - padding: 15px; - font-size: 14px; - color: var(--link-color); -} - -a.rx-nav-item { - color: var(--link-color) !important; -} - -.rx-nav-item.uk-active { - color: var(--link-color); -} - -.rx-btn-small { - font-size: 14px; - font-weight: bold; - letter-spacing: normal; - text-align: center; - padding: 6px 12px; -} - -.rx-fieldset { - border: none; -} - -.rx-info-content .uk-tab a { - color: $rx-color-grey-2; -} - -.rx-info-content .uk-tab a:hover { - color: #666; -} - - - -.rx-collection-title { - font-weight: bold; - color: $rx-color-grey-1; -} - -/* Grid layout */ - -.rx-grid { - display: grid; - margin: 15px auto; - max-width: 1400px; - grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); - grid-column-gap: 30px; - /* justify-content: space-between; */ -} - -.rx-grid-layout { - height: 425px; - width: 275px; - position: relative; - background: white; - margin-bottom: 75px; -} - -.rx-grid-image-container > img { - object-fit: cover; - height: 400px; - /* width: 200px; */ - width: 100%; - /* height: 100%; */ -} - -/* Banner Layout */ -.rx-banner-layout { - display: grid; - margin: 20px auto; - max-width: 100%; - /* grid-template-columns: repeat(auto-fill, minmax(100%, 1fr)); */ - grid-template-columns: 1fr; - /* grid-column-gap: 15px; */ - /* justify-content: space-between; */ -} - -.rx-volume-in-banner { - /* border: 1px solid black; */ - /* text-align: center; */ - /* height: 150px; */ - width: 100%; - margin: 15px 0; - position: relative; - background: black; -} - -.rx-banner-image-container > img { - object-fit: cover; - height: 150px; - width: 100%; - opacity: 0.4; -} - -.rx-banner-text-container { - position: absolute; - left: 0; - bottom: 0; - padding: 1rem; - color: $rx-color-grey-3; - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, - Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif; -} - -.rx-collection-title-banner { - color: white; - font-size: 1.5rem; -} - -.rx-sticky { - position: sticky; - top: 0; - z-index: 1; -} - -/*mirador overrides*/ -.mirador-container .manifest-info { - width: initial !important; -} - -.mirador-container .mirador-viewer a.mirador-icon-view-type, -.mirador-container .mirador-viewer a.mirador-icon-metadata-view, -.mirador-container .mirador-viewer a.mirador-osd-fullscreen { - padding: 5px; - margin: 0; -} - -.mirador-container .mirador-viewer { - top: -12px !important; -} -.mirador-container .workspace-container { - left: -3px !important; -} - -.mirador-container .manifest-info { - width: auto !important; - float: right; - z-index: 2; -} - -.mirador-container .window-manifest-navigation { - float: right; - margin-left: 1em; -} - -.mirador-container .manifest-info { - padding: 0.5rem; - overflow: initial !important; - margin-top: 0px !important; -} - -.mirador-container .window-manifest-navigation { - margin-left: initial; -} - -.mirador-container .mirador-viewer a.mirador-icon-view-type, -.mirador-container .mirador-viewer a.mirador-icon-metadata-view, -.mirador-container .mirador-viewer a.mirador-osd-fullscreen { - display: flex; - flex-direction: row; -} - -.mirador-container .mirador-viewer a.mirador-icon-view-type, -.mirador-container .mirador-viewer a.mirador-icon-metadata-view, -.mirador-container .mirador-viewer a.mirador-osd-fullscreen { - margin-right: 0 !important; - padding-bottom: 0 !important; -} - -.mirador-btn { - padding: 0.5rem; -} - -.window-manifest-navigation { - display: flex; - justify-content: space-between; - width: 55px; -} - -.tooltip { - position: relative; - display: inline-block; - border-bottom: 1px dotted black; -} - -.tooltip .tooltiptext { - visibility: hidden; - width: 120px; - background-color: black; - color: #fff; - text-align: center; - border-radius: 6px; - padding: 5px 0; - position: absolute; - z-index: 1; - top: 150%; - left: 50%; - margin-left: -60px; - font-size: 14px; -} - -.tooltip .tooltiptext::after { - content: ""; - position: absolute; - bottom: 100%; - left: 50%; - margin-left: -5px; - border-width: 5px; - border-style: solid; - border-color: transparent transparent black transparent; -} - -.tooltip:hover .tooltiptext { - visibility: visible; -} - -.rx-page-info-btn, -.rx-btn-secondary, -.rx-page-info-btn > .uk-icon-button { - color: var(--link-color) !important; - filter: contrast(var(--contrast)); - opacity: 0.75; - font-size: 1rem; - font-weight: 600; -} - -.rx-page-info-btn:hover, -.rx-btn-secondary:hover, -.rx-page-info-btn > .uk-icon-button:hover { - color: white !important; -} - -.rx-offcanvas-close { - position: relative; - top: 0; - right: 0; - color: var(--link-color) !important; - filter: contrast(var(--contrast)); -} - -.rx-offcanvas-close:hover { - color: var(--link-color) !important; - filter: contrast(var(--contrast)); -} - -.rx-btn-secondary { - border: 3px solid var(--link-color); - filter: contrast(var(--contrast)); - padding: 0.35em 1em; - white-space: nowrap; -} - -.rx-btn-secondary:hover { - color: white; - background-color: var(--link-color); - filter: contrast(var(--contrast)); - opacity: 1; - transition: all ease-in-out 100ms; -} - -.rx-page-info-btn:hover { - opacity: 1; - transition: all ease-in-out 100ms; - color: var(--link-color); - filter: contrast(var(--contrast)); -} - -.rx-btn-secondary:active { - background-color: var(--link-color); - border-color: var(--link-color); - filter: brightness(75%); -} - -.rx-info-container { - position: absolute; - background: white; - padding: 2rem; - z-index: 2; - right: 0; - width: 50%; - bottom: 0; - overflow: scroll; - top: 0; - box-shadow: 0 3px 6px rgba(0, 0, 0, 0.16), 0 3px 6px rgba(0, 0, 0, 0.23); -} - - - -fieldset { - margin: inherit; - border: 0; - padding: inherit; -} - -#sidenav { - background: white; - position: absolute; - top: 0; -} - -// page search -.rx-page-search-container { - width: 100%; - display: flex; - align-items: center; - justify-content: space-between; - color: $rx-color-grey-1 !important; -} - -.rx-page-search-options, -.rx-page-search-option-label { - display: block; -} -// end of page search - -// page.html flexbox with clipping text -.flex-parent { - display: -webkit-box; - display: flex; - -webkit-box-align: center; - align-items: center; - padding: 10px; - margin: 30px 0; -} - -.long-and-truncated { - -webkit-box-flex: 1; - flex: 1; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.short-and-fixed { - white-space: nowrap; -} - -.short-and-fixed > div { - width: 30px; - height: 30px; - border-radius: 10px; - background: lightgreen; - display: inline-block; -} -// end of page.html flexbox with clipping text - -// rx-search -.rx-search-icon { - color: $rx-color-grey-1 !important; -} - -.rx-search-input { - border: none !important; - color: $rx-color-grey-1 !important; - &::placeholder { - color: $rx-color-grey-2 !important; - } - -} - -.rx-search-result { - font-size: 14px; - a { - color: var(--link-color) !important; - opacity: 0.7; - &:hover { - color: var(--link-color) !important; - opacity: 1; - transition: all ease-in-out 100ms; - } - } -} -// end of rx-search - -// rx-title -.rx-title-container { - background: white; - padding: 1em 0; - position: sticky; - top: 0; - z-index: 3; -} - -.rx-title { - font-size: 2em; - font-weight: bold; - color: var(--link-color); -} - -.rx-title-2 { - font-size: 1.5em; - font-weight: bold; - color: $rx-color-grey-1; -} - -.rx-title-tagline { - font-size: 14px; - color: $rx-color-grey-2; -} - -.rx-title-tagline-banner { - color: #eeeeee; -} - -.rx-title-image { - object-fit: cover; - height: 150px; - width: 100%; -} -// end of rx-title - -@media (max-width: 395px) { - header#page-bg .collection-label h1 { - font-size: 0.7em; - } -} - -@media (max-width: 555px) { - header#page-bg .collection-label h1 { - font-size: 1em; - } -} - -@media (max-width: 750px) { - header#page-bg .collection-label h1 { - font-size: 1.5em; - } -} - -@media (max-width: 639px) { - .rx-offcanvas-bar { - width: 100%; - } - #offcanvas-usage { - width: 100%; - left: 0; - } - - .rx-navbar-nav { - display: block; - } - - .uk-logo { - padding: 0; - } -} - -@media (min-width: 640px) { - .rx-splash { - /* position: fixed; */ - display: flex; - flex-direction: column; - align-self: flex-start; - position: sticky; - top: 0; - justify-content: space-between; - height: calc(100vh - 200px); - } - - .rx-offcanvas-bar { - width: 100%; - } - - #offcanvas-usage { - width: 50%; - left: 50%; - } -} - -@media (max-width: 750px) { - .rx-page-logo { - display: none; - } - .rx-page-title-container { - padding: 0 15px 0 0; - font-size: 0.9rem; - } - - .rx-btn-secondary { - border: none; - padding: none; - white-space: inherit; - margin-left: -1rem !important; - } - - .rx-page-breadcrumb { - display: block; - white-space: normal; - } - - .rx-page-title { - line-height: 1rem; - } -} - -@media (min-width: 768px) { - .modal-dialog > .modal-content { - top: 200px; - } -} - -@media (max-width: 960px) { - .rx-info-container { - width: 100%; - left: 0; - right: 0; - padding: 0; - } - - .rx-volume-image { - margin-bottom: 1rem; - } -} - -@media (min-width: 960px) { - .rx-article { - display: flex; - align-items: stretch; - /* flex: 1 1 auto; */ - max-width: 680px; - width: 100%; - margin: 3rem 0; - } - - .rx-article > .rx-card-image-container { - padding: 0; - } - - .uk-navbar-right { - flex-wrap: initial; - } -} - -@media (min-width: 320px) and (max-width: 959px) { - .readux-home-ecds { - display: none; - } - - .readux-group-title { - font-size: 1.5rem; - } - - .rx-splash-item { - font-size: 1.5rem; - } - - .rx-title-image { - object-fit: cover; - height: 150px; - width: 100%; - } - - .rx-card-image-container { - height: 150px; - width: 100%; - } - - .rx-card-body { - margin: 1rem 0 2rem 0; - } - - .rx-card-title { - overflow: inherit; - display: block; - } - - .rx-card-text { - overflow: inherit; - display: block; - } - - .rx-footer > a { - padding: 0 0 1rem 0; - } - - footer { - padding: 2rem 0 0 0 !important; - } -} - -@media (max-width: 1095px) { - header#page-bg .collection-label h1 { - font-size: 2em; - } -} - -@media only screen and (max-width: 480px) { - /* Smartphone view: 1 tile */ - #box { - width: 100%; - padding-bottom: 100%; - } -} - -@media only screen and (max-width: 650px) and (min-width: 481px) { - /* Tablet view: 2 tiles */ - #box { - width: 50%; - padding-bottom: 50%; - } -} - -@media only screen and (max-width: 1050px) and (min-width: 651px) { - /* Small desktop / ipad view: 3 tiles */ - #box { - width: 33.3%; - padding-bottom: 33.3%; - } -} - -@media only screen and (max-width: 1290px) and (min-width: 1051px) { - /* Medium desktop: 4 tiles */ - #box { - width: 25%; - padding-bottom: 25%; - } -} - -@media only screen and (max-width: 1290px) and (min-width: 1051px) { - /* Medium desktop: 4 tiles */ - ul - > li - > .collectionbox - > .collectioncontainer - > .collection-label - a.nav-link { - font-size: 3em; - } -} - -@media only screen and (max-width: 1050px) and (min-width: 651px) { - /* Small desktop / ipad view: 3 tiles */ - ul - > li - > .collectionbox - > .collectioncontainer - > .collection-label - a.nav-link { - font-size: 2.5em; - } -} - -@media only screen and (max-width: 650px) and (min-width: 481px) { - /* Tablet view: 2 tiles */ - ul - > li - > .collectionbox - > .collectioncontainer - > .collection-label - a.nav-link { - font-size: 2em; - } -} - -@media only screen and (max-width: 480px) { - /* Smartphone view: 1 tile */ - ul - > li - > .collectionbox - > .collectioncontainer - > .collection-label - a.nav-link { - font-size: 1em; - } -} - -@media only screen and (max-width: 1250px) { - ol { - columns: 1; - -webkit-columns: 1; - -moz-columns: 1; - } -} - -.rx-accordion-handle { - color: var(--link-color) !important; -} - -.uk-accordion-content { - color:#595959 !important; -} - -.rx-fixed-width-100 { - min-width: 100px; - text-align: center; -} - -.rx-accordion-content { - margin: 0; - padding: 0 0 10px 0; -} - -.rx-accordion-container { - margin: 20px 0 0 0; -} - -.uk-offcanvas-bar .uk-open>.uk-accordion-title::before { - background-image: url("data:image/svg+xml;charset=UTF-8,%8Csvg+width%3D%2213%22+height%3D%2213%22+viewBox%3D%220+0+13+13%22+xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0D%0A++++%3Crect+fill%3D%22rgba%28149%2C+9%2C+83%2C+1%29%22+width%3D%2213%22+height%3D%221%22+x%3D%220%22+y%3D%226%22+%2F%3E%0D%0A%3C%2Fsvg%3E") !important; -} diff --git a/apps/static/css/readux.scss b/apps/static/css/readux.scss new file mode 100644 index 000000000..a0569fcb0 --- /dev/null +++ b/apps/static/css/readux.scss @@ -0,0 +1,491 @@ +/* -------------------------------------------------------------------------- */ +/* 0) IMPORTS */ +/* -------------------------------------------------------------------------- */ +@import './ecds-annotator.min.css'; +@import './partials/_mixin.scss'; +@import './partials/_colors.scss'; +@import './partials/_media.scss'; +@import './components/social-auth.scss'; +@import './components/wagtail.scss'; +@import './components/uk-switch.scss'; +@import './components/footer.scss'; + +/* -------------------------------------------------------------------------- */ +/* 1) ROOT VARIABLES */ +/* -------------------------------------------------------------------------- */ +:root { + --contrast: 200%; + --link-color: $rx-color-midnight-blue; +} + +/* -------------------------------------------------------------------------- */ +/* 2) BASE / LAYOUT */ +/* -------------------------------------------------------------------------- */ +.sr-only { @include sr-only(); } + +html, body { + height: 100%; + margin: 0; + display: flex; + flex-direction: column; +} + +main { flex-grow: 1; } + +/* Home page navigation transparent */ +.home-nav { nav { background-color: transparent !important; } } + +/* Overlay & content shell */ +.overlay { + position: absolute; + inset: 0 auto auto 0; + width: 100%; + height: 80vh; + min-height: 500px; + background-color: rgba($rx-color-midnight-blue, 0.8); +} + +.content { + position: relative; + z-index: 2; + min-height: 100vh; + display: flex; + flex-flow: column; + + .space { flex-grow: 1; } + .uk-section-default:empty { background-color: transparent; } +} + +/* Text & headings */ +.paragraph { color: $rx-color-midnight-blue; line-height: normal; } +.title { color: $rx-color-midnight-blue; font-size: x-large; font-weight: bold; } +.hero { padding-top: 15vh; color: $color-white; } +.uk-container h2 { margin-bottom: 0.35rem;} + +/* Utilities */ +.slash-gap { margin: 0 0.5rem; } +.color-black { color: $color-black; } + +/* Make nav z-order above content when overlayed */ +.uk-navbar-container { z-index: 10; position: absolute; width: 100%; } + +/* -------------------------------------------------------------------------- */ +/* 3) GLOBAL LINKS & INTERACTIVE */ +/* -------------------------------------------------------------------------- */ +.uk-link, a { + color: $rx-color-midnight-blue; + // text-decoration: underline; + transition: color 0.1s ease; + &:hover { color: rgba($rx-color-midnight-blue, 0.8); } +} + +// a:hover { text-decoration: initial !important; } + +.text-anchor { @include link-variant($rx-color-mario-red, 600, false); text-decoration: underline; } +.text-anchor-blue { @include link-variant($rx-color-midnight-blue, 700, true); } + +/* Consolidated interactive text on dark backgrounds */ +.brand-logo, .menu-item, .brand-readux { + @include interactive-color($color-white); + text-decoration: none !important; +} +.brand-logo { font-size: large; font-weight: bold; } +.menu-item { text-transform: unset !important; } +.brand-inline { font-size: small; font-weight: normal; text-decoration: underline !important; } +.brand-tagline { font-size: small; color: $color-white; } + +/* Page titles */ +.page-title { font-size: xx-large; font-weight: bold; color: $color-black; } +.page-text-lead { font-size: medium; color: $color-black; } + +em { color: inherit !important; } + +/* -------------------------------------------------------------------------- */ +/* 4) BREADCRUMBS & DROPDOWNS */ +/* -------------------------------------------------------------------------- */ +.breadcrumb { + list-style: none; + display: flex; + font-family: -apple-system, 'Helvetica Neue', 'Helvetica', 'Arial', 'Segoe UI', 'Roboto', 'Ubuntu', sans-serif; + font-size: 14px; + padding: 0; + margin: 0; + + > li { margin-right: 5px; } + > li + li::before { content: "/"; margin-right: 5px; color: $rx-color-midnight-blue; } + + a { + color: $rx-color-midnight-blue; + // text-decoration: underline; + transition: color 0.1s ease; + &:hover { color: rgba($rx-color-midnight-blue, 0.8); } + } + + .icon-chevron-down { + margin-left: 3px; + top: 1px; + position: relative;} +} + +/* Breadcrumb dropdown */ +.uk-navbar-dropdown { + background-color: $rx-color-faded-mint; + border-radius: 4px; + box-shadow: 0 4px 4px rgba($color-black, 0.1); + padding: 10px 0 !important; + min-width: 0 !important; +} + +.uk-navbar-dropdown-nav a, +.uk-navbar-dropdown-nav.sort-dropdown label { + cursor: pointer; + color: $rx-color-midnight-blue !important; + font-weight: 600; + padding: 5px 15px !important; + display: block; + transition: background-color 0.1s ease; + &:hover { background-color: rgba($rx-color-midnight-blue, 0.1); } +} + +.uk-navbar-dropdown-nav.sort-dropdown label input { display: none; } +.pagination-nav span { padding: 5px 15px !important; display: block; } + +/* -------------------------------------------------------------------------- */ +/* 5) UIKIT OVERRIDES / COMPONENTS */ +/* -------------------------------------------------------------------------- */ +/* Slidenav */ +.uk-slidenav { color: rgba($rx-color-midnight-blue, 0.6); &:hover { color: $rx-color-midnight-blue; } } + +/* Focus ring */ +.uk-input:focus, .uk-select:focus, .uk-textarea:focus { border-color: $rx-color-midnight-blue; } + +/* Buttons */ +.uk-button-default { @include interactive-border($rx-color-midnight-blue); } +.uk-button-primary { + background-color: var(--link-color); + &:hover, &:active { background-color: var(--link-color); filter: contrast(var(--contrast)); } +} + +/* Close icon */ +.uk-close { color: $rx-color-midnight-blue; &:hover { color: darken($rx-color-midnight-blue, 10%); } } + +/* Slider */ +.uk-slider-items { gap: 4rem; } + +/* Tabs */ +.uk-tab > .uk-active > a { border-color: var(--link-color) !important; color: var(--link-color) !important; } + +/* Form controls */ +.uk-checkbox, .uk-radio { border: 1px solid $rx-color-light-pearl !important; } + +/* Lists & list items */ +.uk-container ul { @include no-list-style; list-style: none; } + +/* Navbar */ +.uk-navbar-right { flex-wrap: initial; } + +/* Offcanvas */ +.uk-offcanvas-container { top: -80px; position: inherit; } + +/* Accordion */ +.uk-accordion-content { color: #595959 !important; } +.uk-accordion > :nth-child(n+2) { margin-top: 10px; } + +/* Override UIkit accordion icons */ +.uk-open > .uk-accordion-title::before { + background-image: url(data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D%2213%22%20height%3D%2213%22%20viewBox%3D%220%200%2013%2013%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%20%20%3Crect%20fill%3D%22%231D3557%22%20width%3D%2213%22%20height%3D%221%22%20x%3D%220%22%20y%3D%226%22%20%2F%3E%0A%3C%2Fsvg%3E); +} +.uk-open > .uk-accordion-title:hover::before { + background-image: url(data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D%2213%22%20height%3D%2213%22%20viewBox%3D%220%200%2013%2013%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%20%20%3Crect%20fill%3D%22%231D3557%22%20width%3D%2213%22%20height%3D%221%22%20x%3D%220%22%20y%3D%226%22%20%2F%3E%0A%3C%2Fsvg%3E); +} +.uk-accordion-title::before { + background-image: url(data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D%2213%22%20height%3D%2213%22%20viewBox%3D%220%200%2013%2013%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%20%20%3Crect%20fill%3D%22%231D3557%22%20width%3D%2213%22%20height%3D%221%22%20x%3D%220%22%20y%3D%226%22%20%2F%3E%0A%20%20%20%20%3Crect%20fill%3D%22%231D3557%22%20width%3D%221%22%20height%3D%2213%22%20x%3D%226%22%20y%3D%220%22%20%2F%3E%0A%3C%2Fsvg%3E); +} +.uk-accordion-title:hover::before { + background-image: url(data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D%2213%22%20height%3D%2213%22%20viewBox%3D%220%200%2013%2013%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%20%20%3Crect%20fill%3D%22%231D3557%22%20width%3D%2213%22%20height%3D%221%22%20x%3D%220%22%20y%3D%226%22%20%2F%3E%0A%20%20%20%20%3Crect%20fill%3D%22%231D3557%22%20width%3D%221%22%20height%3D%2213%22%20x%3D%226%22%20y%3D%220%22%20%2F%3E%0A%3C%2Fsvg%3E); +} + +/* Offcanvas accordion color override */ +.uk-offcanvas-bar .uk-open > .uk-accordion-title::before { + background-image: url("data:image/svg+xml;charset=UTF-8,%8Csvg+width%3D%2213%22+height%3D%2213%22+viewBox%3D%220+0+13+13%22+xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0D%0A++++%3Crect+fill%3D%22rgba%28149%2C+9%2C+83%2C+1%29%22+width%3D%2213%22+height%3D%221%22+x%3D%220%22+y%3D%226%22+%2F%3E%0D%0A%3C%2Fsvg%3E") !important; +} + +/* -------------------------------------------------------------------------- */ +/* 6) CARDS / GRID / LISTS */ +/* -------------------------------------------------------------------------- */ +/* Volume grid */ +.volume-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 2.5rem; } + +.grid-item { + text-align: left; + background-color: transparent; + cursor: pointer; + text-decoration: none; + opacity: 0.75; + transition: opacity 0.1s ease; + display: block; + color: $color-black; + + &:hover { opacity: 1; text-decoration: none; color: $color-black; } + + img { width: 100%; height: auto; display: block; border-radius: 4px; } + h2 { text-decoration: underline; color: $rx-color-midnight-blue; font-weight: 600; font-size: medium; margin: 0; } + p { margin: 0; } +} + +.collection-summary { + display: -webkit-box; + -webkit-line-clamp: 4; + line-clamp: 4; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; + white-space: normal; +} + +/* List view table */ +.list-view-table { + margin-left: -0.5rem; + + thead th { color: $rx-color-midnight-blue; font-weight: bold; } + tbody tr { color: rgba($color-black, 0.75); transition: color 0.3s ease; } + tbody tr:hover { background-color: $rx-color-faded-mint !important; color: $color-black; } + th, td { padding: 0.5rem; } +} + +/* Info line / pagination */ +.info-line { + display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; + font-weight: 600; color: $rx-color-midnight-blue; + .info-group, .pagination-controls { display: flex; align-items: center; gap: 5px; } + a, select { color: $rx-color-mario-red; text-decoration: underline; } +} + +.pagination-controls { .uk-icon-button { color: $rx-color-midnight-blue; } .uk-icon-button[disabled] { color: $rx-color-light-pearl; cursor: not-allowed; } } + +/* Hoverable rows */ +.list-item:hover, .clickable-row:hover { cursor: pointer; } + +/* -------------------------------------------------------------------------- */ +/* 7) SEARCH */ +/* -------------------------------------------------------------------------- */ +.search-bar form { display: flex; align-items: center; width: 100%; max-width: 800px; margin: 2rem auto; } +.search-bar { + input { flex: 1; height: 50px; border-top-left-radius: 5px; border-bottom-left-radius: 5px; font-size: 1.2em; } + button { + height: 50px; border-top-right-radius: 5px; border-bottom-right-radius: 5px; font-size: 1.2em; + background-color: $rx-color-mario-red; color: $color-white; border: none; display: flex; align-items: center; justify-content: center; transition: background-color 0.3s ease; + &:hover, &:active { background-color: darken($rx-color-mario-red, 10%); } + } +} + +/* Featured text area */ +.truncate-text { display: -webkit-box; -webkit-line-clamp: 5; line-clamp: 5; -webkit-box-orient: vertical; overflow: hidden; text-overflow: ellipsis; white-space: normal; } +.section-offset { background-color: $color-white; margin-top: 200px; } + +/* Video section */ +.video-section { position: relative; text-align: center; color: $color-white; } +.video-overlay { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 100%; cursor: pointer; z-index: 2; h2 { margin-bottom: 10px; font-size: 2em; } .play-icon { font-size: 48px; margin-bottom: 10px; } p { max-width: 600px; margin: 0 auto; } } +.video-container { position: relative; width: 100%; padding-bottom: 56.25%; background: $color-black; } +.video-thumbnail, iframe, .transparent-overlay { position: absolute; inset: 0; width: 100%; height: 100%; } +.video-thumbnail { object-fit: cover; } +.transparent-overlay { background-color: rgba($color-black, 0.5); z-index: 1; } +iframe { display: none; } + +/* -------------------------------------------------------------------------- */ +/* 10) SECTIONS / HIGHLIGHTS */ +/* -------------------------------------------------------------------------- */ +.column { background-color: $rx-color-faded-mint; padding: 2rem; border-radius: 8px; text-align: center; position: relative; } + +/* -------------------------------------------------------------------------- */ +/* 11) BUTTONS / AUTH */ +/* -------------------------------------------------------------------------- */ +.description-button { @include interactive-color($rx-color-mario-red, background-color); } +button.google, button.github { color: $color-white; } + +/* -------------------------------------------------------------------------- */ +/* 12) CONTENT PADDING TWEAKS */ +/* -------------------------------------------------------------------------- */ +.content > .uk-section { padding-bottom: 0; } + +/* -------------------------------------------------------------------------- */ +/* 13) PROJECT-SPECIFIC ELEMENTS (rx-*) */ +/* -------------------------------------------------------------------------- */ +.rx-breadcrumb, .rx-action-btn { + color: var(--link-color); + text-transform: uppercase; + font-weight: bold; + display: inline-flex; + padding: 0; margin: 0; user-select: auto; +} +.rx-action-btn { letter-spacing: 0.1em; font-size: small; } +.rx-action-btn:hover { + .rx-label-copy { background-color: $rx-color-midnight-blue !important; color: $color-white !important; } +} + +.rx-breadcrumb-item > a, .rx-action-btn, .rx-icon-btn, .uk-dropdown-nav > li > a:hover { + color: var(--link-color) !important; opacity: 0.75; transition: all 100ms ease-in-out; +} +.rx-breadcrumb-item > a:hover, .rx-action-btn:hover, .rx-icon-btn:hover, .uk-dropdown-nav > li.uk-active > a { + color: var(--link-color); opacity: 1; transition: all 100ms ease-in-out; +} + +.rx-anchor { color: var(--link-color) !important; opacity: 0.75; word-break: break-all; transition: all 100ms; &:hover { color: var(--link-color); opacity: 1; } } + +.rx-label-copy { + background: $color-white; + color: var(--link-color) !important; + border: var(--link-color) 1px solid; + font-size: 0.75rem; letter-spacing: 0.1rem; cursor: pointer; transition: all 100ms ease-in-out; font-weight: 600; font-family: 'Consolas','Monaco','Andale Mono','Ubuntu Mono','monospace' !important; + &:active { background-color: $rx-color-midnight-blue !important; border: 1px solid $rx-color-midnight-blue !important; opacity: 1; } + &:hover { background: $rx-color-midnight-blue; border-color: $rx-color-midnight-blue; color: $color-white !important; } +} + +/* Navigation & bars */ +.breadcrumbs { font-size: 80%; margin: 0; } + +/* Media / imagery */ +.openseadragon-container span { color: transparent; display: inline-flex !important; font-size: 100%; line-height: initial; white-space: nowrap; } +.box { width: 25%; padding-bottom: 25%; position: relative; float: left; } +.box a img { width: 100%; overflow: hidden; } +ul.listing-thumbs > li > img.thumbnail-image { height: 150px; } + +/* Info panels */ +.rx-head-container { margin: 0 0 3em; } +.rx-info-content { padding: 0 0 1.25em; font-size: 0.9rem; &:last-child { padding: 0; } } +.rx-info-content-label { color: $rx-color-cement-gray !important; font-weight: 600 !important; font-size: 0.9rem !important; } +.rx-info-content-value { color: $rx-color-cement-gray; line-height: 1.5; } +.rx-info-content .uk-tab a { color: $rx-color-cement-gray; &:hover { color: #666; } } + +/* Badges */ +.rx-annotation-badge { border-radius: 3px; background: $color-white; color: $rx-color-cement-gray !important; border: $rx-color-cement-gray 1px solid; font-size: 0.75rem; letter-spacing: 0.1rem; font-weight: 600; display: inline-block; cursor: default; text-transform: uppercase; padding: 0.1rem 0.5rem; } + +/* Fieldsets */ +.rx-fieldset { border: none; } + +/* Titles & banners */ +.rx-sticky { position: sticky; top: 0; z-index: 1; } +.rx-title-container { background: $color-white; padding: 1em 0; position: sticky; top: 0; z-index: 3; } +.rx-title { font-size: 2em; font-weight: bold; color: var(--link-color); } +.rx-title-2 { font-size: 1.5em; font-weight: bold; color: $rx-color-cement-gray; } +.rx-title-tagline { font-size: 14px; color: $color-white; } +.rx-title-image { object-fit: cover; height: 150px; width: 100%; } +.rx-breadcrumb-item { font-size: 1rem; font-weight: bold; color: var(--link-color); } + +/* Tooltip */ +.tooltip { position: relative; display: inline-block; border-bottom: 1px dotted black; } +.tooltip .tooltiptext { visibility: hidden; width: 120px; background-color: $color-black; color: $color-white; text-align: center; border-radius: 6px; padding: 5px 0; position: absolute; z-index: 1; top: 150%; left: 50%; margin-left: -60px; font-size: 14px; } +.tooltip .tooltiptext::after { content: ""; position: absolute; bottom: 100%; left: 50%; margin-left: -5px; border-width: 5px; border-style: solid; border-color: transparent transparent black transparent; } +.tooltip:hover .tooltiptext { visibility: visible; } + +/* Page search */ +.rx-page-search-container { width: calc(100% - 30px) !important; color: $rx-color-cement-gray !important; } +.rx-page-search-option-label { display: block; } + +/* Reader / viewer & layout utils */ +.rdx-viewer-container { position: absolute; left: 0; bottom: 0; height: calc(100% - 36px); width: 100%; margin: 0; } +#rdx-viewer > div > div > div.py-8.grid.grid-cols-2.gap-2 { padding-bottom: 0; } +.reader-modal { width: 400px !important; height: fit-content !important; padding: 0 !important; left: 75px !important; top: 60px !important; background: $color-white; } +.rx-line-height-sm { line-height: 1.25em; } +.rx-volume-search em { color: $rx-color-mario-red !important; } +.rx-volume-search { max-height: 75vh; } +.rx-padding-extra-small { padding: 10px; } +.rx-scrollable-area { max-height: 75vh; overflow-y: auto; -ms-overflow-style: none; scrollbar-width: none; &::-webkit-scrollbar { display: none; } } +.count { position: absolute; top: 0; right: 0; padding-top: 20%; padding-right: 20%; height: 10px; width: 10px; font-size: 10px; text-align: center; } +.rx-flex { display: flex; } +.uk-accordion-content { padding: 0.5rem 10px;} + +/* Accordion (rx-) */ +.rx-accordion-head { background: $color-white; color: var(--link-color); border: var(--link-color) 1px solid; font-size: 0.85rem; letter-spacing: 0.1rem; cursor: pointer; transition: all 100ms ease-in-out; font-weight: 600; font-family: 'Consolas','Monaco','Andale Mono','Ubuntu Mono','monospace' !important; &::before { color: $color-white; } } +.rx-accordion-handle { color: var(--link-color) !important; } +.rx-accordion-content { margin: 0; padding: 0 0 10px 0; } +.rx-accordion-container { margin: 20px 0 0 0; } + + +.thumbnail-container { + width: 120px; +} +.thumbnail-container img { + width: 100%; + height: auto; + object-fit: cover; +} + +.cursor-default { + &:hover { + cursor: default !important; + } +} + + +/* Volume grid specific styles */ + /* square container for covers */ + .cover-square { + position: relative; + width: 100%; + aspect-ratio: 1 / 1; /* makes a perfect square */ + background: #000; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + border-radius: 4px; + } + .cover-square img { + max-width: 100%; + max-height: 100%; + width:auto; + height:auto; + display: block; + object-fit: contain; + object-position: center; + } + .volume-title, + .volume-title-inline { + margin-top: .5rem; + margin-bottom: .25rem; + line-height: 1.25; + } + .volume-title__link { + color: $rx-color-midnight-blue; + font-weight: 600; + font-size: medium; + text-decoration: underline; + } + .volume-title__year { + white-space: nowrap; + } + .volume-title__link:hover { + color: $rx-color-midnight-blue; + text-decoration: underline; + } + .volume-year { + margin-top: .5rem; + font-weight: 600; + color: $rx-color-midnight-blue; + line-height: 1.25; + } + .author-line { + color: #555; + line-height: 1.25; + display: -webkit-box; + -webkit-line-clamp: 3; + line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; + white-space: normal; + } +/* End */ + +.rx-line-height-lg { + line-height: 1.15em; +} + +.rx-line-height-md { + line-height: 1em; +} + +.rx-line-height-sm { + line-height: 0.85em; +} diff --git a/apps/static/css/selectize.css b/apps/static/css/selectize.css deleted file mode 100644 index 0af2f658d..000000000 --- a/apps/static/css/selectize.css +++ /dev/null @@ -1,376 +0,0 @@ -/** - * selectize.css (v0.11.0) - * Copyright (c) 2013 Brian Reavis & contributors - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this - * file except in compliance with the License. You may obtain a copy of the License at: - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF - * ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - * - * @author Brian Reavis - */ - -.selectize-control.plugin-drag_drop.multi > .selectize-input > div.ui-sortable-placeholder { - visibility: visible !important; - background: #f2f2f2 !important; - background: rgba(0, 0, 0, 0.06) !important; - border: 0 none !important; - -webkit-box-shadow: inset 0 0 12px 4px #ffffff; - box-shadow: inset 0 0 12px 4px #ffffff; -} - -.selectize-control.plugin-drag_drop .ui-sortable-placeholder::after { - content: '!'; - visibility: hidden; -} - -.selectize-control.plugin-drag_drop .ui-sortable-helper { - -webkit-box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); - box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); -} - -.selectize-dropdown-header { - position: relative; - padding: 5px 8px; - border-bottom: 1px solid #d0d0d0; - background: #f8f8f8; - -webkit-border-radius: 3px 3px 0 0; - -moz-border-radius: 3px 3px 0 0; - border-radius: 3px 3px 0 0; -} - -.selectize-dropdown-header-close { - position: absolute; - right: 8px; - top: 50%; - color: #303030; - opacity: 0.4; - margin-top: -12px; - line-height: 20px; - font-size: 20px !important; -} - -.selectize-dropdown-header-close:hover { - color: #000000; -} - -.selectize-dropdown.plugin-optgroup_columns .optgroup { - border-right: 1px solid #f2f2f2; - border-top: 0 none; - float: left; - -webkit-box-sizing: border-box; - -moz-box-sizing: border-box; - box-sizing: border-box; -} - -.selectize-dropdown.plugin-optgroup_columns .optgroup:last-child { - border-right: 0 none; -} - -.selectize-dropdown.plugin-optgroup_columns .optgroup:before { - display: none; -} - -.selectize-dropdown.plugin-optgroup_columns .optgroup-header { - border-top: 0 none; -} - -.selectize-control.plugin-remove_button [data-value] { - position: relative; - padding-right: 24px !important; -} - -.selectize-control.plugin-remove_button [data-value] .remove { - z-index: 1; - /* fixes ie bug (see #392) */ - position: absolute; - top: 0; - right: 0; - bottom: 0; - width: 17px; - text-align: center; - font-weight: bold; - font-size: 12px; - color: inherit; - text-decoration: none; - vertical-align: middle; - display: inline-block; - padding: 2px 0 0 0; - border-left: 1px solid #d0d0d0; - -webkit-border-radius: 0 2px 2px 0; - -moz-border-radius: 0 2px 2px 0; - border-radius: 0 2px 2px 0; - -webkit-box-sizing: border-box; - -moz-box-sizing: border-box; - box-sizing: border-box; -} - -.selectize-control.plugin-remove_button [data-value] .remove:hover { - background: rgba(0, 0, 0, 0.05); -} - -.selectize-control.plugin-remove_button [data-value].active .remove { - border-left-color: #cacaca; -} - -.selectize-control.plugin-remove_button .disabled [data-value] .remove:hover { - background: none; -} - -.selectize-control.plugin-remove_button .disabled [data-value] .remove { - border-left-color: #ffffff; -} - -.selectize-control { - position: relative; -} - -.selectize-dropdown, -.selectize-input, -.selectize-input input { - color: #303030; - font-family: inherit; - font-size: 13px; - line-height: 18px; - -webkit-font-smoothing: inherit; -} - -.selectize-input, -.selectize-control.single .selectize-input.input-active { - background: #ffffff; - cursor: text; - display: inline-block; -} - -.selectize-input { - border: 1px solid #d0d0d0; - padding: 8px 8px; - display: inline-block; - width: 100%; - overflow: hidden; - position: relative; - z-index: 1; - -webkit-box-sizing: border-box; - -moz-box-sizing: border-box; - box-sizing: border-box; - -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.1); - box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.1); - -webkit-border-radius: 3px; - -moz-border-radius: 3px; - border-radius: 3px; -} - -.selectize-control.multi .selectize-input.has-items { - padding: 6px 8px 3px; -} - -.selectize-input.full { - background-color: #ffffff; -} - -.selectize-input.disabled, -.selectize-input.disabled * { - cursor: default !important; -} - -.selectize-input.focus { - -webkit-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.15); - box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.15); -} - -.selectize-input.dropdown-active { - -webkit-border-radius: 3px 3px 0 0; - -moz-border-radius: 3px 3px 0 0; - border-radius: 3px 3px 0 0; -} - -.selectize-input > * { - vertical-align: baseline; - display: -moz-inline-stack; - display: inline-block; - zoom: 1; - *display: inline; -} - -.selectize-control.multi .selectize-input > div { - cursor: pointer; - margin: 0 3px 3px 0; - padding: 2px 6px; - background: #f2f2f2; - color: #303030; - border: 0 solid #d0d0d0; -} - -.selectize-control.multi .selectize-input > div.active { - background: #e8e8e8; - color: #303030; - border: 0 solid #cacaca; -} - -.selectize-control.multi .selectize-input.disabled > div, -.selectize-control.multi .selectize-input.disabled > div.active { - color: #7d7d7d; - background: #ffffff; - border: 0 solid #ffffff; -} - -.selectize-input > input { - padding: 0 !important; - min-height: 0 !important; - max-height: none !important; - max-width: 100% !important; - margin: 0 2px 0 0 !important; - text-indent: 0 !important; - border: 0 none !important; - background: none !important; - line-height: inherit !important; - -webkit-user-select: auto !important; - -webkit-box-shadow: none !important; - box-shadow: none !important; -} - -.selectize-input > input::-ms-clear { - display: none; -} - -.selectize-input > input:focus { - outline: none !important; -} - -.selectize-input::after { - content: ' '; - display: block; - clear: left; -} - -.selectize-input.dropdown-active::before { - content: ' '; - display: block; - position: absolute; - background: #f0f0f0; - height: 1px; - bottom: 0; - left: 0; - right: 0; -} - -.selectize-dropdown { - position: absolute; - z-index: 10; - border: 1px solid #d0d0d0; - background: #ffffff; - margin: -1px 0 0 0; - border-top: 0 none; - -webkit-box-sizing: border-box; - -moz-box-sizing: border-box; - box-sizing: border-box; - -webkit-box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); - -webkit-border-radius: 0 0 3px 3px; - -moz-border-radius: 0 0 3px 3px; - border-radius: 0 0 3px 3px; -} - -.selectize-dropdown [data-selectable] { - cursor: pointer; - overflow: hidden; -} - -.selectize-dropdown [data-selectable] .highlight { - background: rgba(125, 168, 208, 0.2); - -webkit-border-radius: 1px; - -moz-border-radius: 1px; - border-radius: 1px; -} - -.selectize-dropdown [data-selectable], -.selectize-dropdown .optgroup-header { - padding: 5px 8px; -} - -.selectize-dropdown .optgroup:first-child .optgroup-header { - border-top: 0 none; -} - -.selectize-dropdown .optgroup-header { - color: #303030; - background: #ffffff; - cursor: default; -} - -.selectize-dropdown .active { - background-color: #EEEEEE; - color: #495c68; -} - -.selectize-dropdown .selected { - background-color: #D7E5F0; - color: #495c68; -} - -.selectize-dropdown .selected.active { - background-color: #CED9E2; - color: #495c68; -} - -.selectize-dropdown .active.create { - color: #495c68; -} - -.selectize-dropdown .create { - color: rgba(48, 48, 48, 0.5); -} - -.selectize-dropdown-content { - overflow-y: auto; - overflow-x: hidden; - max-height: 200px; -} - -.selectize-control.single .selectize-input, -.selectize-control.single .selectize-input input { - cursor: pointer; -} - -.selectize-control.single .selectize-input.input-active, -.selectize-control.single .selectize-input.input-active input { - cursor: text; -} - -.selectize-control.single .selectize-input:after { - content: ' '; - display: block; - position: absolute; - top: 50%; - right: 15px; - margin-top: -3px; - width: 0; - height: 0; - border-style: solid; - border-width: 5px 5px 0 5px; - border-color: #808080 transparent transparent transparent; -} - -.selectize-control.single .selectize-input.dropdown-active:after { - margin-top: -4px; - border-width: 0 5px 5px 5px; - border-color: transparent transparent #808080 transparent; -} - -.selectize-control.rtl.single .selectize-input:after { - left: 15px; - right: auto; -} - -.selectize-control.rtl .selectize-input > input { - margin: 0 4px 0 -2px !important; -} - -.selectize-control .selectize-input.disabled { - opacity: 0.5; - background-color: #fafafa; -} diff --git a/apps/static/images/bg.jpg b/apps/static/images/bg.jpg new file mode 100644 index 000000000..55b61628d Binary files /dev/null and b/apps/static/images/bg.jpg differ diff --git a/apps/static/images/ecds-inverse.svg b/apps/static/images/ecds-inverse.svg new file mode 100644 index 000000000..4d01708db --- /dev/null +++ b/apps/static/images/ecds-inverse.svg @@ -0,0 +1,128 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/static/images/ecds.svg b/apps/static/images/ecds.svg index 96045614e..43ef8c8ca 100644 --- a/apps/static/images/ecds.svg +++ b/apps/static/images/ecds.svg @@ -1,408 +1,539 @@ - + - + viewBox="0 0 566.2 100.62" style="enable-background:new 0 0 566.2 100.62;" xml:space="preserve"> - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - + + + - - - + - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/static/images/image_not_found.jpg b/apps/static/images/image_not_found.jpg index d20295ff1..1d166add5 100644 Binary files a/apps/static/images/image_not_found.jpg and b/apps/static/images/image_not_found.jpg differ diff --git a/apps/static/images/image_not_found.svg b/apps/static/images/image_not_found.svg new file mode 100644 index 000000000..0f7b9bf8b --- /dev/null +++ b/apps/static/images/image_not_found.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/apps/static/images/readux-graphics.svg b/apps/static/images/readux-graphics.svg new file mode 100644 index 000000000..16e259e69 --- /dev/null +++ b/apps/static/images/readux-graphics.svg @@ -0,0 +1,15 @@ + + + + + + + + + diff --git a/apps/static/images/video_thumbnail.jpg b/apps/static/images/video_thumbnail.jpg new file mode 100644 index 000000000..ffbf2e4cb Binary files /dev/null and b/apps/static/images/video_thumbnail.jpg differ diff --git a/apps/static/js/components/InfoExport.vue b/apps/static/js/components/InfoExport.vue new file mode 100644 index 000000000..7cc72fed6 --- /dev/null +++ b/apps/static/js/components/InfoExport.vue @@ -0,0 +1,154 @@ + + + diff --git a/apps/static/js/components/InfoUrlExternal.vue b/apps/static/js/components/InfoUrlExternal.vue new file mode 100644 index 000000000..573b1faa9 --- /dev/null +++ b/apps/static/js/components/InfoUrlExternal.vue @@ -0,0 +1,53 @@ + + + diff --git a/apps/static/js/components/InfoUrlMultiple.vue b/apps/static/js/components/InfoUrlMultiple.vue new file mode 100644 index 000000000..3fdc0416b --- /dev/null +++ b/apps/static/js/components/InfoUrlMultiple.vue @@ -0,0 +1,19 @@ + + + diff --git a/apps/static/js/components/InfoUrlSingle.vue b/apps/static/js/components/InfoUrlSingle.vue new file mode 100644 index 000000000..1bc931779 --- /dev/null +++ b/apps/static/js/components/InfoUrlSingle.vue @@ -0,0 +1,37 @@ + + + diff --git a/apps/static/js/components/InfoUrlUnit.vue b/apps/static/js/components/InfoUrlUnit.vue new file mode 100644 index 000000000..8f950bf37 --- /dev/null +++ b/apps/static/js/components/InfoUrlUnit.vue @@ -0,0 +1,32 @@ + + + diff --git a/apps/static/js/components/OcrInspector.vue b/apps/static/js/components/OcrInspector.vue new file mode 100644 index 000000000..da547be63 --- /dev/null +++ b/apps/static/js/components/OcrInspector.vue @@ -0,0 +1,268 @@ + + + + + diff --git a/apps/static/js/components/VolumeAnnotations.vue b/apps/static/js/components/VolumeAnnotations.vue new file mode 100644 index 000000000..747b735c2 --- /dev/null +++ b/apps/static/js/components/VolumeAnnotations.vue @@ -0,0 +1,114 @@ + + + + + \ No newline at end of file diff --git a/apps/static/js/components/VolumeExportAnnotationBtn.vue b/apps/static/js/components/VolumeExportAnnotationBtn.vue new file mode 100644 index 000000000..a3e37d2ed --- /dev/null +++ b/apps/static/js/components/VolumeExportAnnotationBtn.vue @@ -0,0 +1,47 @@ + + + + + \ No newline at end of file diff --git a/apps/static/js/components/VolumeSearch.vue b/apps/static/js/components/VolumeSearch.vue new file mode 100644 index 000000000..53d0957cc --- /dev/null +++ b/apps/static/js/components/VolumeSearch.vue @@ -0,0 +1,164 @@ + + + + + \ No newline at end of file diff --git a/apps/static/js/fontawesome.js b/apps/static/js/fontawesome.js deleted file mode 100644 index 053bee829..000000000 --- a/apps/static/js/fontawesome.js +++ /dev/null @@ -1,2 +0,0 @@ -window.FontAwesomeKitConfig = {"asyncLoading":{"enabled":true},"autoA11y":{"enabled":true},"baseUrl":"https://ka-f.fontawesome.com","baseUrlKit":"https://kit.fontawesome.com","detectConflictsUntil":"2019-12-10T20:14:41Z","iconUploads":{},"id":45311454,"license":"free","method":"css","minify":{"enabled":true},"token":"2ebfe61a88","v4FontFaceShim":{"enabled":true},"v4shim":{"enabled":true},"v5FontFaceShim":{"enabled":false},"version":"5.15.4"}; -!function(t){"function"==typeof define&&define.amd?define("kit-loader",t):t()}((function(){"use strict";function t(e){return(t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(e)}function e(t,e,n){return e in t?Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!0}):t[e]=n,t}function n(t,e){var n=Object.keys(t);if(Object.getOwnPropertySymbols){var o=Object.getOwnPropertySymbols(t);e&&(o=o.filter((function(e){return Object.getOwnPropertyDescriptor(t,e).enumerable}))),n.push.apply(n,o)}return n}function o(t){for(var o=1;ot.length)&&(e=t.length);for(var n=0,o=new Array(e);n2&&void 0!==arguments[2]?arguments[2]:function(){},r=e.document||r,i=u.bind(u,r,["fa","fab","fas","far","fal","fad","fak"]),f=Object.keys(t.iconUploads||{}).length>0;t.autoA11y.enabled&&n(i);var s=[{id:"fa-main",addOn:void 0}];t.v4shim&&t.v4shim.enabled&&s.push({id:"fa-v4-shims",addOn:"-v4-shims"}),t.v5FontFaceShim&&t.v5FontFaceShim.enabled&&s.push({id:"fa-v5-font-face",addOn:"-v5-font-face"}),t.v4FontFaceShim&&t.v4FontFaceShim.enabled&&s.push({id:"fa-v4-font-face",addOn:"-v4-font-face"}),f&&s.push({id:"fa-kit-upload",customCss:!0});var d=s.map((function(n){return new _((function(r,i){F(n.customCss?a(t):c(t,{addOn:n.addOn,minify:t.minify.enabled}),e).then((function(i){r(U(i,o(o({},e),{},{baseUrl:t.baseUrl,version:t.version,id:n.id,contentFilter:function(t,e){return P(t,e.baseUrl,e.version)}})))})).catch(i)}))}));return _.all(d)}function U(t,e){var n=e.contentFilter||function(t,e){return t},o=document.createElement("style"),r=document.createTextNode(n(t,e));return o.appendChild(r),o.media="all",e.id&&o.setAttribute("id",e.id),e&&e.detectingConflicts&&e.detectionIgnoreAttr&&o.setAttributeNode(document.createAttribute(e.detectionIgnoreAttr)),o}function k(t,e){e.autoA11y=t.autoA11y.enabled,"pro"===t.license&&(e.autoFetchSvg=!0,e.fetchSvgFrom=t.baseUrl+"/releases/"+("latest"===t.version?"latest":"v".concat(t.version))+"/svgs",e.fetchUploadedSvgFrom=t.uploadsUrl);var n=[];return t.v4shim.enabled&&n.push(new _((function(n,r){F(c(t,{addOn:"-v4-shims",minify:t.minify.enabled}),e).then((function(t){n(I(t,o(o({},e),{},{id:"fa-v4-shims"})))})).catch(r)}))),n.push(new _((function(n,r){F(c(t,{minify:t.minify.enabled}),e).then((function(t){var r=I(t,o(o({},e),{},{id:"fa-main"}));n(function(t,e){var n=e&&void 0!==e.autoFetchSvg?e.autoFetchSvg:void 0,o=e&&void 0!==e.autoA11y?e.autoA11y:void 0;void 0!==o&&t.setAttribute("data-auto-a11y",o?"true":"false");n&&(t.setAttributeNode(document.createAttribute("data-auto-fetch-svg")),t.setAttribute("data-fetch-svg-from",e.fetchSvgFrom),t.setAttribute("data-fetch-uploaded-svg-from",e.fetchUploadedSvgFrom));return t}(r,e))})).catch(r)}))),_.all(n)}function I(t,e){var n=document.createElement("SCRIPT"),o=document.createTextNode(t);return n.appendChild(o),n.referrerPolicy="strict-origin",e.id&&n.setAttribute("id",e.id),e&&e.detectingConflicts&&e.detectionIgnoreAttr&&n.setAttributeNode(document.createAttribute(e.detectionIgnoreAttr)),n}function L(t){var e,n=[],o=document,r=o.documentElement.doScroll,i=(r?/^loaded|^c/:/^loaded|^i|^c/).test(o.readyState);i||o.addEventListener("DOMContentLoaded",e=function(){for(o.removeEventListener("DOMContentLoaded",e),i=1;e=n.shift();)e()}),i?setTimeout(t,0):n.push(t)}function T(t){"undefined"!=typeof MutationObserver&&new MutationObserver(t).observe(document,{childList:!0,subtree:!0})}try{if(window.FontAwesomeKitConfig){var x=window.FontAwesomeKitConfig,M={detectingConflicts:x.detectConflictsUntil&&new Date<=new Date(x.detectConflictsUntil),detectionIgnoreAttr:"data-fa-detection-ignore",fetch:window.fetch,token:x.token,XMLHttpRequest:window.XMLHttpRequest,document:document},D=document.currentScript,N=D?D.parentElement:document.head;(function(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};return"js"===t.method?k(t,e):"css"===t.method?C(t,e,(function(t){L(t),T(t)})):void 0})(x,M).then((function(t){t.map((function(t){try{N.insertBefore(t,D?D.nextSibling:null)}catch(e){N.appendChild(t)}})),M.detectingConflicts&&D&&L((function(){D.setAttributeNode(document.createAttribute(M.detectionIgnoreAttr));var t=function(t,e){var n=document.createElement("script");return e&&e.detectionIgnoreAttr&&n.setAttributeNode(document.createAttribute(e.detectionIgnoreAttr)),n.src=c(t,{baseFilename:"conflict-detection",fileSuffix:"js",subdir:"js",minify:t.minify.enabled}),n}(x,M);document.body.appendChild(t)}))})).catch((function(t){console.error("".concat("Font Awesome Kit:"," ").concat(t))}))}}catch(t){console.error("".concat("Font Awesome Kit:"," ").concat(t))}})); diff --git a/apps/static/js/index.js b/apps/static/js/index.js index aaaa071fc..f29d81bb4 100644 --- a/apps/static/js/index.js +++ b/apps/static/js/index.js @@ -1,10 +1,11 @@ window.$ = require('jquery'); -window.Vue = require('/apps/static/js/vue.js'); window.axios = require('axios'); -require('/apps/static/js/vue-clipboard.min.js'); -require('selectize'); -require('/apps/static/js/project.js'); +window.noUiSlider = require('nouislider'); +require('@selectize/selectize'); require('/apps/static/js/vue-readux.js'); + +// UIkit window.UIkit = require('uikit'); -window.UIkitIcons = require('/apps/static/js/uikit-icons.min.js'); -require('/apps/static/js/project.js'); +window.UIkitIcons = require('uikit/dist/js/uikit-icons'); +// require('/apps/static/js/project.js'); +window.ECDSAnnotator = require('../../../node_modules/ecds-annotator/dist/ecds-annotator.min.js'); \ No newline at end of file diff --git a/apps/static/js/main.js b/apps/static/js/main.js deleted file mode 100644 index a4c229d14..000000000 --- a/apps/static/js/main.js +++ /dev/null @@ -1,2 +0,0 @@ -/*! For license information please see main.js.LICENSE.txt */ -(()=>{var t={931:()=>{$(document).ready((function(){$(".collection-image-info").on("click",(function(){$(this).toggleClass("collasped")}))})),$.each($(".rx-nav-item"),(function(t,e){var n=!(location.pathname.split("/").length-1-("/"==location.pathname[location.pathname.length-1]?1:0)>1),i=window.location.pathname.replace(/^\/([^\/]*).*$/,"$1");e.href.includes(i)&&""!=i&&n&&e.classList.add("uk-active")})),$(document).ready((function(){var t=$(".rx-home-right-column").offset().top;$(".rx-splash").css("top",t),document.URL.replace(/\/+$/,"")==window.location.origin?$("#rx-nav").addClass("rx-sticky"):$("#rx-nav").removeClass("rx-sticky")}));const t=new PopStateEvent("popstate",{state:{}});$(document).ready((function(){const e=document.getElementById("manifest-search-form");e&&e.addEventListener("submit",(function(e){e.preventDefault(),function(){const e=document.getElementById("rdx-search-results"),n=document.getElementById("search-volume-pid").value,i=document.getElementById("search-query-text").value;let r="partial";document.getElementById("search-exact").checked&&(r="exact"),url=`/search/volume/pages?volume=${n}&query=${i}&type=${r}`,fetch(url).then((t=>t.json())).then((n=>{if(ocrAnnotationCount=n.ocr_annotations.length,userAnnotationCount=n.user_annotations.length,ocrAnnotationUL=document.getElementById("ocr-annotation-results"),ocrAnnotationUL.innerHTML="",userAnnotationUL=document.getElementById("user-annotation-results"),userAnnotationUL.innerHTML="",document.getElementById("annotation-count").innerText=`${ocrAnnotationCount}`,document.getElementById("user-annotation-count").innerText=`${userAnnotationCount}`,ocrAnnotationCount>0)n.ocr_annotations.forEach((e=>{const n=JSON.parse(e),i=document.createElement("li"),r=document.createElement("a");r.href="#",r.addEventListener("click",(e=>{e.preventDefault();let i=window.location.pathname.split("/");i.pop(),i.push(n.canvas__pid),history.pushState({},"",`${window.location.origin}${i.join("/")}`),dispatchEvent(t)})),r.innerText=`Canvas ${n.canvas__position} - number of results ${n.canvas__position__count}`,i.appendChild(r),ocrAnnotationUL.appendChild(i)}));else{const t=document.createElement("p");t.innerText="No results in the text in this volume",ocrAnnotationUL.appendChild(t)}if(userAnnotationCount>0)n.user_annotations.forEach((e=>{const n=JSON.parse(e),i=document.createElement("li"),r=document.createElement("a");r.href="#",r.addEventListener("click",(e=>{e.preventDefault();let i=window.location.pathname.split("/");i.pop(),i.push(n.canvas__pid),history.pushState({},"",`${window.location.origin}${i.join("/")}`),dispatchEvent(t)})),r.innerText=`Canvas ${n.canvas__position}`,i.appendChild(r),userAnnotationUL.appendChild(i)}));else{const t=document.createElement("p");t.innerText="No results in your annotations in this volume",userAnnotationUL.appendChild(t)}e.classList.remove("uk-hidden")}))}()}))}))},635:function(t){t.exports=function(){"use strict";function t(e){t.installed||e.icon.add({"500px":'',album:'',"arrow-down":'',"arrow-left":'',"arrow-right":'',"arrow-up":'',bag:'',ban:'',behance:'',bell:'',bold:'',bolt:'',bookmark:'',calendar:'',camera:'',cart:'',check:'',"chevron-double-left":'',"chevron-double-right":'',"chevron-down":'',"chevron-left":'',"chevron-right":'',"chevron-up":'',clock:'',close:'',"cloud-download":'',"cloud-upload":'',code:'',cog:'',comment:'',commenting:'',comments:'',copy:'',"credit-card":'',database:'',desktop:'',discord:'',download:'',dribbble:'',etsy:'',expand:'',facebook:'',"file-edit":'',"file-pdf":'',"file-text":'',file:'',flickr:'',folder:'',forward:'',foursquare:'',future:'',"git-branch":'',"git-fork":'',"github-alt":'',github:'',gitter:'',google:'',grid:'',happy:'',hashtag:'',heart:'',history:'',home:'',image:'',info:'',instagram:'',italic:'',joomla:'',laptop:'',lifesaver:'',link:'',linkedin:'',list:'',location:'',lock:'',mail:'',menu:'',microphone:'',"minus-circle":'',minus:'',"more-vertical":'',more:'',move:'',nut:'',pagekit:'',"paint-bucket":'',pencil:'',"phone-landscape":'',phone:'',pinterest:'',"play-circle":'',play:'',"plus-circle":'',plus:'',print:'',pull:'',push:'',question:'',"quote-right":'',receiver:'',reddit:'',refresh:'',reply:'',rss:'',search:'',server:'',settings:'',shrink:'',"sign-in":'',"sign-out":'',social:'',soundcloud:'',star:'',strikethrough:'',table:'',"tablet-landscape":'',tablet:'',tag:'',thumbnails:'',tiktok:'',trash:'',"triangle-down":'',"triangle-left":'',"triangle-right":'',"triangle-up":'',tripadvisor:'',tumblr:'',tv:'',twitch:'',twitter:'',uikit:'',unlock:'',upload:'',user:'',users:'',"video-camera":'',vimeo:'',warning:'',whatsapp:'',wordpress:'',world:'',xing:'',yelp:'',youtube:''})}return typeof window<"u"&&window.UIkit&&window.UIkit.use(t),t}()},739:(t,e,n)=>{var i,r,o;!function t(e,n,i){function r(s,a){if(!n[s]){if(!e[s]){if(o)return o(s,!0);var l=new Error("Cannot find module '"+s+"'");throw l.code="MODULE_NOT_FOUND",l}var c=n[s]={exports:{}};e[s][0].call(c.exports,(function(t){return r(e[s][1][t]||t)}),c,c.exports,t,e,n,i)}return n[s].exports}for(var o=void 0,s=0;s{Vue.component("v-volume-image",{props:["imgSrc","volumeLabel"],template:'\n
\n \n

Volume is being added or cover is not available.

\n
\n \n ',data:function(){return{hasImage:!0}},mounted(){var t=this,e=new Image;e.addEventListener("load",(function(){t.hasImage=!0})),e.addEventListener("error",(function(){t.hasImage=!1})),e.src=this.imgSrc},computed:{imgAlt(){return`First page of ${this.volumeLabel}`}}}),Vue.component("v-volume-export-annotation-btn",{props:["manifestCount"],template:'\n
\n \n
\n ',data:function(){return{localManifestCount:this.manifestCount,isExportVisible:!1}},mounted(){var t=this;window.addEventListener("canvasswitch",(function(e){e.detail.annotationsOnPage&&(e.detail.annotationAdded&&t.localManifestCount++,e.detail.annotationDeleted&&t.localManifestCount--,t.isExportVisible=t.localManifestCount>=1)})),t.isExportVisible=t.localManifestCount>=1}}),Vue.component("v-volume-annotations",{props:["manifestCount","pageCount"],template:'\n
\n \n
{{localManifestCount}} in manifest
\n
{{localPageCount}} on page
\n\n
\n \n \n \n ',data:function(){return{hasImage:!1,localManifestCount:this.manifestCount,localPageCount:this.pageCount,annotationData:{}}},mounted(){var t=this;this.annotationData=JSON.parse(document.getElementById("context").textContent).json_data,window.addEventListener("canvasswitch",(function(e){if(e.detail.annotationAdded){for(var n=!0,i=0;i\n \n
\n {{url}}\n
\n \n ',methods:{onCopy(){alert(`You have copied: ${this.url}`)},onError(){alert("Something went wrong with copy.")}}}),Vue.component("v-info-content-url-unit",{props:["url"],template:'\n
\n {{url}}\n
\n Copy\n
\n
\n ',methods:{onCopy(){alert(`You have copied: ${this.url}`)},onError(){alert("Something went wrong with copy.")}}}),Vue.component("v-info-content-url-multiple",{props:["label"],template:'\n
\n \n
\n \n
\n
\n '}),Vue.component("v-info-content-url-external",{props:["label","url","volume"],data:function(){return{localUrl:this.url}},template:'\n
\n \n \n
\n ',methods:{onCopy(){alert(`You have copied: ${this.localUrl}`)},onError(){alert("Something went wrong with copy.")}},mounted(){var t=this;window.addEventListener("canvasswitch",(function(e){if(e.detail){var n=window.location.protocol,i=window.location.host,r=e.detail.canvas,o=t.volume,s=n+"//"+i+o+"/page/"+r;t.localUrl=s,t.vol=o}}))}}),Vue.component("v-info-content-url-image-link",{props:["label","pagelink"],data:function(){return{localUrls:this.url,pageresource:this.pageresource}},template:'\n
\n \n \n
\n ',methods:{onCopy(){alert(`You have copied: ${this.localUrls}`)},onError(){alert("Something went wrong with copy.")}},mounted(){var t=this;window.addEventListener("canvasswitch",(function(e){if(e.detail){window.location.protocol,window.location.host;var n=e.detail.canvas,i=(e.detail.volume,t.pagelink);axios.get(`iiif/resource/${e.detail.canvas}`).then((e=>{console.log(e.data.resource),console.log(e.data.text),t.pageresource=e.data.resource,t.pagetext=e.data.text})).catch((t=>{console.log(t)}));var r=i+"/"+n+"/full/full/0/default.jpg";t.localUrls=r,t.can=n}}))}}),Vue.component("v-info-content-url-page-text",{props:[],data:function(){return{pagetext:this.pagetext}},template:'\n
\n
\n \n

Text

\n

{{pagetext}}

\n
\n
\n ',methods:{},mounted(){var t=this;window.addEventListener("canvasswitch",(function(e){if(e.detail){window.location.protocol,window.location.host;var n=e.detail.canvas,i=(e.detail.volume,t.pagelink);axios.get(`iiif/resource/${e.detail.canvas}`).then((e=>{console.log(e.data.resource),console.log(e.data.text),t.pageresource=e.data.resource,t.pagetext=e.data.text})).catch((t=>{console.log(t)}));var r=i+"/"+n+"/full/full/0/default.jpg";t.localUrls=r,t.can=n}}))}}),new Vue({el:"#v-readux",delimiters:["[[","]]"],component:{VueClipboard},data:{options:["title","author","date published","date added"],searchPrefix:"?sort=",currentSelection:null,itemNotFound:!1,showMoreInfo:!1},methods:{sortBy:function(t){var e=this.searchPrefix+t;window.location!==e&&(window.location=e)},toggleMoreInfo:function(){this.showMoreInfo=!this.showMoreInfo}},mounted:function(){this.$refs["v-attr-sort"]&&(this.currentSelection=this.$refs["v-attr-sort"].getAttribute("data-sort")),window.location.href.includes("?q=")&&(this.showMoreInfo=!0)}})},625:function(t,e,n){t.exports=function(){"use strict";var t=Object.freeze({});function e(t){return null==t}function i(t){return null!=t}function r(t){return!0===t}function o(t){return"string"==typeof t||"number"==typeof t||"symbol"==typeof t||"boolean"==typeof t}function s(t){return null!==t&&"object"==typeof t}var a=Object.prototype.toString;function l(t){return a.call(t).slice(8,-1)}function c(t){return"[object Object]"===a.call(t)}function u(t){return"[object RegExp]"===a.call(t)}function h(t){var e=parseFloat(String(t));return e>=0&&Math.floor(e)===e&&isFinite(t)}function d(t){return i(t)&&"function"==typeof t.then&&"function"==typeof t.catch}function p(t){return null==t?"":Array.isArray(t)||c(t)&&t.toString===a?JSON.stringify(t,null,2):String(t)}function f(t){var e=parseFloat(t);return isNaN(e)?t:e}function g(t,e){for(var n=Object.create(null),i=t.split(","),r=0;r-1)return t.splice(n,1)}}var w=Object.prototype.hasOwnProperty;function x(t,e){return w.call(t,e)}function b(t){var e=Object.create(null);return function(n){return e[n]||(e[n]=t(n))}}var C=/-(\w)/g,k=b((function(t){return t.replace(C,(function(t,e){return e?e.toUpperCase():""}))})),$=b((function(t){return t.charAt(0).toUpperCase()+t.slice(1)})),_=/\B([A-Z])/g,S=b((function(t){return t.replace(_,"-$1").toLowerCase()}));var T=Function.prototype.bind?function(t,e){return t.bind(e)}:function(t,e){function n(n){var i=arguments.length;return i?i>1?t.apply(e,arguments):t.call(e,n):t.call(e)}return n._length=t.length,n};function L(t,e){e=e||0;for(var n=t.length-e,i=new Array(n);n--;)i[n]=t[n+e];return i}function A(t,e){for(var n in e)t[n]=e[n];return t}function O(t){for(var e={},n=0;n0,Q=X&&X.indexOf("edge/")>0,tt=(X&&X.indexOf("android"),X&&/iphone|ipad|ipod|ios/.test(X)||"ios"===J),et=(X&&/chrome\/\d+/.test(X),X&&/phantomjs/.test(X),X&&X.match(/firefox\/(\d+)/)),nt={}.watch,it=!1;if(Z)try{var rt={};Object.defineProperty(rt,"passive",{get:function(){it=!0}}),window.addEventListener("test-passive",null,rt)}catch(t){}var ot=function(){return void 0===U&&(U=!Z&&!K&&void 0!==n.g&&n.g.process&&"server"===n.g.process.env.VUE_ENV),U},st=Z&&window.__VUE_DEVTOOLS_GLOBAL_HOOK__;function at(t){return"function"==typeof t&&/native code/.test(t.toString())}var lt,ct="undefined"!=typeof Symbol&&at(Symbol)&&"undefined"!=typeof Reflect&&at(Reflect.ownKeys);lt="undefined"!=typeof Set&&at(Set)?Set:function(){function t(){this.set=Object.create(null)}return t.prototype.has=function(t){return!0===this.set[t]},t.prototype.add=function(t){this.set[t]=!0},t.prototype.clear=function(){this.set=Object.create(null)},t}();var ut,ht,dt,pt=E,ft="undefined"!=typeof console,gt=/(?:^|[-_])(\w)/g;ut=function(t,e){var n=e?pt(e):"";H.warnHandler?H.warnHandler.call(null,t,e,n):ft&&!H.silent&&console.error("[Vue warn]: "+t+n)},ht=function(t,e){ft&&!H.silent&&console.warn("[Vue tip]: "+t+(e?pt(e):""))},dt=function(t,e){if(t.$root===t)return"";var n="function"==typeof t&&null!=t.cid?t.options:t._isVue?t.$options||t.constructor.options:t,i=n.name||n._componentTag,r=n.__file;if(!i&&r){var o=r.match(/([^/\\]+)\.vue$/);i=o&&o[1]}return(i?"<"+function(t){return t.replace(gt,(function(t){return t.toUpperCase()})).replace(/[-_]/g,"")}(i)+">":"")+(r&&!1!==e?" at "+r:"")};pt=function(t){if(t._isVue&&t.$parent){for(var e=[],n=0;t;){if(e.length>0){var i=e[e.length-1];if(i.constructor===t.constructor){n++,t=t.$parent;continue}n>0&&(e[e.length-1]=[i,n],n=0)}e.push(t),t=t.$parent}return"\n\nfound in\n\n"+e.map((function(t,e){return""+(0===e?"---\x3e ":function(t,e){for(var n="";e;)e%2==1&&(n+=t),e>1&&(t+=t),e>>=1;return n}(" ",5+2*e))+(Array.isArray(t)?dt(t[0])+"... ("+t[1]+" recursive calls)":dt(t))})).join("\n")}return"\n\n(found in "+dt(t)+")"};var vt=0,mt=function(){this.id=vt++,this.subs=[]};mt.prototype.addSub=function(t){this.subs.push(t)},mt.prototype.removeSub=function(t){y(this.subs,t)},mt.prototype.depend=function(){mt.target&&mt.target.addDep(this)},mt.prototype.notify=function(){var t=this.subs.slice();H.async||t.sort((function(t,e){return t.id-e.id}));for(var e=0,n=t.length;e-1)if(o&&!x(r,"default"))a=!1;else if(""===a||a===S(t)){var u=Qt(String,r.type);(u<0||c0&&(Pe((a=Be(a,(n||"")+"_"+s))[0])&&Pe(c)&&(u[l]=$t(c.text+a[0].text),a.shift()),u.push.apply(u,a)):o(a)?Pe(c)?u[l]=$t(c.text+a):""!==a&&u.push($t(a)):Pe(a)&&Pe(c)?u[l]=$t(c.text+a.text):(r(t._isVList)&&i(a.tag)&&e(a.key)&&i(n)&&(a.key="__vlist"+n+"_"+s+"__"),u.push(a)));return u}function Fe(t,e){if(t){for(var n=Object.create(null),i=ct?Reflect.ownKeys(t):Object.keys(t),r=0;r0,s=e?!!e.$stable:!o,a=e&&e.$key;if(e){if(e._normalized)return e._normalized;if(s&&i&&i!==t&&a===i.$key&&!o&&!i.$hasNormal)return i;for(var l in r={},e)e[l]&&"$"!==l[0]&&(r[l]=Ve(n,l,e[l]))}else r={};for(var c in n)c in r||(r[c]=Ue(n,c));return e&&Object.isExtensible(e)&&(e._normalized=r),q(r,"$stable",s),q(r,"$key",a),q(r,"$hasNormal",o),r}function Ve(t,e,n){var i=function(){var t=arguments.length?n.apply(null,arguments):n({}),e=(t=t&&"object"==typeof t&&!Array.isArray(t)?[t]:je(t))&&t[0];return t&&(!e||1===t.length&&e.isComment&&!Re(e))?void 0:t};return n.proxy&&Object.defineProperty(t,e,{get:i,enumerable:!0,configurable:!0}),i}function Ue(t,e){return function(){return t[e]}}function We(t,e){var n,r,o,a,l;if(Array.isArray(t)||"string"==typeof t)for(n=new Array(t.length),r=0,o=t.length;r.",t),l=new bt(H.parsePlatformTagName(e),n,r,void 0,void 0,t)):l=n&&n.pre||!i(u=Wt(t.$options,"components",e))?new bt(e,n,r,void 0,void 0,t):pn(u,n,t,r,e)):l=pn(e,n,t,r),Array.isArray(l)?l:i(l)?(i(c)&&vn(l,c),i(n)&&function(t){s(t.style)&&Ae(t.style),s(t.class)&&Ae(t.class)}(n),l):kt()):kt());var l,c,u}(t,e,n,a,l)}function vn(t,n,o){if(t.ns=n,"foreignObject"===t.tag&&(n=void 0,o=!0),i(t.children))for(var s=0,a=t.children.length;sdocument.createEvent("Event").timeStamp&&(Hn=function(){return zn.now()})}function Rn(){var t,e;for(Fn=Hn(),Pn=!0,Mn.sort((function(t,e){return t.id-e.id})),Bn=0;Bn100)){ut("You may have an infinite update loop "+(t.user?'in watcher with expression "'+t.expression+'"':"in a component render function."),t.vm);break}var n=In.slice(),i=Mn.slice();Bn=Mn.length=In.length=0,Dn={},Nn={},jn=Pn=!1,function(t){for(var e=0;eBn&&Mn[n].id>t.id;)n--;Mn.splice(n+1,0,t)}else Mn.push(t);if(!jn){if(jn=!0,!H.async)return void Rn();ye(Rn)}}}(this)},Vn.prototype.run=function(){if(this.active){var t=this.get();if(t!==this.value||s(t)||this.deep){var e=this.value;if(this.value=t,this.user){var n='callback for watcher "'+this.expression+'"';re(this.cb,this.vm,[t,e],this.vm,n)}else this.cb.call(this.vm,t,e)}}},Vn.prototype.evaluate=function(){this.value=this.get(),this.dirty=!1},Vn.prototype.depend=function(){for(var t=this.deps.length;t--;)this.deps[t].depend()},Vn.prototype.teardown=function(){if(this.active){this.vm._isBeingDestroyed||y(this.vm._watchers,this);for(var t=this.deps.length;t--;)this.deps[t].removeSub(this);this.active=!1}};var Un={enumerable:!0,configurable:!0,get:E,set:E};function Wn(t,e,n){Un.get=function(){return this[e][n]},Un.set=function(t){this[e][n]=t},Object.defineProperty(t,n,Un)}function Zn(t){t._watchers=[];var e=t.$options;e.props&&function(t,e){var n=t.$options.propsData||{},i=t._props={},r=t.$options._propKeys=[],o=!t.$parent;o||Ot(!1);var s=function(s){r.push(s);var a=Zt(s,e,n,t),l=S(s);(m(l)||H.isReservedAttr(l))&&ut('"'+l+'" is a reserved attribute and cannot be used as component prop.',t),It(i,s,a,(function(){o||Sn||ut("Avoid mutating a prop directly since the value will be overwritten whenever the parent component re-renders. Instead, use a data or computed property based on the prop's value. Prop being mutated: \""+s+'"',t)})),s in t||Wn(t,"_props",s)};for(var a in e)s(a);Ot(!0)}(t,e.props),e.methods&&function(t,e){var n=t.$options.props;for(var i in e)"function"!=typeof e[i]&&ut('Method "'+i+'" has type "'+typeof e[i]+'" in the component definition. Did you reference the function correctly?',t),n&&x(n,i)&&ut('Method "'+i+'" has already been defined as a prop.',t),i in t&&R(i)&&ut('Method "'+i+'" conflicts with an existing Vue instance method. Avoid defining component methods that start with _ or $.'),t[i]="function"!=typeof e[i]?E:T(e[i],t)}(t,e.methods),e.data?function(t){var e=t.$options.data;c(e=t._data="function"==typeof e?function(t,e){wt();try{return t.call(e,e)}catch(t){return ie(t,e,"data()"),{}}finally{xt()}}(e,t):e||{})||(e={},ut("data functions should return an object:\nhttps://vuejs.org/v2/guide/components.html#data-Must-Be-a-Function",t));for(var n=Object.keys(e),i=t.$options.props,r=t.$options.methods,o=n.length;o--;){var s=n[o];r&&x(r,s)&&ut('Method "'+s+'" has already been defined as a data property.',t),i&&x(i,s)?ut('The data property "'+s+'" is already declared as a prop. Use prop default value instead.',t):R(s)||Wn(t,"_data",s)}Mt(e,!0)}(t):Mt(t._data={},!0),e.computed&&function(t,e){var n=t._computedWatchers=Object.create(null),i=ot();for(var r in e){var o=e[r],s="function"==typeof o?o:o.get;null==s&&ut('Getter is missing for computed property "'+r+'".',t),i||(n[r]=new Vn(t,s||E,E,Kn)),r in t?r in t.$data?ut('The computed property "'+r+'" is already defined in data.',t):t.$options.props&&r in t.$options.props?ut('The computed property "'+r+'" is already defined as a prop.',t):t.$options.methods&&r in t.$options.methods&&ut('The computed property "'+r+'" is already defined as a method.',t):Jn(t,r,o)}}(t,e.computed),e.watch&&e.watch!==nt&&function(t,e){for(var n in e){var i=e[n];if(Array.isArray(i))for(var r=0;r-1:"string"==typeof t?t.split(",").indexOf(e)>-1:!!u(t)&&t.test(e)}function oi(t,e){var n=t.cache,i=t.keys,r=t._vnode;for(var o in n){var s=n[o];if(s){var a=s.name;a&&!e(a)&&si(n,o,i,r)}}}function si(t,e,n,i){var r=t[e];!r||i&&r.tag===i.tag||r.componentInstance.$destroy(),t[e]=null,y(n,e)}(function(e){e.prototype._init=function(e){var n,i,r=this;r._uid=Qn++,H.performance&&le&&(n="vue-perf-start:"+r._uid,i="vue-perf-end:"+r._uid,le(n)),r._isVue=!0,e&&e._isComponent?function(t,e){var n=t.$options=Object.create(t.constructor.options),i=e._parentVnode;n.parent=e.parent,n._parentVnode=i;var r=i.componentOptions;n.propsData=r.propsData,n._parentListeners=r.listeners,n._renderChildren=r.children,n._componentTag=r.tag,e.render&&(n.render=e.render,n.staticRenderFns=e.staticRenderFns)}(r,e):r.$options=Ut(ti(r.constructor),e||{},r),we(r),r._self=r,function(t){var e=t.$options,n=e.parent;if(n&&!e.abstract){for(;n.$options.abstract&&n.$parent;)n=n.$parent;n.$children.push(t)}t.$parent=n,t.$root=n?n.$root:t,t.$children=[],t.$refs={},t._watcher=null,t._inactive=null,t._directInactive=!1,t._isMounted=!1,t._isDestroyed=!1,t._isBeingDestroyed=!1}(r),function(t){t._events=Object.create(null),t._hasHookEvent=!1;var e=t.$options._parentListeners;e&&$n(t,e)}(r),function(e){e._vnode=null,e._staticTrees=null;var n=e.$options,i=e.$vnode=n._parentVnode,r=i&&i.context;e.$slots=He(n._renderChildren,r),e.$scopedSlots=t,e._c=function(t,n,i,r){return gn(e,t,n,i,r,!1)},e.$createElement=function(t,n,i,r){return gn(e,t,n,i,r,!0)};var o=i&&i.data;It(e,"$attrs",o&&o.attrs||t,(function(){!Sn&&ut("$attrs is readonly.",e)}),!0),It(e,"$listeners",n._parentListeners||t,(function(){!Sn&&ut("$listeners is readonly.",e)}),!0)}(r),En(r,"beforeCreate"),function(t){var e=Fe(t.$options.inject,t);e&&(Ot(!1),Object.keys(e).forEach((function(n){It(t,n,e[n],(function(){ut('Avoid mutating an injected value directly since the changes will be overwritten whenever the provided component re-renders. injection being mutated: "'+n+'"',t)}))})),Ot(!0))}(r),Zn(r),function(t){var e=t.$options.provide;e&&(t._provided="function"==typeof e?e.call(t):e)}(r),En(r,"created"),H.performance&&le&&(r._name=dt(r,!1),le(i),ce("vue "+r._name+" init",n,i)),r.$options.el&&r.$mount(r.$options.el)}})(ei),function(t){var e={get:function(){return this._data}},n={get:function(){return this._props}};e.set=function(){ut("Avoid replacing instance root $data. Use nested data properties instead.",this)},n.set=function(){ut("$props is readonly.",this)},Object.defineProperty(t.prototype,"$data",e),Object.defineProperty(t.prototype,"$props",n),t.prototype.$set=Dt,t.prototype.$delete=Nt,t.prototype.$watch=function(t,e,n){var i=this;if(c(e))return Gn(i,t,e,n);(n=n||{}).user=!0;var r=new Vn(i,t,e,n);if(n.immediate){var o='callback for immediate watcher "'+r.expression+'"';wt(),re(e,i,[r.value],i,o),xt()}return function(){r.teardown()}}}(ei),function(t){var e=/^hook:/;t.prototype.$on=function(t,n){var i=this;if(Array.isArray(t))for(var r=0,o=t.length;r1?L(i):i;for(var r=L(arguments,1),o='event handler for "'+t+'"',s=0,a=i.length;sparseInt(this.max)&&si(e,n[0],n,this._vnode),this.vnodeToCache=null}}},created:function(){this.cache=Object.create(null),this.keys=[]},destroyed:function(){for(var t in this.cache)si(this.cache,t,this.keys)},mounted:function(){var t=this;this.cacheVNode(),this.$watch("include",(function(e){oi(t,(function(t){return ri(e,t)}))})),this.$watch("exclude",(function(e){oi(t,(function(t){return!ri(e,t)}))}))},updated:function(){this.cacheVNode()},render:function(){var t=this.$slots.default,e=xn(t),n=e&&e.componentOptions;if(n){var i=ii(n),r=this.include,o=this.exclude;if(r&&(!i||!ri(r,i))||o&&i&&ri(o,i))return e;var s=this.cache,a=this.keys,l=null==e.key?n.Ctor.cid+(n.tag?"::"+n.tag:""):e.key;s[l]?(e.componentInstance=s[l].componentInstance,y(a,l),a.push(l)):(this.vnodeToCache=e,this.keyToCache=l),e.data.keepAlive=!0}return e||t&&t[0]}},ci={KeepAlive:li};(function(t){var e={get:function(){return H},set:function(){ut("Do not replace the Vue.config object, set individual fields instead.")}};Object.defineProperty(t,"config",e),t.util={warn:ut,extend:A,mergeOptions:Ut,defineReactive:It},t.set=Dt,t.delete=Nt,t.nextTick=ye,t.observable=function(t){return Mt(t),t},t.options=Object.create(null),B.forEach((function(e){t.options[e+"s"]=Object.create(null)})),t.options._base=t,A(t.options.components,ci),function(t){t.use=function(t){var e=this._installedPlugins||(this._installedPlugins=[]);if(e.indexOf(t)>-1)return this;var n=L(arguments,1);return n.unshift(this),"function"==typeof t.install?t.install.apply(t,n):"function"==typeof t&&t.apply(null,n),e.push(t),this}}(t),function(t){t.mixin=function(t){return this.options=Ut(this.options,t),this}}(t),ni(t),function(t){B.forEach((function(e){t[e]=function(t,n){return n?("component"===e&&qt(t),"component"===e&&c(n)&&(n.name=n.name||t,n=this.options._base.extend(n)),"directive"===e&&"function"==typeof n&&(n={bind:n,update:n}),this.options[e+"s"][t]=n,n):this.options[e+"s"][t]}}))}(t)})(ei),Object.defineProperty(ei.prototype,"$isServer",{get:ot}),Object.defineProperty(ei.prototype,"$ssrContext",{get:function(){return this.$vnode&&this.$vnode.ssrContext}}),Object.defineProperty(ei,"FunctionalRenderContext",{value:ln}),ei.version="2.6.14";var ui=g("style,class"),hi=g("input,textarea,option,select,progress"),di=function(t,e,n){return"value"===n&&hi(t)&&"button"!==e||"selected"===n&&"option"===t||"checked"===n&&"input"===t||"muted"===n&&"video"===t},pi=g("contenteditable,draggable,spellcheck"),fi=g("events,caret,typing,plaintext-only"),gi=g("allowfullscreen,async,autofocus,autoplay,checked,compact,controls,declare,default,defaultchecked,defaultmuted,defaultselected,defer,disabled,enabled,formnovalidate,hidden,indeterminate,inert,ismap,itemscope,loop,multiple,muted,nohref,noresize,noshade,novalidate,nowrap,open,pauseonexit,readonly,required,reversed,scoped,seamless,selected,sortable,truespeed,typemustmatch,visible"),vi="http://www.w3.org/1999/xlink",mi=function(t){return":"===t.charAt(5)&&"xlink"===t.slice(0,5)},yi=function(t){return mi(t)?t.slice(6,t.length):""},wi=function(t){return null==t||!1===t};function xi(t){for(var e=t.data,n=t,r=t;i(r.componentInstance);)(r=r.componentInstance._vnode)&&r.data&&(e=bi(r.data,e));for(;i(n=n.parent);)n&&n.data&&(e=bi(e,n.data));return o=e.staticClass,s=e.class,i(o)||i(s)?Ci(o,ki(s)):"";var o,s}function bi(t,e){return{staticClass:Ci(t.staticClass,e.staticClass),class:i(t.class)?[t.class,e.class]:e.class}}function Ci(t,e){return t?e?t+" "+e:t:e||""}function ki(t){return Array.isArray(t)?function(t){for(var e,n="",r=0,o=t.length;r-1?Ki(t,e,n):gi(e)?wi(n)?t.removeAttribute(e):(n="allowfullscreen"===e&&"EMBED"===t.tagName?"true":e,t.setAttribute(e,n)):pi(e)?t.setAttribute(e,function(t,e){return wi(e)||"false"===e?"false":"contenteditable"===t&&fi(e)?e:"true"}(e,n)):mi(e)?wi(n)?t.removeAttributeNS(vi,yi(e)):t.setAttributeNS(vi,e,n):Ki(t,e,n)}function Ki(t,e,n){if(wi(n))t.removeAttribute(e);else{if(Y&&!G&&"TEXTAREA"===t.tagName&&"placeholder"===e&&""!==n&&!t.__ieph){var i=function(e){e.stopImmediatePropagation(),t.removeEventListener("input",i)};t.addEventListener("input",i),t.__ieph=!0}t.setAttribute(e,n)}}var Ji={create:Wi,update:Wi};function Xi(t,n){var r=n.elm,o=n.data,s=t.data;if(!(e(o.staticClass)&&e(o.class)&&(e(s)||e(s.staticClass)&&e(s.class)))){var a=xi(n),l=r._transitionClasses;i(l)&&(a=Ci(a,ki(l))),a!==r._prevClass&&(r.setAttribute("class",a),r._prevClass=a)}}var Yi,Gi,Qi,tr,er,nr,ir,rr={create:Xi,update:Xi},or=/[\w).+\-_$\]]/;function sr(t){var e,n,i,r,o,s=!1,a=!1,l=!1,c=!1,u=0,h=0,d=0,p=0;for(i=0;i=0&&" "===(g=t.charAt(f));f--);g&&or.test(g)||(c=!0)}}else void 0===r?(p=i+1,r=t.slice(0,i).trim()):v();function v(){(o||(o=[])).push(t.slice(p,i).trim()),p=i+1}if(void 0===r?r=t.slice(0,i).trim():0!==p&&v(),o)for(i=0;i-1?{exp:t.slice(0,tr),key:'"'+t.slice(tr+1)+'"'}:{exp:t,key:null};for(Gi=t,tr=er=nr=0;!$r();)_r(Qi=kr())?Tr(Qi):91===Qi&&Sr(Qi);return{exp:t.slice(0,er),key:t.slice(er+1,nr)}}(t);return null===n.key?t+"="+e:"$set("+n.exp+", "+n.key+", "+e+")"}function kr(){return Gi.charCodeAt(++tr)}function $r(){return tr>=Yi}function _r(t){return 34===t||39===t}function Sr(t){var e=1;for(er=tr;!$r();)if(_r(t=kr()))Tr(t);else if(91===t&&e++,93===t&&e--,0===e){nr=tr;break}}function Tr(t){for(var e=t;!$r()&&(t=kr())!==e;);}var Lr,Ar="__r";function Or(t,e,n){var i=Lr;return function r(){null!==e.apply(null,arguments)&&Ir(t,r,n,i)}}var Er=ue&&!(et&&Number(et[1])<=53);function Mr(t,e,n,i){if(Er){var r=Fn,o=e;e=o._wrapper=function(t){if(t.target===t.currentTarget||t.timeStamp>=r||t.timeStamp<=0||t.target.ownerDocument!==document)return o.apply(this,arguments)}}Lr.addEventListener(t,e,it?{capture:n,passive:i}:n)}function Ir(t,e,n,i){(i||Lr).removeEventListener(t,e._wrapper||e,n)}function Dr(t,n){if(!e(t.data.on)||!e(n.data.on)){var r=n.data.on||{},o=t.data.on||{};Lr=n.elm,function(t){if(i(t.__r)){var e=Y?"change":"input";t[e]=[].concat(t.__r,t[e]||[]),delete t.__r}i(t.__c)&&(t.change=[].concat(t.__c,t.change||[]),delete t.__c)}(r),Ie(r,o,Mr,Ir,Or,n.context),Lr=void 0}}var Nr,jr={create:Dr,update:Dr};function Pr(t,n){if(!e(t.data.domProps)||!e(n.data.domProps)){var r,o,s=n.elm,a=t.data.domProps||{},l=n.data.domProps||{};for(r in i(l.__ob__)&&(l=n.data.domProps=A({},l)),a)r in l||(s[r]="");for(r in l){if(o=l[r],"textContent"===r||"innerHTML"===r){if(n.children&&(n.children.length=0),o===a[r])continue;1===s.childNodes.length&&s.removeChild(s.childNodes[0])}if("value"===r&&"PROGRESS"!==s.tagName){s._value=o;var c=e(o)?"":String(o);Br(s,c)&&(s.value=c)}else if("innerHTML"===r&&Si(s.tagName)&&e(s.innerHTML)){(Nr=Nr||document.createElement("div")).innerHTML=""+o+"";for(var u=Nr.firstChild;s.firstChild;)s.removeChild(s.firstChild);for(;u.firstChild;)s.appendChild(u.firstChild)}else if(o!==a[r])try{s[r]=o}catch(t){}}}}function Br(t,e){return!t.composing&&("OPTION"===t.tagName||function(t,e){var n=!0;try{n=document.activeElement!==t}catch(t){}return n&&t.value!==e}(t,e)||function(t,e){var n=t.value,r=t._vModifiers;if(i(r)){if(r.number)return f(n)!==f(e);if(r.trim)return n.trim()!==e.trim()}return n!==e}(t,e))}var Fr={create:Pr,update:Pr},Hr=b((function(t){var e={},n=/:(.+)/;return t.split(/;(?![^(]*\))/g).forEach((function(t){if(t){var i=t.split(n);i.length>1&&(e[i[0].trim()]=i[1].trim())}})),e}));function zr(t){var e=Rr(t.style);return t.staticStyle?A(t.staticStyle,e):e}function Rr(t){return Array.isArray(t)?O(t):"string"==typeof t?Hr(t):t}var qr,Vr=/^--/,Ur=/\s*!important$/,Wr=function(t,e,n){if(Vr.test(e))t.style.setProperty(e,n);else if(Ur.test(n))t.style.setProperty(S(e),n.replace(Ur,""),"important");else{var i=Kr(e);if(Array.isArray(n))for(var r=0,o=n.length;r-1?e.split(Yr).forEach((function(e){return t.classList.add(e)})):t.classList.add(e);else{var n=" "+(t.getAttribute("class")||"")+" ";n.indexOf(" "+e+" ")<0&&t.setAttribute("class",(n+e).trim())}}function Qr(t,e){if(e&&(e=e.trim()))if(t.classList)e.indexOf(" ")>-1?e.split(Yr).forEach((function(e){return t.classList.remove(e)})):t.classList.remove(e),t.classList.length||t.removeAttribute("class");else{for(var n=" "+(t.getAttribute("class")||"")+" ",i=" "+e+" ";n.indexOf(i)>=0;)n=n.replace(i," ");(n=n.trim())?t.setAttribute("class",n):t.removeAttribute("class")}}function to(t){if(t){if("object"==typeof t){var e={};return!1!==t.css&&A(e,eo(t.name||"v")),A(e,t),e}return"string"==typeof t?eo(t):void 0}}var eo=b((function(t){return{enterClass:t+"-enter",enterToClass:t+"-enter-to",enterActiveClass:t+"-enter-active",leaveClass:t+"-leave",leaveToClass:t+"-leave-to",leaveActiveClass:t+"-leave-active"}})),no=Z&&!G,io="transition",ro="animation",oo="transition",so="transitionend",ao="animation",lo="animationend";no&&(void 0===window.ontransitionend&&void 0!==window.onwebkittransitionend&&(oo="WebkitTransition",so="webkitTransitionEnd"),void 0===window.onanimationend&&void 0!==window.onwebkitanimationend&&(ao="WebkitAnimation",lo="webkitAnimationEnd"));var co=Z?window.requestAnimationFrame?window.requestAnimationFrame.bind(window):setTimeout:function(t){return t()};function uo(t){co((function(){co(t)}))}function ho(t,e){var n=t._transitionClasses||(t._transitionClasses=[]);n.indexOf(e)<0&&(n.push(e),Gr(t,e))}function po(t,e){t._transitionClasses&&y(t._transitionClasses,e),Qr(t,e)}function fo(t,e,n){var i=vo(t,e),r=i.type,o=i.timeout,s=i.propCount;if(!r)return n();var a=r===io?so:lo,l=0,c=function(){t.removeEventListener(a,u),n()},u=function(e){e.target===t&&++l>=s&&c()};setTimeout((function(){l0&&(n=io,u=s,h=o.length):e===ro?c>0&&(n=ro,u=c,h=l.length):h=(n=(u=Math.max(s,c))>0?s>c?io:ro:null)?n===io?o.length:l.length:0,{type:n,timeout:u,propCount:h,hasTransform:n===io&&go.test(i[oo+"Property"])}}function mo(t,e){for(;t.length explicit "+e+" duration is not a valid number - got "+JSON.stringify(t)+".",n.context):isNaN(t)&&ut(" explicit "+e+" duration is NaN - the duration expression might be incorrect.",n.context)}function Co(t){return"number"==typeof t&&!isNaN(t)}function ko(t){if(e(t))return!1;var n=t.fns;return i(n)?ko(Array.isArray(n)?n[0]:n):(t._length||t.length)>1}function $o(t,e){!0!==e.data.show&&wo(e)}var _o=function(t){var n,s,a={},l=t.modules,c=t.nodeOps;for(n=0;n - did you register the component correctly? For recursive components, make sure to provide the "name" option.',t.context),t.elm=t.ns?c.createElementNS(t.ns,g):c.createElement(g,t),b(t),y(t,f,e),i(h)&&x(t,e),m(n,t.elm,o),h&&h.pre&&p--):r(t.isComment)?(t.elm=c.createComment(t.text),m(n,t.elm,o)):(t.elm=c.createTextNode(t.text),m(n,t.elm,o))}}function v(t,e){i(t.data.pendingInsert)&&(e.push.apply(e,t.data.pendingInsert),t.data.pendingInsert=null),t.elm=t.componentInstance.$el,w(t)?(x(t,e),b(t)):(Di(t),e.push(t))}function m(t,e,n){i(t)&&(i(n)?c.parentNode(n)===t&&c.insertBefore(t,e,n):c.appendChild(t,e))}function y(t,e,n){if(Array.isArray(e)){S(e);for(var i=0;ip?C(t,e(r[m+1])?null:r[m+1].elm,r,d,m,o):d>m&&$(n,h,p)}(h,g,v,o,u):i(v)?(S(v),i(t.text)&&c.setTextContent(h,""),C(h,null,v,0,v.length-1,o)):i(g)?$(g,0,g.length-1):i(t.text)&&c.setTextContent(h,""):t.text!==n.text&&c.setTextContent(h,n.text),i(p)&&i(d=p.hook)&&i(d=d.postpatch)&&d(t,n)}}}function A(t,e,n){if(r(n)&&i(t.parent))t.parent.data.pendingInsert=e;else for(var o=0;o, or missing . Bailing hydration and performing full client-side render.")}C=t,t=new bt(c.tagName(C).toLowerCase(),{},[],void 0,C)}var d=t.elm,p=c.parentNode(d);if(f(n,u,d._leaveCb?null:p,c.nextSibling(d)),i(n.parent))for(var g=n.parent,v=w(n);g;){for(var m=0;m-1,s.selected!==o&&(s.selected=o);else if(D(Oo(s),i))return void(t.selectedIndex!==a&&(t.selectedIndex=a));r||(t.selectedIndex=-1)}else ut('