From 84642cb3ff692b5dcc480b20da2331debd9a7f6b Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Wed, 7 Feb 2024 12:21:36 +0000 Subject: [PATCH 01/10] Adds 4 experimental reports. --- logic.py | 337 +++++++++++++++++- templates/reporting/index.html | 25 ++ templates/reporting/report_authors_data.html | 77 ++++ templates/reporting/report_reviewers.html | 88 +++++ templates/reporting/report_yearly_stats.html | 68 ++++ .../reporting/workflow_timings_report.html | 100 ++++++ urls.py | 14 +- views.py | 124 ++++++- 8 files changed, 827 insertions(+), 6 deletions(-) create mode 100644 templates/reporting/report_authors_data.html create mode 100644 templates/reporting/report_reviewers.html create mode 100644 templates/reporting/report_yearly_stats.html create mode 100644 templates/reporting/workflow_timings_report.html diff --git a/logic.py b/logic.py index 5a69d6b..17af2f1 100644 --- a/logic.py +++ b/logic.py @@ -17,15 +17,19 @@ F, IntegerField, Min, + Max, Case, Count, Q, Subquery, Func, When, - OuterRef + OuterRef, + Avg, + fields, ) from django.db.models.functions import TruncMonth +from django.utils.text import capfirst from submission import models as sm from core.files import serve_temp_file @@ -215,10 +219,14 @@ def stream_csv(headers, iterable, filename=None): def response_streamer(): """Writes each row to an in-memory file that is yielded immediately""" - # Headers file_like = StringIO() - csv_writer = csv.writer(file_like) + csv_writer = csv.writer( + file_like, + delimiter=',', + quotechar='"', + quoting=csv.QUOTE_ALL, + ) csv_writer.writerow(headers) yield file_like.getvalue() @@ -998,4 +1006,325 @@ def export_workflow_report(article_list, averages): all_rows.append(row) - return export_csv(all_rows) \ No newline at end of file + return export_csv(all_rows) + + +@cache(600) +def get_report_reviewers_data(journal): + return rm.ReviewAssignment.objects.filter( + article__journal=journal + ).values( + 'reviewer', + 'reviewer__first_name', + 'reviewer__last_name', + ).annotate( + total_assignments=Count('id'), + accepted_assignments=Count( + 'id', + filter=Q(date_accepted__isnull=False) + ), + declined_assignments=Count( + 'id', + filter=Q( + date_declined__isnull=False, + decision__isnull=True, + ) + ), + withdrawn_assignments=Count( + 'id', + filter=Q( + decision='withdrawn', + ) + ), + completed_assignments=Count( + 'id', + filter=Q( + date_declined__isnull=True, + decision__isnull=False, + date_complete__isnull=False, + is_complete=True, + ) + ), + assignments_awaiting_response=Count( + 'id', + filter=Q( + decision__isnull=True, + date_accepted__isnull=True, + date_declined__isnull=True, + ), + ), + earliest_completed_review=Min( + 'date_complete', + filter=Q( + is_complete=True, + date_declined__isnull=True, + ), + ), + latest_completed_review=Max( + 'date_complete', + filter=Q( + is_complete=True, + date_declined__isnull=True, + ), + ), + average_rating=Avg('reviewerrating__rating'), + average_time_to_complete=Avg( + Case( + When( + date_requested__lte=F('date_complete'), + then=F('date_complete') - F('date_requested') + ), + default=None, + output_field=fields.DurationField(), + ), + filter=Q(date_complete__isnull=False, date_declined__isnull=True) + ), + ) + + +def get_report_author_data(journal): + return core_models.Account.objects.filter( + accountrole__role__slug="author", + accountrole__journal=journal, + ).values( + 'id', + 'username', + 'first_name', + 'last_name', + 'salutation', + ).annotate( + total_articles=Count( + 'authors__id', + filter=Q(authors__date_submitted__isnull=False, + authors__journal=journal), + ), + accepted_articles=Count( + 'authors__id', + filter=Q(authors__date_submitted__isnull=False, + authors__date_accepted__isnull=False, + authors__journal=journal), + ), + declined_articles=Count( + 'authors__id', + filter=Q(authors__date_submitted__isnull=False, + authors__date_declined__isnull=False, + authors__journal=journal), + ), + published_articles=Count( + 'authors__id', filter=Q( + authors__date_submitted__isnull=False, + authors__date_published__isnull=False, + authors__journal=journal), + ), + ) + + +@cache(600) +def get_workflow_times(journal, date_parts): + start = timezone.make_aware(timezone.datetime( + int(date_parts["start_month_y"]), + int(date_parts["start_month_m"]), + 1 + )) + end = timezone.make_aware(timezone.datetime( + int(date_parts["end_month_y"]), + int(date_parts["end_month_m"]), + 1 + # get first day of next month at 00:00:00 + ) + relativedelta(months=1)) + articles = sm.Article.objects.filter( + journal=journal, + date_submitted__range=[ + start, + end, + ], + date_published__isnull=False, + ) + + workflow_elements = core_models.WorkflowElement.objects.filter( + journal=journal, + element_name__in=core_models.WorkflowLog.objects.filter( + article__journal=journal, + article__date_submitted__range=[ + start, + end, + ], + article__date_published__isnull=False, + ).values('element__element_name') + ).order_by('order') + + workflow_element_names = [element.element_name for element in workflow_elements] + + workflow_logs = core_models.WorkflowLog.objects.filter( + article__journal=journal, + article__date_submitted__range=[ + start, + end, + ], + article__date_published__isnull=False, + ).select_related('article', 'element').order_by('article', 'timestamp') + + workflow_times_dict = {} + for article in articles: + workflow_times_dict[article.id] = {element_name: None for element_name in workflow_element_names} + + article_logs = workflow_logs.filter(article=article) + for index, workflow_log in enumerate(article_logs): + element_name = workflow_log.element.element_name + + try: + next_workflow_log = article_logs[index + 1] + time_in_element = next_workflow_log.timestamp - workflow_log.timestamp + except IndexError: + # For the last entry, compare with date_published + time_in_element = article.date_published - workflow_log.timestamp + + workflow_times_dict[article.pk][element_name] = time_in_element + + workflow_times_list = [] + for article in articles: + article_id = article.id + workflow_times_list.append({ + 'article_title': article.title, + 'date_submitted': article.date_submitted, + **workflow_times_dict[article_id] + }) + + return workflow_times_list + + +@cache(600) +def get_yearly_stats(journal): + # Get the earliest year of publication + earliest_year = sm.Article.objects.filter(journal=journal).order_by('date_submitted').values('date_submitted__year').first()['date_submitted__year'] + + yearly_stats = {} + + # Loop through each year from the earliest year to the current year + current_year = timezone.now().year + for year in range(earliest_year, current_year + 1): + yearly_stats[year] = sm.Article.objects.filter( + journal=journal, date_submitted__year=year + ).aggregate( + articles_submitted=Count('id'), + articles_assigned_review_revision=Count( + Case( + When(stage__in=['Assigned', 'Under Review', 'Under Revision'], then='id'), + default=None, + output_field=IntegerField() + ) + ), + articles_accepted=Count( + Case( + When(date_accepted__isnull=False, then='id'), + default=None, + output_field=IntegerField() + ) + ), + articles_rejected=Count( + Case( + When(date_declined__isnull=False, then='id'), + default=None, + output_field=IntegerField() + ) + ), + articles_published=Count( + Case( + When(date_published__isnull=False, then='id'), + default=None, + output_field=IntegerField() + ) + ), + articles_archived=Count( + Case( + When(stage='Archived', then='id'), + default=None, + output_field=IntegerField() + ) + ), + ) + return yearly_stats + + +def yearly_stats_iterable(yearly_stats): + iterable = list() + for year, stats in yearly_stats.items(): + iterable.append([ + year, + stats.get('articles_submitted'), + stats.get('articles_assigned_review_revision'), + stats.get('articles_accepted'), + stats.get('articles_rejected'), + stats.get('articles_published'), + stats.get('articles_archived'), + ]) + return iterable + + +def get_workflow_times_export(workflow_times_list): + headers = ['Article Title', 'Date Submitted'] + iterable = list() + for i, article_data in enumerate(workflow_times_list): + row = [] + for stage, time in article_data.items(): + if i == 0 and stage not in ['article_title', 'date_submitted']: # first in list + headers.append(capfirst(stage)) + row.append(time) + iterable.append(row) + + return headers, iterable + + +def get_report_author_export(authors): + headers = list() + iterable = list() + for i, author_data in enumerate(authors): + row = [] + for k, v in author_data.items(): + if i == 0: + headers.append(capfirst(k)) + row.append(v) + iterable.append(row) + + return headers, iterable + + +def get_reviewers_export(reviewers): + headers = [ + 'ID', + 'First Name', + 'Last Name', + 'Total Requests', + 'Accepted Requests', + 'Declined Requests', + 'Withdrawn Requests', + 'Completed Requests', + 'Requests Awaiting Response', + 'Earliest Completed Review', + 'Latest Completed Review', + 'Average Time to Completion', + 'Average Rating', + ] + print(reviewers) + iterable = list() + for reviewer in reviewers: + iterable.append( + [ + reviewer.get('reviewer'), + reviewer.get('reviewer__first_name'), + reviewer.get('reviewer__last_name'), + reviewer.get('total_assignments'), + reviewer.get('accepted_assignments'), + reviewer.get('declined_assignments'), + reviewer.get('withdrawn_assignments'), + reviewer.get('completed_assignments'), + reviewer.get('assignments_awaiting_response'), + reviewer.get('earliest_completed_review'), + reviewer.get('latest_completed_review'), + reviewer.get('average_time_to_complete'), + reviewer.get('average_rating'), + + ] + ) + + return headers, iterable diff --git a/templates/reporting/index.html b/templates/reporting/index.html index 89b4505..ca700e1 100644 --- a/templates/reporting/index.html +++ b/templates/reporting/index.html @@ -140,6 +140,31 @@ View Report + +
  • + Experimental Reports +
    +

    Listed below are some experimental reports intended to be fully released as part of v1.6.

    +
      +
    • + Workflow Stage Time to Completion +
      • Details how long an article took to complete each workflow stage.
      +
    • +
    • + Journal Authors Report +
      • Shows users with the author role and statistics about their submissions.
      +
    • +
    • + Peer Reviewers Data Report +
      • Shows statistics about each peer reviewer for this journal including total number of requests, accepted requests and declined requests.
      +
    • +
    • + Journal Yearly Statistics +
      • For each year this journal has a published article this report shows the total number of submissions, acceptances, rejections, publications and the number of articles from that year that are still under review.
      +
    • +
    +
    +
  • diff --git a/templates/reporting/report_authors_data.html b/templates/reporting/report_authors_data.html new file mode 100644 index 0000000..ac02ecb --- /dev/null +++ b/templates/reporting/report_authors_data.html @@ -0,0 +1,77 @@ +{% extends "admin/core/base.html" %} + +{% block title %}Journal Author Data Report{% endblock %} +{% block title-section %}Journal Author Data Report{% endblock %} + +{% block breadcrumbs %} + {{ block.super }} +
  • Reporting Index
  • +
  • Journal Author Data Report
  • +{% endblock %} + +{% block body %} +
    +
    +
    +
    +

    Context

    +
    +
    +

    This report shows a journal's authors and their statistics. + Authors are limited to users with the author role on this + journal. Row totals may not add up where the author has + an article in workflow that is not accepted, rejected + or published.

    +
    +
    +
    +
    +
    +
    +

    Journal Author Data Report for {{ request.journal.name }}

    + Export to CSV +
    +
    +
    + + + + + + + + + + + + + + + + {% for author in authors %} + + + + + + + + + + + + {% endfor %} + +
    IDSalutationFirst NameLast NameUsernameTotal ArticlesAccepted ArticlesRejected ArticlesPublished Articles
    {{ author.id }} + {{ author.salutation|default_if_none:"" }} + {{ author.first_name }}{{ author.last_name }}{{ author.username }}{{ author.total_articles }}{{ author.accepted_articles }}{{ author.declined_articles }}{{ author.published_articles }}
    +
    +
    +
    +
    +
    +{% endblock %} + +{% block js %} + {% include "elements/datatables.html" with target="#reviewers_report" %} +{% endblock js %} diff --git a/templates/reporting/report_reviewers.html b/templates/reporting/report_reviewers.html new file mode 100644 index 0000000..8083b50 --- /dev/null +++ b/templates/reporting/report_reviewers.html @@ -0,0 +1,88 @@ +{% extends "admin/core/base.html" %} + +{% block title %}Peer Reviewers Data Report{% endblock %} +{% block title-section %}Peer Reviewers Data Report{% endblock %} + +{% block breadcrumbs %} + {{ block.super }} +
  • Reporting Index
  • +
  • Peer Reviewers Data Report
  • +{% endblock %} + +{% block body %} +
    +
    +
    +
    +

    Context

    +
    +
    +

    This report displays totals for each reviewer including + total request, accepted, declined and withdrawn + reviews. Note that for the Average Time to + Completion column all review assignments where + date_requested is greater than date_completed are + ignored. This is usually due to an import where the + date_requested is not actually known. +

    +
    +
    +
    +
    +
    +
    +

    Peer Reviewers Data Report for {{ request.journal.name }}

    + Export to CSV +
    +
    +
    + + + + + + + + + + + + + + + + + + + + {% for reviewer in reviewers %} + + + + + + + + + + + + + + + + {% endfor %} + +
    IDFirst NameLast NameTotal RequestsAccepted RequestsDeclined RequestsWithdrawn RequestsCompleted RequestsRequests Awaiting ResponseEarliest Completed ReviewLatest Completed ReviewAverage Time to CompletionAverage Rating
    {{ reviewer.reviewer }}{{ reviewer.reviewer__first_name }}{{ reviewer.reviewer__last_name }}{{ reviewer.total_assignments }}{{ reviewer.accepted_assignments }}{{ reviewer.declined_assignments }}{{ reviewer.withdrawn_assignments }}{{ reviewer.completed_assignments }}{{ reviewer.assignments_awaiting_response }} + {{ reviewer.earliest_completed_review|date:"Y-m-d" }} + {{ reviewer.latest_completed_review|date:"Y-m-d" }}{{ reviewer.average_time_to_complete }}{{ reviewer.average_rating }}
    +
    +
    +
    +
    +
    +{% endblock %} + +{% block js %} + {% include "elements/datatables.html" with target="#reviewers_report" %} +{% endblock js %} diff --git a/templates/reporting/report_yearly_stats.html b/templates/reporting/report_yearly_stats.html new file mode 100644 index 0000000..b0fc584 --- /dev/null +++ b/templates/reporting/report_yearly_stats.html @@ -0,0 +1,68 @@ +{% extends "admin/core/base.html" %} +{% load timedelta %} + +{% block title %}Journal Yearly Statistics{% endblock %} +{% block title-section %} +Journal Yearly Statistics +{% endblock %} + +{% block breadcrumbs %} + {{ block.super }} +
  • Reporting Index
  • +
  • Journal Yearly Statistics
  • +{% endblock %} + +{% block body %} +
    +
    +
    +
    +

    Context

    +
    +
    +

    For each year displays a series of journal statistics. Assigned, Under Review or Under Revision shows articles that were submitted in the given year that are still in that workflow element.

    +
    +
    +
    +
    +
    +
    +
    +

    Statistics by Year for {{ request.journal.name }}

    + Export to CSV +
    +
    +
    + + + + + + + + + + + + + + {% for year, stats in yearly_stats.items %} + + + + + + + + + + {% endfor %} + +
    YearArticles SubmittedAssigned, Under Review, or Under RevisionArticles AcceptedArticles RejectedArticles PublishedArticles Archived
    {{ year }}{{ stats.articles_submitted }}{{ stats.articles_assigned_review_revision }}{{ stats.articles_accepted }}{{ stats.articles_rejected }}{{ stats.articles_published }}{{ stats.articles_archived }}
    +
    +
    +
    +
    + + +{% endblock %} diff --git a/templates/reporting/workflow_timings_report.html b/templates/reporting/workflow_timings_report.html new file mode 100644 index 0000000..535b5f6 --- /dev/null +++ b/templates/reporting/workflow_timings_report.html @@ -0,0 +1,100 @@ +{% extends "admin/core/base.html" %} +{% load timedelta %} + +{% block title %}Workflow Stage Completion Time{% endblock %} +{% block title-section %}Workflow Stage Completion Time{% endblock %} + +{% block breadcrumbs %} + {{ block.super }} +
  • Reporting Index
  • +
  • Workflow Times Report
  • +{% endblock %} + +{% block body %} +
    +
    +
    +
    +

    Context

    +
    +
    +

    For published articles this report displays how long it was in each of the workflow stages.

    +
    +
    +

    Date Filters

    +
    +
    +

    Choose a + year and month using the YYYY-MM format. The report + will cover articles that have been published during + this specific time period.

    +
    +
    + {{ month_form.errors|safe }} +
    + {{ month_form.start_month }} +
    +
    + {{ month_form.end_month }} +
    +
    + +
    +
    +


    +
    + +


    +
    +
    +
    +
    +
    +
    +
    +

    Workflow Stage Time to Completion for {{ request.journal.name }}

    +
    + {% csrf_token %} + Export to CSV +
    +
    +
    +
    + + + + + + {% for element_name in workflow_times_list.0 %} + {% if element_name != 'article_title' and element_name != 'date_submitted' %} + + {% endif %} + {% endfor %} + + + + {% for workflow_times in workflow_times_list %} + + + + {% for element_name, time_in_element in workflow_times.items %} + {% if element_name != 'article_title' and element_name != 'date_submitted' %} + + {% endif %} + {% endfor %} + + {% endfor %} + +
    Article TitleDate Submitted{{ element_name|capfirst }}
    {{ workflow_times.article_title }} + {{ workflow_times.date_submitted|date:"Y-m-d" }} + {{ time_in_element|default:"None" }}
    + +
    +
    +
    +
    +{% endblock %} + +{% block js %} + {% include "elements/datatables.html" with target="#workflow_timings" %} +{% endblock js %} diff --git a/urls.py b/urls.py index 909fd51..b49e23f 100644 --- a/urls.py +++ b/urls.py @@ -60,7 +60,19 @@ re_path(r'^licenses/$', views.report_licenses, name='reporting_license'), - re_path(r'^workflow/$', + re_path(r'^workflow/submssion_to_publication/$', views.report_workflow, name='reporting_workflow'), + re_path(r'^reviewers/$', + views.report_reviewers, + name='report_reviewers'), + re_path(r'^authors/data/$', + views.report_authors_data, + name='report_authors_data'), + re_path(r'^workflow/stage_averages/$', + views.report_workflow_stage, + name='report_workflow_stages'), + re_path(r'^yearly_stats/$', + views.report_yearly_stats, + name='report_yearly_stats'), ] diff --git a/views.py b/views.py index 084ee30..a65aadb 100644 --- a/views.py +++ b/views.py @@ -18,7 +18,9 @@ from journal import models as jm from metrics import models as mm from utils import plugins +from review import models as rm +from django.db.models import Count @editor_user_required def index(request): @@ -597,4 +599,124 @@ def report_authors(request): template = 'reporting/report_authors.html' - return render(request, template, context) \ No newline at end of file + return render(request, template, context) + + +@editor_user_required +def report_reviewers(request): + """ + Displays information about Peer Reviewers. + :param request: HttpRequest object + :return: HttpResponse or HttpRedirect + """ + reviewers = logic.get_report_reviewers_data( + journal=request.journal, + ) + if 'csv' in request.GET: + headers, iterable = logic.get_reviewers_export( + reviewers, + ) + return logic.stream_csv( + headers, + iterable, + filename=f'{request.journal.code}_reviewer_report.csv' + ) + template = 'reporting/report_reviewers.html' + context = { + 'reviewers': reviewers, + } + return render( + request, + template, + context, + ) + + +@editor_user_required +def report_authors_data(request): + """ + Displays information about a Journal's authors. + :param request: HttpRequest object + :return: HttpResponse or HttpRedirect + """ + authors = logic.get_report_author_data( + journal=request.journal, + ) + if 'csv' in request.GET: + headers, iterable = logic.get_report_author_export( + authors, + ) + return logic.stream_csv( + headers, + iterable, + f'{request.journal.code}_author_report.csv', + ) + template = 'reporting/report_authors_data.html' + context = { + 'authors': authors, + } + return render( + request, + template, + context, + ) + + +@editor_user_required +def report_workflow_stage(request): + start_month, end_month, date_parts = logic.get_start_and_end_months( + request, + ) + month_form = forms.MonthForm( + initial={ + 'start_month': start_month, 'end_month': end_month, + } + ) + workflow_times_list = logic.get_workflow_times( + request.journal, + date_parts, + ) + if 'csv' in request.GET: + headers, iterable = logic.get_workflow_times_export( + workflow_times_list, + ) + return logic.stream_csv( + headers, + iterable, + filename=f'{request.journal.code}_workflow_stage_completion.csv', + ) + + context = { + 'month_form': month_form, + 'workflow_times_list': workflow_times_list, + } + + template = 'reporting/workflow_timings_report.html', + return render( + request, + template, + context, + ) + + +@editor_user_required +def report_yearly_stats(request): + yearly_stats = logic.get_yearly_stats(request.journal) + + if 'csv' in request.GET: + return logic.stream_csv( + ['Year', 'Articles Submitted', 'In Review', ' Articles Accepted', + 'Artcicles Rejected', 'Articles Published', 'Articles Archived'], + logic.yearly_stats_iterable(yearly_stats), + filename=f'{request.journal.code}_yearly_stats.csv' + ) + + template = 'reporting/report_yearly_stats.html' + context = { + 'yearly_stats': yearly_stats + } + return render( + request, + template, + context, + ) From 0175058b6dcdbd81817ad220f0b222e6fc58994a Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Wed, 21 Feb 2024 15:15:39 +0000 Subject: [PATCH 02/10] Remove print command --- logic.py | 1 - 1 file changed, 1 deletion(-) diff --git a/logic.py b/logic.py index 17af2f1..2712357 100644 --- a/logic.py +++ b/logic.py @@ -1305,7 +1305,6 @@ def get_reviewers_export(reviewers): 'Average Time to Completion', 'Average Rating', ] - print(reviewers) iterable = list() for reviewer in reviewers: iterable.append( From 3ca2274af14f62983c63fe86522ce4aabc6a4b1b Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Wed, 12 Jun 2024 16:10:53 +0100 Subject: [PATCH 03/10] Adds new experimental report for active reviews. --- logic.py | 22 ++++- .../reporting/articles_under_review.html | 91 +++++++++++++++++++ templates/reporting/index.html | 4 + urls.py | 3 + views.py | 34 ++++++- 5 files changed, 150 insertions(+), 4 deletions(-) create mode 100644 templates/reporting/articles_under_review.html diff --git a/logic.py b/logic.py index 2712357..e66c181 100644 --- a/logic.py +++ b/logic.py @@ -36,7 +36,7 @@ from core import models as core_models from utils.function_cache import cache from journal import models as jm -from review import models as rm +from review import models as rm, logic as rl from metrics import models as mm from identifiers import models as id_models from plugins.reporting.templatetags import timedelta as td_tag @@ -1261,6 +1261,26 @@ def yearly_stats_iterable(yearly_stats): return iterable +def articles_under_review_iterable(articles_under_review): + iterable = list() + for article in articles_under_review: + for i, review in enumerate(article.reviewassignment_set.all()): + iterable.append([ + article.title if i == 0 else '', + review.reviewer.first_name, + review.reviewer.last_name, + review.reviewer.email, + review.request_decision_status(), + review.decision, + article.journal.site_url( + path=rl.generate_access_code_url('do_review', review, review.access_code) + ), + review.date_due, + review.date_complete + ]) + return iterable + + def get_workflow_times_export(workflow_times_list): headers = ['Article Title', 'Date Submitted'] iterable = list() diff --git a/templates/reporting/articles_under_review.html b/templates/reporting/articles_under_review.html new file mode 100644 index 0000000..7172d55 --- /dev/null +++ b/templates/reporting/articles_under_review.html @@ -0,0 +1,91 @@ +{% extends "admin/core/base.html" %} +{% load timedelta %} + +{% block title %}Articles Under Review{% endblock %} +{% block title-section %} + Articles Under Review +{% endblock %} + +{% block breadcrumbs %} + {{ block.super }} +
  • Reporting Index
  • +
  • Articles Under Review
  • +{% endblock %} + +{% block body %} +
    +
    +
    +
    +

    Context

    +
    +
    +

    Displays all of the articles currently under review for + this journal including their reviewers, recommendations + and due dates.

    +
    +
    +
    +
    +
    +
    +
    +

    Articles Under Review + in {{ request.journal.name|safe }}

    + Export to CSV +
    +
    +
    + {% for article in articles_under_review %} + + + + + + + + + + + + + + + + + + {% for review in article.reviewassignment_set.all %} + + + + + + + + + + + {% empty %} + + + + {% endfor %} + +
    {{ article.safe_title }}
    First NameLast NameEmail AddressReviewer DecisionRecommendationAccess CodeDue DateDate Complete
    {{ review.reviewer.first_name }}{{ review.reviewer.last_name }}{{ review.reviewer.email }}{% if review.decision == 'withdrawn' %} + {% trans 'Withdrawn' %} + {{ review.date_complete|date:"Y-m-d" }} + {% elif review.date_accepted %}{% trans 'Accepted' %} + {{ review.date_accepted|date:"Y-m-d" }} + {% elif review.date_declined %}{% trans 'Declined' %} + {{ review.date_declined|date:"Y-m-d" }} + {% else %} + {% trans 'Awaiting acknowledgement' %}{% endif %} + {{ review.get_decision_display }}{% journal_url 'do_review' review.pk %}?access_code={{ review.access_code }}{{ review.date_due|date:"Y-m-d" }}{{ review.date_complete|date:"Y-m-d" }}
    No review assignments
    + {% endfor %} +
    +
    +
    +
    + + +{% endblock %} diff --git a/templates/reporting/index.html b/templates/reporting/index.html index ca700e1..35dbc2e 100644 --- a/templates/reporting/index.html +++ b/templates/reporting/index.html @@ -162,6 +162,10 @@ Journal Yearly Statistics
    • For each year this journal has a published article this report shows the total number of submissions, acceptances, rejections, publications and the number of articles from that year that are still under review.
    +
  • + Articles Under Review +
    • For each article lists the review assignments, reviewer information and status.
    +
  • diff --git a/urls.py b/urls.py index b49e23f..764a506 100644 --- a/urls.py +++ b/urls.py @@ -75,4 +75,7 @@ re_path(r'^yearly_stats/$', views.report_yearly_stats, name='report_yearly_stats'), + re_path(r'^under-review/$', + views.report_articles_under_review, + name='report_articles_under_review'), ] diff --git a/views.py b/views.py index a65aadb..25b954f 100644 --- a/views.py +++ b/views.py @@ -13,14 +13,12 @@ from plugins.reporting import forms, logic from journal import models from production import models as pm -from security.decorators import editor_user_required +from security.decorators import editor_user_required, has_journal from submission import models as sm from journal import models as jm from metrics import models as mm from utils import plugins -from review import models as rm -from django.db.models import Count @editor_user_required def index(request): @@ -720,3 +718,33 @@ def report_yearly_stats(request): template, context, ) + + +@has_journal +@editor_user_required +def report_articles_under_review(request): + articles_under_review = sm.Article.objects.filter( + stage__in=sm.REVIEW_STAGES, + journal=request.journal, + ).prefetch_related( + 'reviewassignment_set', + ) + if 'csv' in request.GET: + return logic.stream_csv( + [ + 'Title', 'First Name', 'Last Name', 'Email Address', + 'Reviewer Decision', 'Recommendation', 'Access Code', + 'Due Date', 'Date Complete' + ], + logic.articles_under_review_iterable(articles_under_review), + filename=f'{request.journal.code}_yearly_stats.csv' + ) + template = 'reporting/articles_under_review.html' + context = { + 'articles_under_review': articles_under_review, + } + return render( + request, + template, + context, + ) From ae18ccc8c753026f11860292c9361f83b531850e Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Wed, 12 Jun 2024 16:22:38 +0100 Subject: [PATCH 04/10] Tweak the filter of under review report. --- views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/views.py b/views.py index 25b954f..024fab0 100644 --- a/views.py +++ b/views.py @@ -724,7 +724,7 @@ def report_yearly_stats(request): @editor_user_required def report_articles_under_review(request): articles_under_review = sm.Article.objects.filter( - stage__in=sm.REVIEW_STAGES, + stage=sm.STAGE_UNDER_REVIEW, journal=request.journal, ).prefetch_related( 'reviewassignment_set', From c813817f0e8bf102024aa109f97f6b102aee93fe Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Fri, 14 Jun 2024 22:19:20 +0100 Subject: [PATCH 05/10] Run report via reviewassignment rather than article. --- logic.py | 31 +++++++++---------- .../reporting/articles_under_review.html | 9 ++++-- views.py | 19 +++++++----- 3 files changed, 32 insertions(+), 27 deletions(-) diff --git a/logic.py b/logic.py index e66c181..4ee8656 100644 --- a/logic.py +++ b/logic.py @@ -1261,23 +1261,22 @@ def yearly_stats_iterable(yearly_stats): return iterable -def articles_under_review_iterable(articles_under_review): +def articles_under_review_iterable(review_assignments): iterable = list() - for article in articles_under_review: - for i, review in enumerate(article.reviewassignment_set.all()): - iterable.append([ - article.title if i == 0 else '', - review.reviewer.first_name, - review.reviewer.last_name, - review.reviewer.email, - review.request_decision_status(), - review.decision, - article.journal.site_url( - path=rl.generate_access_code_url('do_review', review, review.access_code) - ), - review.date_due, - review.date_complete - ]) + for i, review in enumerate(review_assignments): + iterable.append([ + review.article.title if i == 0 else '', + review.reviewer.first_name, + review.reviewer.last_name, + review.reviewer.email, + review.request_decision_status(), + review.decision, + review.article.journal.site_url( + path=rl.generate_access_code_url('do_review', review, review.access_code) + ), + review.date_due, + review.date_complete + ]) return iterable diff --git a/templates/reporting/articles_under_review.html b/templates/reporting/articles_under_review.html index 7172d55..b2b9089 100644 --- a/templates/reporting/articles_under_review.html +++ b/templates/reporting/articles_under_review.html @@ -13,6 +13,8 @@ {% endblock %} {% block body %} + {% regroup review_assignments by article as article_list %} +
    @@ -36,8 +38,9 @@

    Articles Under Review

    - {% for article in articles_under_review %} - + {% regroup review_assignments by article as article_list %} + {% for article, review_assignments in article_list %} +
    @@ -54,7 +57,7 @@

    Articles Under Review

    - {% for review in article.reviewassignment_set.all %} + {% for review in review_assignments %} diff --git a/views.py b/views.py index 024fab0..f3255a2 100644 --- a/views.py +++ b/views.py @@ -17,6 +17,7 @@ from submission import models as sm from journal import models as jm from metrics import models as mm +from review import models as rm from utils import plugins @@ -723,11 +724,13 @@ def report_yearly_stats(request): @has_journal @editor_user_required def report_articles_under_review(request): - articles_under_review = sm.Article.objects.filter( - stage=sm.STAGE_UNDER_REVIEW, - journal=request.journal, - ).prefetch_related( - 'reviewassignment_set', + review_assignments = rm.ReviewAssignment.objects.filter( + article__stage=sm.STAGE_UNDER_REVIEW, + article__journal=request.journal, + ).select_related( + 'article', + 'article__journal', + 'reviewer', ) if 'csv' in request.GET: return logic.stream_csv( @@ -736,12 +739,12 @@ def report_articles_under_review(request): 'Reviewer Decision', 'Recommendation', 'Access Code', 'Due Date', 'Date Complete' ], - logic.articles_under_review_iterable(articles_under_review), - filename=f'{request.journal.code}_yearly_stats.csv' + logic.articles_under_review_iterable(review_assignments), + filename=f'{request.journal.code}_articles_under_review.csv' ) template = 'reporting/articles_under_review.html' context = { - 'articles_under_review': articles_under_review, + 'review_assignments': review_assignments, } return render( request, From aea36f87f917451abf64368b63ada48767873f53 Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Tue, 25 Jun 2024 13:34:49 +0100 Subject: [PATCH 06/10] Always display the article title in exports. --- logic.py | 2 +- views.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/logic.py b/logic.py index 4ee8656..cf24b77 100644 --- a/logic.py +++ b/logic.py @@ -1265,7 +1265,7 @@ def articles_under_review_iterable(review_assignments): iterable = list() for i, review in enumerate(review_assignments): iterable.append([ - review.article.title if i == 0 else '', + review.article.title, review.reviewer.first_name, review.reviewer.last_name, review.reviewer.email, diff --git a/views.py b/views.py index f3255a2..40b2951 100644 --- a/views.py +++ b/views.py @@ -731,6 +731,8 @@ def report_articles_under_review(request): 'article', 'article__journal', 'reviewer', + ).order_by( + 'article__title', ) if 'csv' in request.GET: return logic.stream_csv( From 2cf93de3e0a813c1358a09d1cdae0b6909b3799b Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Tue, 14 Jan 2025 11:58:34 +0000 Subject: [PATCH 07/10] Fixes bad merge. --- views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/views.py b/views.py index 222ce63..09aa4ce 100644 --- a/views.py +++ b/views.py @@ -778,7 +778,7 @@ def report_articles_under_review(request): request, template, context, - } + ) @is_repository_manager From 75fd2a8087d3bfd8fdd27bcaabbf95bfe0bb3479 Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Tue, 4 Mar 2025 09:53:16 +0000 Subject: [PATCH 08/10] feat: adds new experimental report --- logic.py | 40 ++++++++++- .../report_time_to_first_decision.html | 71 +++++++++++++++++++ urls.py | 5 ++ views.py | 50 +++++++++++++ 4 files changed, 164 insertions(+), 2 deletions(-) create mode 100644 templates/reporting/report_time_to_first_decision.html diff --git a/logic.py b/logic.py index 1c2c972..ac70eca 100644 --- a/logic.py +++ b/logic.py @@ -2,7 +2,7 @@ from io import StringIO from itertools import chain import os -from datetime import datetime, date, timedelta +from datetime import date, timedelta from dateutil.relativedelta import relativedelta @@ -27,10 +27,12 @@ OuterRef, Avg, fields, + Value, + CharField, + DateTimeField, ) from django.db.models.functions import TruncMonth from django.utils.text import capfirst -from django.contrib import messages from submission import models as sm from core.files import serve_temp_file @@ -1374,3 +1376,37 @@ def manager_metrics_summary(repository, start_date, end_date): ) ) return preprints + + +def get_time_to_first_decision(journal, start_date, end_date): + return sm.Article.objects.filter( + journal=journal, + date_submitted__gte=start_date, + date_submitted__lte=end_date, + ).annotate( + first_decision_date=ExpressionWrapper( + Func( + F("date_accepted"), + F("date_declined"), + F("revisionrequest__date_requested"), + function="LEAST", + ), + output_field=DateTimeField(), + ), + decision_type=Case( + When( + date_accepted=F("first_decision_date"), + then=Value("accept"), + ), + When( + date_declined=F("first_decision_date"), + then=Value("decline"), + ), + When( + revisionrequest__date_requested=F("first_decision_date"), + then=Value("revision"), + ), + default=Value("unknown"), + output_field=CharField(), + ), + ) diff --git a/templates/reporting/report_time_to_first_decision.html b/templates/reporting/report_time_to_first_decision.html new file mode 100644 index 0000000..9bbfef3 --- /dev/null +++ b/templates/reporting/report_time_to_first_decision.html @@ -0,0 +1,71 @@ +{% extends "admin/core/base.html" %} + +{% block title %}Reports{% endblock %} +{% block title-section %}Time to First Decision{% endblock %} + +{% block breadcrumbs %} + {{ block.super }} +
  • Reporting Index
  • +
  • Time to First Decision
  • +{% endblock %} + +{% block body %} + +
    +
    +

    Filter by Dates

    +
    {% csrf_token %} + Export to CSV + +
    +
    +
    +
    + {{ date_form.errors|safe }} +
    + {{ date_form.start_date }} +
    +
    + {{ date_form.end_date }} +
    +
    + +
    + +
    +
    +
    +
    +
    +
    +
    {{ article.safe_title }}
    {{ review.reviewer.first_name }} {{ review.reviewer.last_name }}
    + + + + + + + + + + + {% for article in articles %} + + + + + + + + + {% endfor %} + +
    IDTitleDate SubmittedFirst Decision DateDecision
    {{ article.pk }}{{ article.safe_title }}{{ article.date_submitted }}{{ article.first_decision_date }}{{ article.decision_type }}
    +
    +
    +
    +{% endblock %} + +{% block js %} + {% include "elements/datatables.html" with target="#first-decision-report" %} +{% endblock js %} diff --git a/urls.py b/urls.py index 5f1decf..d4f0786 100644 --- a/urls.py +++ b/urls.py @@ -86,4 +86,9 @@ views.geographical_data, name='api_geographical_data' ), +re_path( + r'^first-decision/$', + views.report_time_to_first_decision, + name='report_time_to_first_decision' + ), ] diff --git a/views.py b/views.py index 09aa4ce..22873b0 100644 --- a/views.py +++ b/views.py @@ -780,6 +780,56 @@ def report_articles_under_review(request): context, ) + +@editor_user_required +def report_time_to_first_decision(request): + """ + A report that shows for articles submitted during a time period + when their first decision was and the time to the first decision. + """ + start_date, end_date = logic.get_start_and_end_date(request) + date_form = forms.DateForm( + request.GET, + ) + articles = sm.Article.objects.none() + if date_form.is_valid(): + articles = logic.get_time_to_first_decision( + journal=request.journal, + start_date=start_date, + end_date=end_date, + ) + if "csv" in request.GET: + return logic.stream_csv( + headers=[ + 'ID', + 'Title', + 'Date Submitted', + 'First Decision Date', + 'Decision', + ], + iterable=[ + [ + article.pk, + article.title, + article.date_submitted, + article.first_decision_date, + article.decision_type, + ] + for article in articles + ], + filename=f'{request.journal.code}_time_to_first_decision.csv' + ) + template = 'reporting/report_time_to_first_decision.html' + context = { + 'articles': articles, + 'date_form': date_form, + } + return render( + request, + template, + context, + ) + @is_repository_manager def report_preprints_metrics(request): From dab18f5bf4d1ddd3db1885d1412aa5d9719df5be Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Tue, 4 Mar 2025 09:55:41 +0000 Subject: [PATCH 09/10] feat: adds new experimental report --- templates/reporting/index.html | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/templates/reporting/index.html b/templates/reporting/index.html index a10ab22..811fe98 100644 --- a/templates/reporting/index.html +++ b/templates/reporting/index.html @@ -165,6 +165,10 @@ Articles Under Review
    • For each article lists the review assignments, reviewer information and status.
    +
  • + Time to First Decision +
    • For each article submitted in a given time period displays a time to first decision.
    +
  • From fd43b4245f40378df3d2cf63b722a15724c8e42e Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Tue, 4 Mar 2025 09:56:41 +0000 Subject: [PATCH 10/10] feat: adds new experimental report --- templates/reporting/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/reporting/index.html b/templates/reporting/index.html index 811fe98..32c05d7 100644 --- a/templates/reporting/index.html +++ b/templates/reporting/index.html @@ -166,7 +166,7 @@
    • For each article lists the review assignments, reviewer information and status.
  • - Time to First Decision + Time to First Decision
    • For each article submitted in a given time period displays a time to first decision.