diff --git a/.eslintrc.yaml b/.eslintrc.yaml index 989beb8c0..56959c746 100644 --- a/.eslintrc.yaml +++ b/.eslintrc.yaml @@ -15,6 +15,7 @@ rules: 'unicorn/prefer-query-selector': off # See https://github.com/sindresorhus/eslint-plugin-unicorn/issues/276 'unicorn/prefer-node-append': off 'unicorn/prefer-ternary': off + 'unicorn/no-lonely-if': off env: browser: true es2024: true diff --git a/concordia/admin/__init__.py b/concordia/admin/__init__.py index 0462e8891..e6a53c48c 100644 --- a/concordia/admin/__init__.py +++ b/concordia/admin/__init__.py @@ -9,7 +9,6 @@ from django.contrib.auth.admin import UserAdmin from django.contrib.auth.decorators import permission_required from django.contrib.auth.models import User -from django.db.models import Count from django.http import HttpResponseRedirect from django.shortcuts import get_object_or_404, render from django.template.defaultfilters import truncatechars @@ -80,6 +79,7 @@ SubmittedFilter, TagCampaignListFilter, TagCampaignStatusListFilter, + TopicListFilter, TranscriptionCampaignListFilter, TranscriptionCampaignStatusListFilter, TranscriptionProjectListFilter, @@ -93,8 +93,9 @@ CampaignAdminForm, CardAdminForm, GuideAdminForm, + ItemAdminForm, ProjectAdminForm, - SanitizedDescriptionAdminForm, + TopicAdminForm, ) logger = logging.getLogger(__name__) @@ -110,13 +111,18 @@ class ConcordiaUserAdmin(UserAdmin): ) def get_queryset(self, request): - qs = super().get_queryset(request) - qs = qs.annotate(Count("transcription")) + qs = super().get_queryset(request).select_related("profile") return qs - @admin.display(ordering="transcription__count") + @admin.display( + description="Transcription Count", ordering="profile__transcribe_count" + ) def transcription_count(self, obj): - return obj.transcription__count + return obj.profile.transcribe_count + + @admin.display(description="Review Count", ordering="profile__review_count") + def review_count(self, obj): + return obj.profile.review_count EXPORT_FIELDS = ( "username", @@ -128,17 +134,31 @@ def transcription_count(self, obj): "is_superuser", "date_joined", "last_login", - "transcription__count", + "profile__transcribe_count", + "profile__review_count", ) + EXTRA_VERBOSE_NAMES = { + "profile__transcribe_count": "transcription count", + "profile__review_count": "review count", + } + def export_users_as_csv(self, request, queryset): return export_to_csv_action( - self, request, queryset, field_names=self.EXPORT_FIELDS + self, + request, + queryset, + field_names=self.EXPORT_FIELDS, + extra_verbose_names=self.EXTRA_VERBOSE_NAMES, ) def export_users_as_excel(self, request, queryset): return export_to_excel_action( - self, request, queryset, field_names=self.EXPORT_FIELDS + self, + request, + queryset, + field_names=self.EXPORT_FIELDS, + extra_verbose_names=self.EXTRA_VERBOSE_NAMES, ) actions = (anonymize_action, export_users_as_csv, export_users_as_excel) @@ -358,8 +378,8 @@ class ResourceAdmin(admin.ModelAdmin, CustomListDisplayFieldsMixin): list_filter = ( "resource_type", ResourceCampaignStatusListFilter, + TopicListFilter, ResourceCampaignListFilter, - "title", ) def formfield_for_foreignkey(self, db_field, request, **kwargs): @@ -396,7 +416,7 @@ def get_fields(self, request, obj=None): @admin.register(Topic) class TopicAdmin(admin.ModelAdmin): - form = SanitizedDescriptionAdminForm + form = TopicAdminForm list_display = ( "id", @@ -523,6 +543,7 @@ def item_import_view(self, request, object_id): @admin.register(Item) class ItemAdmin(admin.ModelAdmin): + form = ItemAdminForm list_display = ("title", "item_id", "campaign_title", "project", "published") list_display_links = ("title", "item_id") search_fields = [ diff --git a/concordia/admin/filters.py b/concordia/admin/filters.py index 236c31337..4148a0d5b 100644 --- a/concordia/admin/filters.py +++ b/concordia/admin/filters.py @@ -1,7 +1,7 @@ from django.contrib import admin from django.utils.translation import gettext_lazy as _ -from ..models import Campaign, Project +from ..models import Campaign, Project, Topic class NullableTimestampFilter(admin.SimpleListFilter): @@ -92,6 +92,25 @@ def queryset(self, request, queryset): return queryset +class TopicListFilter(admin.SimpleListFilter): + """ + Base class for admin topic filters + """ + + title = "Topic" + template = "admin/long_name_filter.html" + parameter_name = "topic__id__exact" + + def lookups(self, request, model_admin): + queryset = Topic.objects.all() + return queryset.values_list("id", "title").order_by("title") + + def queryset(self, request, queryset): + if self.value(): + return queryset.filter(**{self.parameter_name: self.value()}) + return queryset + + class ProjectCampaignListFilter(CampaignListFilter): parameter_name = "campaign__id__exact" status_filter_parameter = "campaign__status" diff --git a/concordia/admin/forms.py b/concordia/admin/forms.py index ff3011a36..49df703b5 100644 --- a/concordia/admin/forms.py +++ b/concordia/admin/forms.py @@ -3,7 +3,7 @@ from django.core.cache import caches from tinymce.widgets import TinyMCE -from ..models import Campaign, Card, Guide, Project +from ..models import Campaign, Card, Guide, Item, Project, Topic FRAGMENT_ALLOWED_TAGS = { "a", @@ -90,6 +90,15 @@ def clean_short_description(self): ) +class TopicAdminForm(SanitizedDescriptionAdminForm): + class Meta(SanitizedDescriptionAdminForm.Meta): + model = Topic + widgets = { + "description": TinyMCE(), + "short_description": TinyMCE(), + } + + class CampaignAdminForm(SanitizedDescriptionAdminForm): class Meta(SanitizedDescriptionAdminForm.Meta): model = Campaign @@ -108,6 +117,13 @@ class Meta(SanitizedDescriptionAdminForm.Meta): } +class ItemAdminForm(forms.ModelForm): + class Meta: + model = Item + widgets = {"description": TinyMCE()} + fields = "__all__" + + class CardAdminForm(forms.ModelForm): class Meta: model = Card diff --git a/concordia/documents.py b/concordia/documents.py index 1a0620914..1060e3a6e 100644 --- a/concordia/documents.py +++ b/concordia/documents.py @@ -41,6 +41,7 @@ class Django: fields = [ "created_on", + "report_name", "assets_total", "assets_published", "assets_not_started", @@ -54,12 +55,14 @@ class Django: "projects_unpublished", "anonymous_transcriptions", "transcriptions_saved", + "daily_review_actions", "distinct_tags", "tag_uses", "campaigns_published", "campaigns_unpublished", "users_registered", "users_activated", + "registered_contributors", "daily_active_users", ] diff --git a/concordia/migrations/0097_alter_sitereport_options_userprofile_review_count_and_more.py b/concordia/migrations/0097_alter_sitereport_options_userprofile_review_count_and_more.py new file mode 100644 index 000000000..73264a261 --- /dev/null +++ b/concordia/migrations/0097_alter_sitereport_options_userprofile_review_count_and_more.py @@ -0,0 +1,43 @@ +# Generated by Django 4.2.13 on 2024-07-29 17:30 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("concordia", "0096_transcription_source"), + ] + + operations = [ + migrations.AlterModelOptions( + name="sitereport", + options={"get_latest_by": "created_on", "ordering": ("-created_on",)}, + ), + migrations.AddField( + model_name="userprofile", + name="review_count", + field=models.IntegerField( + default=0, verbose_name="transcription review count" + ), + ), + migrations.AddField( + model_name="userprofile", + name="transcribe_count", + field=models.IntegerField( + default=0, verbose_name="transcription save/submit count" + ), + ), + migrations.AlterField( + model_name="userprofile", + name="user", + field=models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="profile", + to=settings.AUTH_USER_MODEL, + ), + ), + ] diff --git a/concordia/migrations/0098_userprofile_create_and_population.py b/concordia/migrations/0098_userprofile_create_and_population.py new file mode 100644 index 000000000..36efd5bc7 --- /dev/null +++ b/concordia/migrations/0098_userprofile_create_and_population.py @@ -0,0 +1,40 @@ +# Generated by Django 4.2.13 on 2024-07-29 17:40 + +from django.conf import settings +from django.db import migrations + + +def create_and_populate_profiles(apps, schema_editor): + User = apps.get_model("auth", "User") + UserProfile = apps.get_model("concordia", "UserProfile") + db_alias = schema_editor.connection.alias + for user in User.objects.using(db_alias).all().iterator(chunk_size=10000): + profile, created = UserProfile.objects.using(db_alias).get_or_create(user=user) + for activity in user.userprofileactivity_set.all(): + profile.transcribe_count += activity.transcribe_count + profile.review_count += activity.review_count + profile.save() + + +def revert_create_and_populate_profiles(apps, schema_editor): + # We can't actually revert the data to the state it was before, + # and there's no actual need to, but we need this function to be + # able to reverse this migration + pass + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ( + "concordia", + "0097_alter_sitereport_options_userprofile_review_count_and_more", + ), + ] + + operations = [ + migrations.RunPython( + create_and_populate_profiles, revert_create_and_populate_profiles + ), + ] diff --git a/concordia/models.py b/concordia/models.py index 495082209..e9c8b7939 100644 --- a/concordia/models.py +++ b/concordia/models.py @@ -11,7 +11,7 @@ from django.core.cache import cache from django.core.exceptions import ValidationError from django.core.validators import RegexValidator -from django.db import connection, models +from django.db import models from django.db.models import Count, ExpressionWrapper, F, JSONField, Q from django.db.models.functions import Round from django.db.models.signals import post_save @@ -27,8 +27,10 @@ User._meta.get_field("email").__dict__["_unique"] = True +ONE_MINUTE = datetime.timedelta(minutes=1) ONE_DAY = datetime.timedelta(days=1) ONE_DAY_AGO = timezone.now() - ONE_DAY +THRESHOLD = 3 def resource_file_upload_path(instance, filename): @@ -72,9 +74,53 @@ def get_email_reconfirmation_key(self): def validate_reconfirmation_email(self, email): return email == self.get_email_for_reconfirmation() + def review_incidents(self, recent_accepts, recent_rejects, threshold=THRESHOLD): + accepts = recent_accepts.filter(reviewed_by=self).values_list( + "accepted", flat=True + ) + rejects = recent_rejects.filter(reviewed_by=self).values_list( + "rejected", flat=True + ) + timestamps = list(accepts) + list(rejects) + timestamps.sort() + incidents = 0 + for i in range(len(timestamps)): + count = 1 + for j in range(i + 1, len(timestamps)): + if (timestamps[j] - timestamps[i]).seconds <= 60: + count += 1 + if count == threshold: + incidents += 1 + break + else: + break + return incidents + + def transcribe_incidents(self, transcriptions, threshold=THRESHOLD): + recent_transcriptions = transcriptions.filter(user=self).order_by("submitted") + timestamps = recent_transcriptions.values_list("submitted", flat=True) + incidents = 0 + for i in range(len(timestamps)): + count = 1 + for j in range(i + 1, len(timestamps)): + if (timestamps[j] - timestamps[i]).seconds <= 60: + count += 1 + if count == threshold: + incidents += 1 + break + else: + break + return incidents + class UserProfile(MetricsModelMixin("userprofile"), models.Model): - user = models.ForeignKey(User, on_delete=models.CASCADE) + user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile") + transcribe_count = models.IntegerField( + default=0, verbose_name="transcription save/submit count" + ) + review_count = models.IntegerField( + default=0, verbose_name="transcription review count" + ) class OverlayPosition(object): @@ -792,63 +838,49 @@ def recent_review_actions(self, days=1): START = timezone.now() - datetime.timedelta(days=days) return self.review_actions(START) - def reviewing_too_quickly(self, start=ONE_DAY_AGO): - with connection.cursor() as cursor: - cursor.execute( - f"""SELECT u.id, u.username, COUNT(*) - FROM concordia_transcription t1 - JOIN concordia_transcription t2 - ON t1.id < t2.id - JOIN concordia_transcription t3 - ON t2.id < t3.id - AND t1.reviewed_by_id = t2.reviewed_by_id - AND t2.reviewed_by_id = t3.reviewed_by_id - AND t1.accepted >= '{start}' - AND t2.accepted >= '{start}' - AND t3.accepted >= '{start}' - AND ABS( - EXTRACT(EPOCH FROM (t1.updated_on - t2.updated_on)) - ) < 60 - AND ABS( - EXTRACT(EPOCH FROM (t1.updated_on - t3.updated_on)) - ) < 60 - AND ABS(EXTRACT( - EPOCH FROM (t2.updated_on - t3.updated_on)) - ) < 60 - JOIN auth_user u on t1.reviewed_by_id = u.id - WHERE u.is_superuser = FALSE and u.is_staff = False - GROUP BY u.id, u.username""" # nosec B608 - ) - return cursor.fetchall() - - def transcribing_too_quickly(self, start=ONE_DAY_AGO): - with connection.cursor() as cursor: - cursor.execute( - f"""SELECT u.id, u.username, COUNT(*) - FROM concordia_transcription t1 - JOIN concordia_transcription t2 - ON t1.id < t2.id - JOIN concordia_transcription t3 - ON t2.id < t3.id - AND t1.user_id = t2.user_id - AND t2.user_id = t3.user_id - AND t1.submitted >= '{start}' - AND t2.submitted >= '{start}' - AND t3.submitted >= '{start}' - AND ABS( - EXTRACT(EPOCH FROM (t1.created_on - t2.created_on)) - ) < 60 - AND ABS( - EXTRACT(EPOCH FROM (t1.created_on - t3.created_on)) - ) < 60 - AND ABS( - EXTRACT(EPOCH FROM (t2.created_on - t3.created_on)) - ) < 60 - JOIN auth_user u on t1.user_id = u.id - WHERE u.is_superuser = FALSE and u.is_staff = False - GROUP BY u.id, u.username""" # nosec B608 - ) - return cursor.fetchall() + def review_incidents(self): + user_incident_count = [] + recent_accepts = self.filter( + accepted__gte=ONE_DAY_AGO, + reviewed_by__is_superuser=False, + reviewed_by__is_staff=False, + ) + recent_rejects = self.filter( + rejected__gte=ONE_DAY_AGO, + reviewed_by__is_superuser=False, + reviewed_by__is_staff=False, + ) + recent_actions = recent_accepts.union(recent_rejects) + user_ids = set( + recent_actions.order_by("reviewed_by").values_list("reviewed_by", flat=True) + ) + + for user_id in user_ids: + user = ConcordiaUser.objects.get(id=user_id) + incident_count = user.review_incidents(recent_accepts, recent_rejects) + if incident_count > 0: + user_incident_count.append((user.id, user.username, incident_count)) + + return user_incident_count + + def transcribe_incidents(self): + user_incident_count = [] + transcriptions = self.get_queryset().filter( + submitted__gte=ONE_DAY_AGO, user__is_superuser=False, user__is_staff=False + ) + user_ids = ( + transcriptions.order_by("user") + .distinct("user") + .values_list("user", flat=True) + ) + + for user_id in user_ids: + user = ConcordiaUser.objects.get(id=user_id) + incident_count = user.transcribe_incidents(transcriptions) + if incident_count > 0: + user_incident_count.append((user.id, user.username, incident_count)) + + return user_incident_count class Transcription(MetricsModelMixin("transcription"), models.Model): @@ -962,10 +994,13 @@ def on_transcription_save(sender, instance, **kwargs): user=user, campaign=instance.asset.item.project.campaign, ) + profile, created = UserProfile.objects.get_or_create(user=user) if created: setattr(user_profile_activity, attr_name, 1) + setattr(profile, attr_name, 1) else: setattr(user_profile_activity, attr_name, F(attr_name) + 1) + setattr(profile, attr_name, F(attr_name) + 1) q = Q(transcription__user=user) | Q(transcription__reviewed_by=user) user_profile_activity.asset_count = ( Asset.objects.filter(q) @@ -974,6 +1009,7 @@ def on_transcription_save(sender, instance, **kwargs): .count() ) user_profile_activity.save() + profile.save() post_save.connect(on_transcription_save, sender=Transcription) diff --git a/concordia/signals/handlers.py b/concordia/signals/handlers.py index 47451cfae..72f12620b 100644 --- a/concordia/signals/handlers.py +++ b/concordia/signals/handlers.py @@ -14,7 +14,7 @@ from django_registration.signals import user_activated, user_registered from flags.state import flag_enabled -from ..models import Asset, Transcription, TranscriptionStatus +from ..models import Asset, Transcription, TranscriptionStatus, UserProfile from ..tasks import calculate_difficulty_values from .signals import reservation_obtained, reservation_released @@ -191,3 +191,9 @@ def send_asset_reservation_message( @receiver(post_delete, sender=Asset) def remove_file_from_s3(sender, instance, using, **kwargs): instance.storage_image.delete(save=False) + + +@receiver(post_save, sender=settings.AUTH_USER_MODEL) +def create_user_profile(sender, instance, *args, **kwargs): + if not hasattr(instance, "profile"): + UserProfile.objects.create(user=instance) diff --git a/concordia/static/js/src/asset-reservation.js b/concordia/static/js/src/asset-reservation.js index bb1ef4cfe..9e2299b59 100644 --- a/concordia/static/js/src/asset-reservation.js +++ b/concordia/static/js/src/asset-reservation.js @@ -1,5 +1,7 @@ /* global jQuery displayMessage displayHtmlMessage buildErrorMessage Sentry */ -/* exported attemptToReserveAsset */ +/* exported attemptToReserveAsset reserveAssetForEditing */ + +const assetData = document.currentScript.dataset; function attemptToReserveAsset(reservationURL, findANewPageURL, actionType) { var $transcriptionEditor = jQuery('#transcription-editor'); @@ -95,3 +97,15 @@ function attemptToReserveAsset(reservationURL, findANewPageURL, actionType) { } }); } + +function reserveAssetForEditing() { + if (assetData.reserveAssetUrl) { + attemptToReserveAsset(assetData.reserveAssetUrl, '', 'transcribe'); + } +} + +jQuery(function () { + if (assetData.reserveForEditing) { + reserveAssetForEditing(); + } +}); diff --git a/concordia/static/js/src/banner.js b/concordia/static/js/src/banner.js new file mode 100644 index 000000000..a46e3ea2a --- /dev/null +++ b/concordia/static/js/src/banner.js @@ -0,0 +1,18 @@ +/* global $ */ + +if (typeof Storage !== 'undefined') { + if (!(window.screen.width < 1024 || window.screen.height < 768)) { + for (var key in localStorage) { + if (key.startsWith('banner-')) { + if ($('#' + key).hasClass('alert')) { + $('#' + key).attr('hidden', true); + } + } + } + } +} + +$('#no-interface-banner').click(function (event) { + localStorage.setItem(event.target.parentElement.id, true); + $('#' + event.target.parentElement.id).attr('hidden', true); +}); diff --git a/concordia/static/js/src/contribute.js b/concordia/static/js/src/contribute.js index 30f9c8942..f477ef02e 100644 --- a/concordia/static/js/src/contribute.js +++ b/concordia/static/js/src/contribute.js @@ -766,4 +766,27 @@ function setupPage() { } } +let transcriptionForm = document.getElementById('transcription-editor'); +let ocrForm = document.getElementById('ocr-transcription-form'); + +let formChanged = false; +transcriptionForm.addEventListener('change', function () { + formChanged = true; +}); +transcriptionForm.addEventListener('submit', function () { + formChanged = false; +}); +if (ocrForm) { + ocrForm.addEventListener('submit', function () { + formChanged = false; + }); +} +window.addEventListener('beforeunload', function (event) { + if (formChanged) { + // Some browsers ignore this value and always display a built-in message instead + return (event.returnValue = + "The transcription you've started has not been saved."); + } +}); + setupPage(); diff --git a/concordia/static/js/src/guide.js b/concordia/static/js/src/guide.js new file mode 100644 index 000000000..2bfe28570 --- /dev/null +++ b/concordia/static/js/src/guide.js @@ -0,0 +1,86 @@ +/* global $ trackUIInteraction */ +/* exported openOffcanvas closeOffcanvas showPane hidePane */ + +function openOffcanvas() { + var guide = document.getElementById('guide-sidebar'); + guide.classList.remove('offscreen'); + guide.style.borderWidth = '0 0 thick thick'; + guide.style.borderStyle = 'solid'; + guide.style.borderColor = '#0076ad'; + document.getElementById('open-guide').style.display = 'none'; + document.addEventListener('keydown', function (event) { + if (event.key == 'Escape') { + closeOffcanvas(); + } + }); +} + +function closeOffcanvas() { + var guide = document.getElementById('guide-sidebar'); + guide.classList.add('offscreen'); + + guide.style.border = 'none'; + document.getElementById('open-guide').style.display = 'block'; +} + +function showPane(elementId) { + document.getElementById(elementId).classList.add('show', 'active'); + document.getElementById('guide-nav').classList.remove('show', 'active'); +} + +function hidePane(elementId) { + document.getElementById(elementId).classList.remove('show', 'active'); + document.getElementById('guide-nav').classList.add('show', 'active'); +} + +function trackHowToInteraction(element, label) { + trackUIInteraction(element, 'How To Guide', 'click', label); +} + +$('#open-guide').on('click', function () { + trackHowToInteraction($(this), 'Open'); +}); +$('#close-guide').on('click', function () { + trackHowToInteraction($(this), 'Close'); +}); +$('#previous-guide').on('click', function () { + trackHowToInteraction($(this), 'Back'); +}); +$('#next-guide').on('click', function () { + trackHowToInteraction($(this), 'Next'); +}); +$('#guide-bars').on('click', function () { + trackHowToInteraction($(this), 'Hamburger Menu'); +}); +$('#guide-sidebar .nav-link').on('click', function () { + let label = $(this).text().trim(); + trackHowToInteraction($(this), label); +}); + +$('#guide-carousel') + .carousel({ + interval: false, + wrap: false, + }) + .on('slide.bs.carousel', function (event) { + if (event.to == 0) { + $('#guide-bars').addClass('d-none'); + } else { + $('#guide-bars').removeClass('d-none'); + } + }); + +$('#previous-card').hide(); + +$('#card-carousel').on('slid.bs.carousel', function () { + if ($('#card-carousel .carousel-item:first').hasClass('active')) { + $('#previous-card').hide(); + $('#next-card').show(); + } else if ($('#card-carousel .carousel-item:last').hasClass('active')) { + $('#previous-card').show(); + $('#next-card').hide(); + } else { + $('#previous-card').show(); + $('#next-card').show(); + } +}); diff --git a/concordia/static/js/src/ocr.js b/concordia/static/js/src/ocr.js new file mode 100644 index 000000000..8c5cf0d07 --- /dev/null +++ b/concordia/static/js/src/ocr.js @@ -0,0 +1,7 @@ +/* global $ */ +/* exported selectLanguage */ + +function selectLanguage() { + $('#ocr-transcription-modal').modal('hide'); + $('#language-selection-modal').modal('show'); +} diff --git a/concordia/static/js/src/quick-tips.js b/concordia/static/js/src/quick-tips.js new file mode 100644 index 000000000..139eb3acf --- /dev/null +++ b/concordia/static/js/src/quick-tips.js @@ -0,0 +1,38 @@ +/* global $ trackUIInteraction setTutorialHeight */ + +function trackQuickTipsInteraction(element, label) { + trackUIInteraction(element, 'Quick Tips', 'click', label); +} + +var mainContentHeight = $('#contribute-main-content').height(); +if (mainContentHeight < 710) { + $('.sidebar').height(mainContentHeight - 130); +} + +$('#tutorial-popup').on('shown.bs.modal', function () { + setTutorialHeight(); +}); + +$('#quick-tips').on('click', function () { + trackQuickTipsInteraction($(this), 'Open'); +}); +$('#previous-card').on('click', function () { + trackQuickTipsInteraction($(this), 'Back'); +}); +$('#next-card').on('click', function () { + trackQuickTipsInteraction($(this), 'Next'); +}); +$('.carousel-indicators li').on('click', function () { + let index = [...this.parentElement.children].indexOf(this); + trackQuickTipsInteraction($(this), `Carousel ${index}`); +}); +$('#tutorial-popup').on('hidden.bs.modal', function () { + // We're tracking whenever the popup closes, so we don't separately track the close button being clicked + trackUIInteraction($(this), 'Quick Tips', 'click', 'Close'); +}); +$('#tutorial-popup').on('shown-on-load', function () { + // We set a timeout to make sure the analytics code is loaded before trying to track + setTimeout(function () { + trackUIInteraction($(this), 'Quick Tips', 'load', 'Open'); + }, 1000); +}); diff --git a/concordia/static/js/src/viewer-split.js b/concordia/static/js/src/viewer-split.js new file mode 100644 index 000000000..2769b7b49 --- /dev/null +++ b/concordia/static/js/src/viewer-split.js @@ -0,0 +1,146 @@ +/* global Split seadragonViewer */ + +let pageSplit; +let contributeContainer = document.getElementById('contribute-container'); +let ocrSection = document.getElementById('ocr-section'); +let editorColumn = document.getElementById('editor-column'); +let viewerColumn = document.getElementById('viewer-column'); +let layoutColumns = ['#viewer-column', '#editor-column']; +let verticalKey = 'transcription-split-sizes-vertical'; +let horizontalKey = 'transcription-split-sizes-horizontal'; + +let sizesVertical = localStorage.getItem(verticalKey); + +if (sizesVertical) { + sizesVertical = JSON.parse(sizesVertical); +} else { + sizesVertical = [50, 50]; +} + +let sizesHorizontal = localStorage.getItem(horizontalKey); + +if (sizesHorizontal) { + sizesHorizontal = JSON.parse(sizesHorizontal); +} else { + sizesHorizontal = [50, 50]; +} + +let splitDirection = localStorage.getItem('transcription-split-direction'); + +if (splitDirection) { + splitDirection = JSON.parse(splitDirection); +} else { + splitDirection = 'h'; +} + +function saveSizes(sizes) { + let sizeKey; + if (splitDirection == 'h') { + sizeKey = horizontalKey; + sizesHorizontal = sizes; + } else { + sizeKey = verticalKey; + sizesVertical = sizes; + } + localStorage.setItem(sizeKey, JSON.stringify(sizes)); +} + +function saveDirection(direction) { + localStorage.setItem( + 'transcription-split-direction', + JSON.stringify(direction), + ); +} + +function verticalSplit() { + splitDirection = 'v'; + saveDirection(splitDirection); + contributeContainer.classList.remove('flex-row'); + contributeContainer.classList.add('flex-column'); + viewerColumn.classList.remove('h-100'); + if (ocrSection != undefined) { + editorColumn.prepend(ocrSection); + } + + return Split(layoutColumns, { + sizes: sizesVertical, + minSize: 100, + gutterSize: 8, + direction: 'vertical', + elementStyle: function (dimension, size, gutterSize) { + return { + 'flex-basis': 'calc(' + size + '% - ' + gutterSize + 'px)', + }; + }, + gutterStyle: function (dimension, gutterSize) { + return { + 'flex-basis': gutterSize + 'px', + }; + }, + onDragEnd: saveSizes, + }); +} +function horizontalSplit() { + splitDirection = 'h'; + saveDirection(splitDirection); + contributeContainer.classList.remove('flex-column'); + contributeContainer.classList.add('flex-row'); + viewerColumn.classList.add('h-100'); + if (ocrSection != undefined) { + viewerColumn.append(ocrSection); + } + return Split(layoutColumns, { + sizes: sizesHorizontal, + minSize: 100, + gutterSize: 8, + elementStyle: function (dimension, size, gutterSize) { + return { + 'flex-basis': 'calc(' + size + '% - ' + gutterSize + 'px)', + }; + }, + gutterStyle: function (dimension, gutterSize) { + return { + 'flex-basis': gutterSize + 'px', + }; + }, + onDragEnd: saveSizes, + }); +} + +document + .getElementById('viewer-layout-horizontal') + .addEventListener('click', function () { + if (splitDirection != 'h') { + if (pageSplit != undefined) { + pageSplit.destroy(); + } + pageSplit = horizontalSplit(); + setTimeout(function () { + // Some quirk in the viewer makes this + // sometimes not work depending on + // the rotation, unless it's delayed. + // Less than 10ms didn't reliable work. + seadragonViewer.viewport.zoomTo(1); + }, 10); + } + }); + +document + .getElementById('viewer-layout-vertical') + .addEventListener('click', function () { + if (splitDirection != 'v') { + if (pageSplit != undefined) { + pageSplit.destroy(); + } + pageSplit = verticalSplit(); + setTimeout(function () { + seadragonViewer.viewport.zoomTo(1); + }, 10); + } + }); + +if (splitDirection == 'v') { + pageSplit = verticalSplit(); +} else { + pageSplit = horizontalSplit(); +} diff --git a/concordia/static/js/src/viewer.js b/concordia/static/js/src/viewer.js new file mode 100644 index 000000000..9ad073409 --- /dev/null +++ b/concordia/static/js/src/viewer.js @@ -0,0 +1,200 @@ +/* global OpenSeadragon screenfull debounce */ +/* exported seadragonView stepUp stepDown resetImageFilterForms */ + +const viewerData = document.currentScript.dataset; + +var seadragonViewer = OpenSeadragon({ + id: 'asset-image', + prefixUrl: viewerData.prefixUrl, + tileSources: { + type: 'image', + url: viewerData.tileSourceUrl, + }, + gestureSettingsTouch: { + pinchRotate: true, + }, + showNavigator: true, + showRotationControl: true, + showFlipControl: true, + toolbar: 'viewer-controls', + zoomInButton: 'viewer-zoom-in', + zoomOutButton: 'viewer-zoom-out', + homeButton: 'viewer-home', + rotateLeftButton: 'viewer-rotate-left', + rotateRightButton: 'viewer-rotate-right', + flipButton: 'viewer-flip', + crossOriginPolicy: 'Anonymous', +}); + +// We need to define our own fullscreen function rather than using OpenSeadragon's +// because the built-in fullscreen function overwrites the DOM with the viewer, +// breaking our extra controls, such as the image filters. +if (screenfull.isEnabled) { + let fullscreenButton = document.querySelector('#viewer-fullscreen'); + fullscreenButton.addEventListener('click', function (event) { + event.preventDefault(); + let targetElement = document.querySelector( + fullscreenButton.dataset.target, + ); + if (screenfull.isFullscreen) { + screenfull.exit(); + } else { + screenfull.request(targetElement); + } + }); +} + +// The buttons configured as controls for the viewer don't properly get focus +// when clicked. This mostly isn't a problem, but causes odd-looking behavior +// when one of the extra buttons in the control bar is clicked (and therefore +// focused) first--clicking the control button leaves the focus on the extra +// button. +// TODO: Attempting to add focus to the clicked button here doesn't consistently +// work for unknown reasons, so it just removes focus from the extra buttons +// for now +let viewerControlButtons = document.querySelectorAll('.viewer-control-button'); +for (const node of viewerControlButtons) { + node.addEventListener('click', function () { + let focusedButton = document.querySelector( + '.extra-control-button:focus', + ); + if (focusedButton) { + focusedButton.blur(); + } + }); +} + +/* + * Image filter handling + */ + +let availableFilters = [ + { + formId: 'gamma-form', + inputId: 'gamma', + getFilter: function () { + let value = document.getElementById(this.inputId).value; + if ( + !Number.isNaN(value) && + value != 1 && + value >= 0 && + value <= 5 + ) { + return OpenSeadragon.Filters.GAMMA(value); + } + }, + }, + { + formId: 'invert-form', + inputId: 'invert', + getFilter: function () { + let value = document.getElementById(this.inputId).checked; + if (value) { + return OpenSeadragon.Filters.INVERT(); + } + }, + }, + { + formId: 'threshold-form', + inputId: 'threshold', + getFilter: function () { + let value = document.getElementById(this.inputId).value; + if (!Number.isNaN(value) && value > 0 && value <= 255) { + return OpenSeadragon.Filters.THRESHOLDING(value); + } + }, + }, +]; + +function updateFilters() { + let filters = []; + for (const filterData of availableFilters) { + let filter = filterData.getFilter(); + if (filter) { + filters.push(filter); + } + } + + seadragonViewer.setFilterOptions({ + filters: { + processors: filters, + }, + }); +} + +for (const filterData of availableFilters) { + let form = document.getElementById(filterData.formId); + if (form) { + form.addEventListener('change', updateFilters); + form.addEventListener('reset', function () { + // We use setTimeout to push the updateFilters + // call to the next event cycle in order to + // call it after the form is reset, instead + // of before, which is when this listener + // triggers + setTimeout(updateFilters); + }); + } + + let input = document.getElementById(filterData.inputId); + if (input) { + // We use debounce here so that updateFilters is only called once, + // after the user stops typing or scrolling with their mousewheel + input.addEventListener( + 'keyup', + debounce(() => updateFilters()), + ); + input.addEventListener( + 'wheel', + debounce(() => updateFilters()), + ); + } +} + +/* + * Image filter form handling + */ +function stepUp(id) { + let input = document.getElementById(id); + input.stepUp(); + input.dispatchEvent(new Event('input', {bubbles: true})); + input.dispatchEvent(new Event('change', {bubbles: true})); + return false; +} + +function stepDown(id) { + let input = document.getElementById(id); + input.stepDown(); + input.dispatchEvent(new Event('input', {bubbles: true})); + input.dispatchEvent(new Event('change', {bubbles: true})); + return false; +} + +function resetImageFilterForms() { + for (const filterData of availableFilters) { + let form = document.getElementById(filterData.formId); + form.reset(); + } +} + +let gammaNumber = document.getElementById('gamma'); +let gammaRange = document.getElementById('gamma-range'); + +gammaNumber.addEventListener('input', function () { + gammaRange.value = gammaNumber.value; +}); + +gammaRange.addEventListener('input', function () { + gammaNumber.value = gammaRange.value; +}); + +let thresholdNumber = document.getElementById('threshold'); +let thresholdRange = document.getElementById('threshold-range'); + +thresholdNumber.addEventListener('input', function () { + thresholdRange.value = thresholdNumber.value; +}); + +thresholdRange.addEventListener('input', function () { + thresholdNumber.value = thresholdRange.value; +}); diff --git a/concordia/tasks.py b/concordia/tasks.py index bb3054dad..8445b0670 100644 --- a/concordia/tasks.py +++ b/concordia/tasks.py @@ -1043,14 +1043,6 @@ def fix_storage_images(campaign_slug=None, asset_start_id=None): logger.debug("%s / %s (%s%%)", count, full_count, str(count / full_count * 100)) -def transcribing_too_quickly(): - return Transcription.objects.transcribing_too_quickly() - - -def reviewing_too_quickly(): - return Transcription.objects.reviewing_too_quickly() - - @celery_app.task(ignore_result=True) def clear_sessions(): # This clears expired Django sessions in the database @@ -1067,8 +1059,8 @@ def unusual_activity(): "title": "Unusual User Activity Report for " + timezone.now().strftime("%b %d %Y, %I:%M %p"), "domain": "https://" + site.domain, - "transcriptions": transcribing_too_quickly(), - "reviews": reviewing_too_quickly(), + "transcriptions": Transcription.objects.transcribe_incidents(), + "reviews": Transcription.objects.review_incidents(), } text_body_template = loader.get_template("emails/unusual_activity.txt") diff --git a/concordia/templates/admin/concordia/item/change_form.html b/concordia/templates/admin/concordia/item/change_form.html index 4e0dc3352..835d87b2e 100644 --- a/concordia/templates/admin/concordia/item/change_form.html +++ b/concordia/templates/admin/concordia/item/change_form.html @@ -29,18 +29,3 @@ {% endif %} {{ block.super }} {% endblock %} - -{% block extrahead %} - {{ block.super }} - - {% include 'fragments/codemirror.html' %} -{% endblock extrahead %} - - -{% block content %} - {{ block.super }} - - -{% endblock content %} diff --git a/concordia/templates/admin/concordia/topic/change_form.html b/concordia/templates/admin/concordia/topic/change_form.html deleted file mode 100644 index e461b658c..000000000 --- a/concordia/templates/admin/concordia/topic/change_form.html +++ /dev/null @@ -1,19 +0,0 @@ -{% extends "admin/change_form.html" %} - -{% load i18n admin_urls static %} - -{% block extrahead %} - {{ block.super }} - - {% include 'fragments/codemirror.html' %} -{% endblock extrahead %} - - -{% block content %} - {{ block.super }} - - -{% endblock content %} diff --git a/concordia/templates/emails/unusual_activity.txt b/concordia/templates/emails/unusual_activity.txt index eb2bf8549..daba6e152 100644 --- a/concordia/templates/emails/unusual_activity.txt +++ b/concordia/templates/emails/unusual_activity.txt @@ -6,7 +6,7 @@ Incidents of two or more transcriptions submitted within a single minute: No transcriptions fell within the window. {% endfor %} Incidents of two or more transcriptions reviewed within a single minute: -{% for row in reviews_by %} +{% for row in reviews %} {{ row.1 }} | {{ row.2 }} {% empty %} No reviews fell within the window. diff --git a/concordia/templates/transcriptions/asset_detail.html b/concordia/templates/transcriptions/asset_detail.html index 5ed967fbf..f67474313 100644 --- a/concordia/templates/transcriptions/asset_detail.html +++ b/concordia/templates/transcriptions/asset_detail.html @@ -18,6 +18,25 @@ + + + + + + + + + + + {% endblock head_content %} {% block breadcrumbs %} @@ -35,330 +54,13 @@
-
-
-
- - -
- -
- -
- -
- - -
- -
- - -
- -
- -
- -
- -
- -
- -
- -
- -
-
-
- - -
-
- -
- -
-
-
-
-
-
- - -
-
-
- -
-
- -
-
-
- - - -
-
-
-
- -
- - -
- -
-
-
-
-
-
- - -
-
-
- -
-
- -
-
-
- - - -
-
-
-
+ {% include "transcriptions/asset_detail/viewer.html" %} + {% include "transcriptions/asset_detail/ocr_help_modal.html" %} + {% include "transcriptions/asset_detail/viewer_filters.html" %}
{% if not disable_ocr %}
@@ -559,739 +97,26 @@

