Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 15 additions & 15 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ on:
workflow_dispatch:

env:
DATABASE_URL: postgres://user:password@localhost:5432/readux
DATABASE_URL: postgres://user:password@localhost:5432/readux_test
DJANGO_ENV: test

jobs:
Expand All @@ -16,9 +16,9 @@ jobs:

services:
elasticsearch:
image: docker.elastic.co/elasticsearch/elasticsearch:7.17.5
image: docker.elastic.co/elasticsearch/elasticsearch:8.13.4
env:
STACK_VERSION: 7.17.5
STACK_VERSION: 8.13.4
xpack.security.enabled: false
cluster.name: readux-elasticsearch
http.port: 9200
Expand All @@ -32,7 +32,7 @@ jobs:
- 9200:9200

postgres:
image: postgres:12
image: postgres:16
env:
POSTGRES_PASSWORD: password
POSTGRES_USER: user
Expand Down Expand Up @@ -62,7 +62,7 @@ jobs:
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "20"
node-version: "24"

- name: Install system requirements
run: |
Expand All @@ -86,16 +86,16 @@ jobs:
# - name: Test Export
# run: pytest apps/export/

# - name: Test IIIF
# run: pytest apps/iiif/
- name: Test IIIF
run: pytest apps/iiif/

# - name: Test Readux
# run: pytest apps/readux/
- name: Test Readux
run: pytest apps/readux/

# - name: Test Users
# run: pytest apps/users/
- name: Test Users
run: pytest apps/users/

# - name: Test Webpack build
# run: |
# npm install
# npx webpack
- name: Test Vite build
run: |
npm install
npm run build
12 changes: 8 additions & 4 deletions apps/cms/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
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
Expand Down Expand Up @@ -237,7 +236,12 @@ def get_context(self, request):


class HomePage(Page):
"""Home page"""
"""
Home page

NOTE: If the HomePage admin panel becomes unwieldy (collections or manifests lists grow large), the proper Wagtail 6 replacement is to register Collection and Manifest as snippets and use a SnippetChooserPanel or implement a custom ChooserWidget. But that's a separate improvement — for now this unblocks manage.py check. Give it another run.
"""