{% if cards %} - + {% include "transcriptions/asset_detail/quick_tips_modal.html" %} {% endif %} {% endblock main_content %} - -{% block body_scripts %} - - - - - - - - - - - - - - - - - - - -{% endblock body_scripts %} diff --git a/concordia/templates/transcriptions/asset_detail/asset_reservation_failure_modal.html b/concordia/templates/transcriptions/asset_detail/asset_reservation_failure_modal.html new file mode 100644 index 000000000..a84b36411 --- /dev/null +++ b/concordia/templates/transcriptions/asset_detail/asset_reservation_failure_modal.html @@ -0,0 +1,19 @@ + diff --git a/concordia/templates/transcriptions/asset_detail/captcha_modal.html b/concordia/templates/transcriptions/asset_detail/captcha_modal.html new file mode 100644 index 000000000..2ca5c457d --- /dev/null +++ b/concordia/templates/transcriptions/asset_detail/captcha_modal.html @@ -0,0 +1,38 @@ + diff --git a/concordia/templates/transcriptions/asset_detail/editor.html b/concordia/templates/transcriptions/asset_detail/editor.html new file mode 100644 index 000000000..92a09da5e --- /dev/null +++ b/concordia/templates/transcriptions/asset_detail/editor.html @@ -0,0 +1,118 @@ +
+
+

+ + Needs review +

+

+ + Completed +

+

+ + Not started +

+

+ + In progress +

+ +
+ +
+ {% csrf_token %} + + +
+ +

+ Registered Contributors: {{ registered_contributors }} +

+ + Transcribe this page. + + + Someone started this transcription. Can you finish it? + + + Check this transcription thoroughly. Accept if correct! + + + This transcription is finished! You can read and add tags. + +
+ +
+ + {% spaceless %} +
+ +
+
+ + {% if guides %} + + {% include "fragments/_how-to-guide.html" %} + {% endif %} +
+ + + +
+ {% if transcription_status == 'not_started' or transcription_status == 'in_progress' %} +
+ + + + + + +
+ + + + + + + {% elif transcription_status == 'submitted' %} + {% if not user.is_authenticated %} +

+ Register + or + login + to help review +

+ {% else %} + + {% if transcription.user.pk == user.pk %} +

You submitted this transcription. You can re-open it for editing if you wish to make changes before another volunteer reviews it.

+ {% else %} + + {% endif %} + {% endif %} + {% endif %} +
+ {% endspaceless %} +
+
diff --git a/concordia/templates/transcriptions/asset_detail/language_selection_modal.html b/concordia/templates/transcriptions/asset_detail/language_selection_modal.html new file mode 100644 index 000000000..63ba19e31 --- /dev/null +++ b/concordia/templates/transcriptions/asset_detail/language_selection_modal.html @@ -0,0 +1,33 @@ + diff --git a/concordia/templates/transcriptions/asset_detail/navigation.html b/concordia/templates/transcriptions/asset_detail/navigation.html new file mode 100644 index 000000000..6f9c2259c --- /dev/null +++ b/concordia/templates/transcriptions/asset_detail/navigation.html @@ -0,0 +1,49 @@ + diff --git a/concordia/templates/transcriptions/asset_detail/ocr_help_modal.html b/concordia/templates/transcriptions/asset_detail/ocr_help_modal.html new file mode 100644 index 000000000..b491590cf --- /dev/null +++ b/concordia/templates/transcriptions/asset_detail/ocr_help_modal.html @@ -0,0 +1,27 @@ + diff --git a/concordia/templates/transcriptions/asset_detail/ocr_transcription_modal.html b/concordia/templates/transcriptions/asset_detail/ocr_transcription_modal.html new file mode 100644 index 000000000..39ef4601b --- /dev/null +++ b/concordia/templates/transcriptions/asset_detail/ocr_transcription_modal.html @@ -0,0 +1,24 @@ + diff --git a/concordia/templates/transcriptions/asset_detail/quick_tips_modal.html b/concordia/templates/transcriptions/asset_detail/quick_tips_modal.html new file mode 100644 index 000000000..9d6337a28 --- /dev/null +++ b/concordia/templates/transcriptions/asset_detail/quick_tips_modal.html @@ -0,0 +1,42 @@ + diff --git a/concordia/templates/transcriptions/asset_detail/review_accepted_modal.html b/concordia/templates/transcriptions/asset_detail/review_accepted_modal.html new file mode 100644 index 000000000..5bb447160 --- /dev/null +++ b/concordia/templates/transcriptions/asset_detail/review_accepted_modal.html @@ -0,0 +1,35 @@ + diff --git a/concordia/templates/transcriptions/asset_detail/successful_submission_modal.html b/concordia/templates/transcriptions/asset_detail/successful_submission_modal.html new file mode 100644 index 000000000..45cdb964a --- /dev/null +++ b/concordia/templates/transcriptions/asset_detail/successful_submission_modal.html @@ -0,0 +1,35 @@ + diff --git a/concordia/templates/transcriptions/asset_detail/tags.html b/concordia/templates/transcriptions/asset_detail/tags.html new file mode 100644 index 000000000..0df583244 --- /dev/null +++ b/concordia/templates/transcriptions/asset_detail/tags.html @@ -0,0 +1,47 @@ +
+