tagline = RichTextField(blank=True)
content_display = models.CharField(
max_length=20,
Expand Down Expand Up @@ -313,9 +317,9 @@ class HomePage(Page):
FieldPanel('tagline', classname="full"),
FieldPanel('background_image', classname="full"),
FieldPanel('content_display', classname="full"),
AutocompletePanel('featured_collections', target_model="kollections.Collection"),
FieldPanel('featured_collections'),
FieldPanel('featured_collections_sort_order', classname="full"),
AutocompletePanel('featured_volumes', target_model="manifests.Manifest"),
FieldPanel('featured_volumes'),
FieldPanel('featured_volumes_sort_order', classname="full"),
MultiFieldPanel(children=[
FieldPanel('featured_story_title'),
Expand Down
6 changes: 3 additions & 3 deletions apps/iiif/kollections/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from import_export import resources
from import_export.admin import ImportExportModelAdmin

from apps.iiif.manifests.admin import SummernoteMixin
from apps.iiif.manifests.admin import RichTextMixin
from .models import Collection
from ..manifests.models import Manifest

Expand Down Expand Up @@ -38,14 +38,14 @@ def manifest_pid(self, instance):

manifest_pid.short_description = 'Manifest Local ID'

class CollectionAdmin(ImportExportModelAdmin, SummernoteMixin, admin.ModelAdmin):
class CollectionAdmin(ImportExportModelAdmin, RichTextMixin, admin.ModelAdmin):
"""Django admin configuration for a collection."""
inlines = [
ManifestInline,
]
resource_class = CollectionResource
list_display = ('id', 'pid', 'metadata', 'summary', 'label')
search_fields = ('label', 'summary', 'pid')
summernote_fields = ('summary', 'summary_en',)
rich_text_fields = ('summary', 'summary_en',)

admin.site.register(Collection, CollectionAdmin)
27 changes: 12 additions & 15 deletions apps/iiif/manifests/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from import_export import resources, fields
from import_export.admin import ImportExportModelAdmin
from import_export.widgets import ManyToManyWidget, ForeignKeyWidget
from django_summernote.admin import SummernoteModelAdmin
from tinymce.widgets import TinyMCE
from .models import Manifest, Note, ImageServer, RelatedLink, Language
from .forms import ManifestAdminForm
from .views import AddToCollectionsView, MetadataImportView
Expand Down Expand Up @@ -64,21 +64,18 @@ class RelatedLinksInline(admin.TabularInline):
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 RichTextMixin:
"""Mixin that applies a TinyMCE widget to fields listed in rich_text_fields."""

rich_text_fields = ()

def formfield_for_dbfield(self, db_field, request, **kwargs):
if db_field.name in self.rich_text_fields:
kwargs["widget"] = TinyMCE()
return super().formfield_for_dbfield(db_field, request, **kwargs)


class ManifestAdmin(ImportExportModelAdmin, SummernoteMixin, admin.ModelAdmin):
class ManifestAdmin(ImportExportModelAdmin, RichTextMixin, admin.ModelAdmin):
"""Django admin configuration for manifests"""

resource_class = ManifestResource
Expand All @@ -95,7 +92,7 @@ class ManifestAdmin(ImportExportModelAdmin, SummernoteMixin, admin.ModelAdmin):
"publisher",
)
search_fields = ("id", "pid", "label", "author", "published_date")
summernote_fields = ("summary",)
rich_text_fields = ("summary",)
form = ManifestAdminForm
actions = ["add_to_collections_action"]
inlines = [RelatedLinksInline]
Expand Down
14 changes: 14 additions & 0 deletions apps/iiif/manifests/documents.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,12 +57,26 @@ class Index:
"""Settings for Elasticsearch"""

name = f"{settings.INDEX_PREFIX}_manifests" if settings.INDEX_PREFIX else "manifests"
settings = {
"analysis": {
"analyzer": {
"en": {
"type": "custom",
"tokenizer": "standard",
"filter": ["lowercase", "stop", "porter_stem"],
}
}
}
}

class Django:
"""Settings for automatically pulling data from Django"""

model = Manifest
ignore_signals = True
# Django 4.2+ requires chunk_size when calling iterator() on a queryset
# that uses prefetch_related(). This value is passed through as chunk_size.
queryset_pagination = 2000

# fields to map dynamically in Elasticsearch
fields = [
Expand Down
28 changes: 28 additions & 0 deletions apps/iiif/manifests/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
from django.contrib.contenttypes.models import ContentType
from django.conf import settings
from datetime import date as _date
from edtf.fields import EDTFField
from apps.iiif.manifests.validators import validate_edtf
from apps.utils.dates import date_to_jd, jd_to_date
from ..choices import Choices
from ..kollections.models import Collection
from ..models import IiifBase
Expand Down Expand Up @@ -424,6 +426,32 @@ def save(self, *args, **kwargs): # pylint: disable = arguments-differ

super().save(*args, **kwargs)

# EDTFField.pre_save() runs inside super().save() and can write extreme JD
# values for imprecise strings like "186X" — upper bounds reaching year 9999,
# lower bounds falling before year 1 AD. Clamp after the fact using a direct
# queryset update() to avoid triggering another recursive save().
today_jd = date_to_jd(_date.today())
min_jd = date_to_jd(_date(1, 1, 1))
# Read the raw float values EDTFField wrote during super().save() without
# going through EDTFField.from_db_value (which can recurse). .values() returns
# plain Python values, bypassing field descriptors entirely.
date_fields = ("date_earliest", "date_latest", "date_sort_ascending", "date_sort_descending")
db_dates = type(self).objects.filter(pk=self.pk).values(*date_fields).first() or {}

update_fields = {}
for field in ("date_earliest", "date_sort_ascending"):
val = db_dates.get(field)
if val is not None and (jd_to_date(val) is None or val < min_jd):
update_fields[field] = None
for field in ("date_latest", "date_sort_descending"):
val = db_dates.get(field)
if val is not None and val > today_jd:
update_fields[field] = today_jd
if update_fields:
type(self).objects.filter(pk=self.pk).update(**update_fields)
for field, val in update_fields.items():
setattr(self, field, val)

for collection in self.collections.all(): # pylint: disable = no-member
collection.modified_at = self.modified_at
collection.save()
Expand Down
3 changes: 2 additions & 1 deletion apps/iiif/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from dirtyfields import DirtyFieldsMixin
from django.db import models
from django.utils import timezone
from datetime import timezone as dt_timezone
import config.settings.local as settings
from modelcluster.models import ClusterableModel
from apps.utils.noid import encode_noid
Expand Down Expand Up @@ -63,7 +64,7 @@ def save(self, *args, **kwargs): # pylint: disable = arguments-differ
@staticmethod
def __js_isoformat(date):
return date.astimezone(
timezone.utc).isoformat(
dt_timezone.utc).isoformat(
timespec="milliseconds").replace("+00:00", "Z")

def clean_pid(self):
Expand Down
6 changes: 5 additions & 1 deletion apps/readux/annotations.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,11 @@ class Annotations(View):
"""

def get_queryset(self):
return Canvas.objects.filter(pid=self.kwargs["canvas"], manifest__pid=self.kwargs["vol"])
# Two URL patterns point to this view:
# user_annotations → annotations/<username>/<volume>/list/<canvas> (kwarg: "volume")
# user_comments → iiif/<version>/<vol>/annotations/<canvas>/... (kwarg: "vol")
manifest_pid = self.kwargs.get("volume") or self.kwargs.get("vol")
return Canvas.objects.filter(pid=self.kwargs["canvas"], manifest__pid=manifest_pid)

def get(self, request, *args, **kwargs):
username = kwargs["username"]
Expand Down
37 changes: 27 additions & 10 deletions apps/readux/context_processors.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,18 +27,35 @@ def current_version(_=None):
"""
repo = Repo(settings.ROOT_DIR.path())

version_info = {"DJANGO_ENV": environ["DJANGO_ENV"], "APP_VERSION": __version__}
# Prefer the Django setting (which has a default) over os.environ directly,
# to avoid KeyError when DJANGO_ENV is not exported to the shell environment.
django_env = getattr(settings, "DJANGO_ENV", environ.get("DJANGO_ENV", "develop"))

if environ["DJANGO_ENV"] == "production":
version_info = {"DJANGO_ENV": django_env, "APP_VERSION": __version__}

if django_env == "production":
return version_info
return {
**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"
),
}

try:
branch = repo.active_branch
return {
**version_info,
"BRANCH": branch.name,
"COMMIT": branch.commit.hexsha,
"COMMIT_DATE": branch.commit.committed_datetime.strftime(
"%m/%d/%Y, %H:%M:%S"
),
}
except TypeError:
# HEAD is detached — e.g. during a git rebase or a CI checkout of a
# specific commit. Fall back to commit-level info without a branch name.
commit = repo.head.commit
return {
**version_info,
"BRANCH": commit.hexsha[:12],
"COMMIT": commit.hexsha,
"COMMIT_DATE": commit.committed_datetime.strftime("%m/%d/%Y, %H:%M:%S"),
}


def footer_template(_):
Expand Down
11 changes: 11 additions & 0 deletions apps/readux/documents.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,17 @@ class Index:
"""Settings for Elasticsearch"""

name = f"{settings.INDEX_PREFIX}_annotations" if settings.INDEX_PREFIX else "annotations"
settings = {
"analysis": {
"analyzer": {
"en": {
"type": "custom",
"tokenizer": "standard",
"filter": ["lowercase", "stop", "porter_stem"],
}
}
}
}

class Django:
"""Settings for automatically pulling data from Django"""
Expand Down
14 changes: 8 additions & 6 deletions apps/readux/forms.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
"""Forms for Readux search"""

from dateutil import parser
from django import forms
from django.forms import widgets
from django.conf import settings
Expand Down Expand Up @@ -212,11 +211,14 @@ def set_facets(self, facets):
)

def set_date(self, min_date, max_date):
"""Use min and max aggregations from Elasticsearch to populate date range fields"""
min_date_object = parser.isoparse(min_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"))
"""Use min and max aggregations from Elasticsearch to populate date range fields.

Expects pre-formatted YYYY-MM-DD strings with zero-padded 4-digit years.
Avoid re-formatting via strftime here — strftime does not zero-pad years < 1000
on all platforms, which breaks the JS date slider.
"""
self.fields["start_date"].set_initial(min_date)
self.fields["end_date"].set_initial(max_date)


class CustomDropdownSelect(widgets.ChoiceWidget):
Expand Down
2 changes: 2 additions & 0 deletions apps/readux/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ def get_queryresults(self):
volumes = ManifestDocument.search()
# filter to only volume matching pid
volumes = volumes.filter("term", pid=volume_pid)
# inner_hits carry the per-page match context; canvas_set _source is not needed
volumes = volumes.source(excludes=["canvas_set"])

# build query for nested fields (i.e. canvas position and text)
nested_kwargs = {
Expand Down
4 changes: 2 additions & 2 deletions apps/readux/tests/test_forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,8 @@ def test_set_facets(self):
@patch("apps.readux.forms.MinMaxDateField.set_initial")
def test_set_date(self, mock_set_initial):
"""Should call set_initial on start_date and end_date form fields with formatted dates"""
min_date = "2022-01-01T00:00:00.000Z"
max_date = "2022-12-31T00:00:00.000Z"
min_date = "2022-01-01"
max_date = "2022-12-31"
form = forms.ManifestSearchForm()
form.set_date(min_date, max_date)
mock_set_initial.assert_has_calls([call("2022-01-01"), call("2022-12-31")], any_order=True)
Expand Down
Loading
Loading