+
+ {% csrf_token %} +
+ {% if user.is_authenticated %} +
+
+ +
+ +
+
+ Tags must be between 1-50 characters and may contain only letters, numbers, dashes, underscores, apostrophes, and spaces +
+
+
+ {% else %} +

+ Want to tag this page? + + Register + or + login + to add tags. +

+ {% endif %} +
+ +
    + {% for tag in tags %} +
  • + + +
  • + {% endfor %} +
+
+
diff --git a/concordia/templates/transcriptions/asset_detail/viewer.html b/concordia/templates/transcriptions/asset_detail/viewer.html new file mode 100644 index 000000000..edd395d35 --- /dev/null +++ b/concordia/templates/transcriptions/asset_detail/viewer.html @@ -0,0 +1,121 @@ +
+
+
+ + +
+ +
+ +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+
+
+ + diff --git a/concordia/templates/transcriptions/asset_detail/viewer_filters.html b/concordia/templates/transcriptions/asset_detail/viewer_filters.html new file mode 100644 index 000000000..3170aae21 --- /dev/null +++ b/concordia/templates/transcriptions/asset_detail/viewer_filters.html @@ -0,0 +1,125 @@ +
+
+ +
+ +
+
+
+
+
+
+ + +
+
+
+ +
+
+ +
+
+
+ + + +
+
+
+
+ +
+ + +
+ +
+
+
+
+
+
+ + +
+
+
+ +
+
+ +
+
+
+ + + +
+
+
+
diff --git a/concordia/templates/transcriptions/topic_detail.html b/concordia/templates/transcriptions/topic_detail.html index cd230eb5e..6f159a8c3 100644 --- a/concordia/templates/transcriptions/topic_detail.html +++ b/concordia/templates/transcriptions/topic_detail.html @@ -14,12 +14,12 @@ {% block breadcrumbs %} - + {% endblock breadcrumbs %} {% block main_content %}
-
+

{{ topic.title }}

{{ topic.description|safe }}
diff --git a/concordia/tests/test_admin.py b/concordia/tests/test_admin.py index b524503ad..8fe3f680f 100644 --- a/concordia/tests/test_admin.py +++ b/concordia/tests/test_admin.py @@ -79,9 +79,9 @@ def test_csv_export(self): self.assertEqual(len(content), 4) # Includes empty line at the end of the file test_data = [ b"username,email address,first name,last name,active,staff status," - + b"superuser status,last login,transcription__count", - b"testsuperuser,testsuperuser@example.com,,,True,True,True,,0", - b"testuser,testuser@example.com,,,True,False,False,,0", + + b"superuser status,last login,transcription count,review count", + b"testsuperuser,testsuperuser@example.com,,,True,True,True,,0,0", + b"testuser,testuser@example.com,,,True,False,False,,0,0", b"", ] self.assertEqual(content, test_data) diff --git a/concordia/tests/test_models.py b/concordia/tests/test_models.py index bfc4dec81..bee7580fa 100644 --- a/concordia/tests/test_models.py +++ b/concordia/tests/test_models.py @@ -114,6 +114,110 @@ def test_review_actions(self): end = timezone.now() - timedelta(days=1) self.assertEqual(Transcription.objects.review_actions(start, end).count(), 1) + def test_review_incidents(self): + self.transcription1.accepted = timezone.now() + self.transcription1.reviewed_by = self.create_user(username="tester2") + self.transcription1.save() + self.transcription2.accepted = self.transcription1.accepted + timedelta( + seconds=29 + ) + self.transcription2.reviewed_by = self.transcription1.reviewed_by + self.transcription2.save() + users = Transcription.objects.review_incidents() + self.assertNotIn(self.transcription1.user.id, users) + + transcription3 = create_transcription( + asset=self.transcription1.asset, + user=self.transcription1.user, + reviewed_by=self.transcription1.reviewed_by, + accepted=self.transcription1.accepted + timedelta(seconds=58), + ) + transcription4 = create_transcription( + asset=self.transcription1.asset, + user=self.transcription1.user, + reviewed_by=self.transcription1.reviewed_by, + accepted=transcription3.accepted + timedelta(minutes=1, seconds=1), + ) + users = Transcription.objects.review_incidents() + self.assertEqual(len(users), 1) + self.assertEqual( + users[0], + ( + self.transcription1.reviewed_by.id, + self.transcription1.reviewed_by.username, + 1, + ), + ) + + create_transcription( + asset=self.transcription1.asset, + user=self.transcription1.user, + reviewed_by=self.transcription1.reviewed_by, + accepted=transcription4.accepted + timedelta(seconds=29), + ) + create_transcription( + asset=self.transcription1.asset, + user=self.transcription1.user, + reviewed_by=self.transcription1.reviewed_by, + accepted=transcription4.accepted + timedelta(seconds=58), + ) + users = Transcription.objects.review_incidents() + self.assertEqual(len(users), 1) + self.assertEqual( + users[0], + ( + self.transcription1.reviewed_by.id, + self.transcription1.reviewed_by.username, + 2, + ), + ) + + def test_transcribe_incidents(self): + self.transcription1.submitted = timezone.now() + self.transcription1.save() + self.transcription2.submitted = self.transcription1.submitted + timedelta( + seconds=29 + ) + self.transcription2.user = self.transcription1.user + self.transcription2.save() + users = Transcription.objects.transcribe_incidents() + self.assertEqual(len(users), 0) + self.assertNotIn(self.transcription1.user.id, users) + + transcription3 = create_transcription( + asset=self.transcription1.asset, + user=self.transcription1.user, + submitted=self.transcription1.submitted + timedelta(seconds=58), + ) + transcription4 = create_transcription( + asset=self.transcription1.asset, + user=self.transcription1.user, + submitted=transcription3.submitted + timedelta(minutes=1, seconds=1), + ) + create_transcription( + asset=self.transcription1.asset, + user=self.transcription1.user, + submitted=transcription4.submitted + timedelta(seconds=59), + ) + users = Transcription.objects.transcribe_incidents() + self.assertEqual(len(users), 1) + self.assertEqual( + users[0], + (self.transcription1.user.id, self.transcription1.user.username, 1), + ) + + create_transcription( + asset=self.transcription1.asset, + user=self.transcription1.user, + submitted=self.transcription1.submitted + timedelta(minutes=1, seconds=59), + ) + users = Transcription.objects.transcribe_incidents() + self.assertEqual(len(users), 1) + self.assertEqual( + users[0], + (self.transcription1.user.id, self.transcription1.user.username, 2), + ) + class TranscriptionTestCase(CreateTestUsers, TestCase): def setUp(self): @@ -176,44 +280,6 @@ def test_status(self): TranscriptionStatus.CHOICE_MAP[TranscriptionStatus.COMPLETED], ) - def test_reviewing_too_quickly(self): - self.transcription1.accepted = timezone.now() - self.transcription1.reviewed_by = self.create_user(username="tester2") - self.transcription1.save() - self.transcription2.accepted = self.transcription1.accepted - self.transcription2.reviewed_by = self.transcription1.reviewed_by - self.transcription2.save() - transcriptions = Transcription.objects.reviewing_too_quickly() - self.assertEqual(len(transcriptions), 0) - - transcription3 = create_transcription( - asset=self.transcription1.asset, - user=self.transcription1.user, - reviewed_by=self.transcription1.reviewed_by, - accepted=self.transcription1.accepted, - ) - transcriptions = Transcription.objects.reviewing_too_quickly() - self.assertEqual(len(transcriptions), 1) - self.assertEqual(transcriptions[0][0], transcription3.reviewed_by.id) - - def test_transcribing_too_quickly(self): - self.transcription1.submitted = timezone.now() - self.transcription1.save() - self.transcription2.submitted = self.transcription1.submitted - self.transcription2.user = self.transcription1.user - self.transcription2.save() - transcriptions = Transcription.objects.transcribing_too_quickly() - self.assertEqual(len(transcriptions), 0) - - transcription3 = create_transcription( - asset=self.transcription1.asset, - user=self.transcription1.user, - submitted=self.transcription1.submitted, - ) - transcriptions = Transcription.objects.transcribing_too_quickly() - self.assertEqual(len(transcriptions), 1) - self.assertEqual(transcriptions[0][0], transcription3.user.id) - class AssetTranscriptionReservationTest(CreateTestUsers, TestCase): def setUp(self): diff --git a/concordia/tests/test_views.py b/concordia/tests/test_views.py index 23e4fd2da..ea912bef1 100644 --- a/concordia/tests/test_views.py +++ b/concordia/tests/test_views.py @@ -577,8 +577,8 @@ def test_asset_reservation_competition(self): # to edit it after logging in # 4 queries = - # 1 expiry + 1 acquire + 2 get user ID from request - with self.assertNumQueries(4): + # 1 expiry + 1 acquire + 2 get user ID + 2 get user profile from request + with self.assertNumQueries(6): resp = self.client.post(reverse("reserve-asset", args=(asset.pk,))) self.assertEqual(200, resp.status_code) self.assertEqual(1, AssetTranscriptionReservation.objects.count()) @@ -682,8 +682,9 @@ def test_asset_reservation_tombstone(self): self.client.logout() - # 1 reservation check + 1 acquire + 2 get user ID from request - expected_queries = 4 + # 1 reservation check + 1 acquire + 2 get user ID + # + 2 get user profile from request + expected_queries = 6 if settings.SESSION_ENGINE.endswith("db"): # + 1 session check expected_queries += 1 diff --git a/exporter/tabular_export/admin.py b/exporter/tabular_export/admin.py index 5c368a889..9892b43f0 100644 --- a/exporter/tabular_export/admin.py +++ b/exporter/tabular_export/admin.py @@ -49,10 +49,17 @@ def inner(modeladmin, request, queryset, filename=None, *args, **kwargs): @ensure_filename("xlsx") def export_to_excel_action( - modeladmin, request, queryset, filename=None, field_names=None + modeladmin, + request, + queryset, + filename=None, + field_names=None, + extra_verbose_names=None, ): """Django admin action which exports selected records as an Excel XLSX download""" - headers, rows = flatten_queryset(queryset, field_names=field_names) + headers, rows = flatten_queryset( + queryset, field_names=field_names, extra_verbose_names=extra_verbose_names + ) return export_to_excel_response(filename, headers, rows) @@ -61,10 +68,17 @@ def export_to_excel_action( @ensure_filename("csv") def export_to_csv_action( - modeladmin, request, queryset, filename=None, field_names=None + modeladmin, + request, + queryset, + filename=None, + field_names=None, + extra_verbose_names=None, ): """Django admin action which exports the selected records as a CSV download""" - headers, rows = flatten_queryset(queryset, field_names=field_names) + headers, rows = flatten_queryset( + queryset, field_names=field_names, extra_verbose_names=extra_verbose_names + ) return export_to_csv_response(filename, headers, rows)