diff --git a/app/blueprints/admin/__init__.py b/app/blueprints/admin/__init__.py
index cc8e041b..f5a787bb 100644
--- a/app/blueprints/admin/__init__.py
+++ b/app/blueprints/admin/__init__.py
@@ -19,4 +19,4 @@ from flask import Blueprint
bp = Blueprint("admin", __name__)
-from . import admin, audit, licenseseditor, tagseditor, versioneditor, warningseditor, languageseditor, email
+from . import admin, audit, licenseseditor, tagseditor, versioneditor, warningseditor, languageseditor, email, approval_stats
diff --git a/app/blueprints/admin/approval_stats.py b/app/blueprints/admin/approval_stats.py
new file mode 100644
index 00000000..a4e996c6
--- /dev/null
+++ b/app/blueprints/admin/approval_stats.py
@@ -0,0 +1,77 @@
+# ContentDB
+# Copyright (C) 2024 rubenwardy
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+import datetime
+from flask import render_template, request, abort, redirect, url_for, jsonify
+
+from . import bp
+from app.logic.approval_stats import get_approval_statistics
+from app.models import UserRank
+from app.utils import rank_required
+
+
+@bp.route("/admin/approval_stats/")
+@rank_required(UserRank.APPROVER)
+def approval_stats():
+ start = request.args.get("start")
+ end = request.args.get("end")
+ if start and end:
+ try:
+ start = datetime.datetime.fromisoformat(start)
+ end = datetime.datetime.fromisoformat(end)
+ except ValueError:
+ abort(400)
+ elif start:
+ return redirect(url_for("admin.approval_stats", start=start, end=datetime.datetime.utcnow().date().isoformat()))
+ elif end:
+ return redirect(url_for("admin.approval_stats", start="2020-07-01", end=end))
+ else:
+ end = datetime.datetime.utcnow()
+ start = end - datetime.timedelta(days=365)
+
+ stats = get_approval_statistics(start, end)
+ return render_template("admin/approval_stats.html", stats=stats, start=start, end=end)
+
+
+@bp.route("/admin/approval_stats.json")
+@rank_required(UserRank.APPROVER)
+def approval_stats_json():
+ start = request.args.get("start")
+ end = request.args.get("end")
+ if start and end:
+ try:
+ start = datetime.datetime.fromisoformat(start)
+ end = datetime.datetime.fromisoformat(end)
+ except ValueError:
+ abort(400)
+ else:
+ end = datetime.datetime.utcnow()
+ start = end - datetime.timedelta(days=365)
+
+ stats = get_approval_statistics(start, end)
+ for key, value in stats.packages_info.items():
+ stats.packages_info[key] = value.__dict__()
+
+ return jsonify({
+ "start": start.isoformat(),
+ "end": end.isoformat(),
+ "editor_approvals": stats.editor_approvals,
+ "packages_info": stats.packages_info,
+ "turnaround_time": {
+ "avg": stats.avg_turnaround_time,
+ "max": stats.max_turnaround_time,
+ },
+ })
diff --git a/app/logic/approval_stats.py b/app/logic/approval_stats.py
new file mode 100644
index 00000000..45e0a955
--- /dev/null
+++ b/app/logic/approval_stats.py
@@ -0,0 +1,148 @@
+# ContentDB
+# Copyright (C) 2024 rubenwardy
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+import datetime
+from collections import namedtuple, defaultdict
+from typing import Dict, Optional
+from sqlalchemy import or_
+
+from app.models import AuditLogEntry, db, PackageState
+
+
+class PackageInfo:
+ state: Optional[PackageState]
+ first_submitted: Optional[datetime.datetime]
+ last_change: Optional[datetime.datetime]
+ approved_at: Optional[datetime.datetime]
+ wait_time: int
+ total_approval_time: int
+ is_in_range: bool
+ events: list[tuple[str, str, str]]
+
+ def __init__(self):
+ self.state = None
+ self.first_submitted = None
+ self.last_change = None
+ self.approved_at = None
+ self.wait_time = 0
+ self.total_approval_time = -1
+ self.is_in_range = False
+ self.events = []
+
+ def __lt__(self, other):
+ return self.wait_time < other.wait_time
+
+ def __dict__(self):
+ return {
+ "first_submitted": self.first_submitted.isoformat(),
+ "last_change": self.last_change.isoformat(),
+ "approved_at": self.approved_at.isoformat() if self.approved_at else None,
+ "wait_time": self.wait_time,
+ "total_approval_time": self.total_approval_time if self.total_approval_time >= 0 else None,
+ "events": [ { "date": x[0], "by": x[1], "title": x[2] } for x in self.events ],
+ }
+
+ def add_event(self, created_at: datetime.datetime, causer: str, title: str):
+ self.events.append((created_at.isoformat(), causer, title))
+
+
+def get_state(title: str):
+ if title.startswith("Approved "):
+ return PackageState.APPROVED
+
+ assert title.startswith("Marked ")
+
+ for state in PackageState:
+ if state.value in title:
+ return state
+
+ if "Work in Progress" in title:
+ return PackageState.WIP
+
+ raise Exception(f"Unable to get state for title {title}")
+
+
+Result = namedtuple("Result", "editor_approvals packages_info avg_turnaround_time max_turnaround_time")
+
+
+def _get_approval_statistics(entries: list[AuditLogEntry], start_date: Optional[datetime.datetime] = None, end_date: Optional[datetime.datetime] = None) -> Result:
+ editor_approvals = defaultdict(int)
+ package_info: Dict[str, PackageInfo] = {}
+ ignored_packages = set()
+ turnaround_times: list[int] = []
+
+ for entry in entries:
+ package_id = str(entry.package.get_id())
+ if package_id in ignored_packages:
+ continue
+
+ info = package_info.get(package_id, PackageInfo())
+ package_info[package_id] = info
+
+ is_in_range = (((start_date is None or entry.created_at >= start_date) and
+ (end_date is None or entry.created_at <= end_date)))
+ info.is_in_range = info.is_in_range or is_in_range
+
+ new_state = get_state(entry.title)
+ if new_state == info.state:
+ continue
+
+ info.add_event(entry.created_at, entry.causer.username if entry.causer else None, new_state.value)
+
+ if info.state == PackageState.READY_FOR_REVIEW:
+ seconds = int((entry.created_at - info.last_change).total_seconds())
+ info.wait_time += seconds
+ if is_in_range:
+ turnaround_times.append(seconds)
+
+ if new_state == PackageState.APPROVED:
+ ignored_packages.add(package_id)
+ info.approved_at = entry.created_at
+ if is_in_range:
+ editor_approvals[entry.causer.username] += 1
+ if info.first_submitted is not None:
+ info.total_approval_time = int((entry.created_at - info.first_submitted).total_seconds())
+ elif new_state == PackageState.READY_FOR_REVIEW:
+ if info.first_submitted is None:
+ info.first_submitted = entry.created_at
+
+ info.state = new_state
+ info.last_change = entry.created_at
+
+ packages_info_2 = {}
+ package_count = 0
+ for package_id, info in package_info.items():
+ if info.first_submitted and info.is_in_range:
+ package_count += 1
+ packages_info_2[package_id] = info
+
+ if len(turnaround_times) > 0:
+ avg_turnaround_time = sum(turnaround_times) / len(turnaround_times)
+ max_turnaround_time = max(turnaround_times)
+ else:
+ avg_turnaround_time = 0
+ max_turnaround_time = 0
+
+ return Result(editor_approvals, packages_info_2, avg_turnaround_time, max_turnaround_time)
+
+
+def get_approval_statistics(start_date: Optional[datetime.datetime] = None, end_date: Optional[datetime.datetime] = None) -> Result:
+ entries = AuditLogEntry.query.filter(AuditLogEntry.package).filter(or_(
+ AuditLogEntry.title.like("Approved %"),
+ AuditLogEntry.title.like("Marked %"))
+ ).order_by(db.asc(AuditLogEntry.created_at)).all()
+
+ return _get_approval_statistics(entries, start_date, end_date)
diff --git a/app/templates/admin/approval_stats.html b/app/templates/admin/approval_stats.html
new file mode 100644
index 00000000..2e7097c3
--- /dev/null
+++ b/app/templates/admin/approval_stats.html
@@ -0,0 +1,116 @@
+{% extends "base.html" %}
+
+{% block title %}
+ Approval Stats
+{% endblock %}
+
+{% block content %}
+
{{ self.title() }}
+
+
+ Since Aug 2020 |
+ Last 365 days
+
+
+{% if start or end %}
+
+ From {{ start.date() }} to {{ end.date() }}.
+
+{% endif %}
+
+
+
+
+
+
+ Total packages submitted
+
+
+ {{ stats.packages_info | length }}
+
+
+
+
+
+
+
+
+
+ Average turnaround time
+
+
+ {{ (stats.avg_turnaround_time / (60*60*24)) | round(1) }} days
+
+
+
+
+
+
+
+
+
+ Max turnaround time
+
+
+ {{ (stats.max_turnaround_time / (60*60*24)) | round(1) }} days
+
+
+
+
+
+
+
+ Turnaround: how long after clicking "Ready for Review" does the package receive a response. A package can be marked
+ as ready for review multiple times.
+
+
+Editor Approvals
+
+
+
+ Name |
+ Count |
+
+ {% for name, count in stats.editor_approvals.items() | sort(attribute=1, reverse=True) %}
+
+ {{ name }} |
+ {{ count }} |
+
+ {% endfor %}
+
+
+
+Packages
+
+
+
+ Name |
+ First submitted |
+ Approved at |
+ Time waiting for review |
+ Time to approve |
+
+ {% for name, info in stats.packages_info.items() | sort(attribute="1", reverse=True) %}
+ {% set parts = name.split("/") %}
+
+ {{ name }} |
+ {{ info.first_submitted.date() }} |
+ {% if info.approved_at %}{{ info.approved_at.date() }}{% endif %} |
+ {{ (info.wait_time / (60*60*24)) | round(1) }} |
+
+ {% set approval_time = info.total_approval_time %}
+ {% if approval_time >= 0 %}
+ {{ (approval_time / (60*60*24)) | round(1) }}
+ {% endif %}
+ |
+
+ {% endfor %}
+
+
+
+Export
+
+ approval_stats.json
+
+
+{% endblock %}
diff --git a/app/templates/admin/list.html b/app/templates/admin/list.html
index 4aaac1e7..73ad8b63 100644
--- a/app/templates/admin/list.html
+++ b/app/templates/admin/list.html
@@ -41,6 +41,10 @@
Zip grep
+
+
+ Approval stats
+
Types
diff --git a/app/tests/unit/logic/test_approval_stats.py b/app/tests/unit/logic/test_approval_stats.py
new file mode 100644
index 00000000..b15ab2f9
--- /dev/null
+++ b/app/tests/unit/logic/test_approval_stats.py
@@ -0,0 +1,96 @@
+# ContentDB
+# Copyright (C) 2024 rubenwardy
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+import datetime
+from app.logic.approval_stats import _get_approval_statistics
+from app.models import AuditLogEntry, User
+
+
+class MockPackage:
+ def __init__(self, id_: str):
+ self.id_ = id_
+
+ def get_id(self):
+ return self.id_
+
+
+class MockEntry:
+ def __init__(self, date: str, package_id: str, username: str, title: str):
+ causer = User()
+ causer.username = username
+
+ self.causer = causer
+ self.created_at = datetime.datetime.strptime(date, "%Y-%m-%dT%H:%M:%S%z")
+ self.title = title
+ self.package = MockPackage(package_id)
+
+
+# noinspection PyTypeChecker
+def make_entry(date: str, package_id: str, username: str, title: str) -> AuditLogEntry:
+ return MockEntry(date, package_id, username, title)
+
+
+def test_empty():
+ stats = _get_approval_statistics([])
+ assert not stats.editor_approvals
+ assert not stats.packages_info
+ assert stats.avg_turnaround_time == 0
+
+
+def test_package_simple():
+ package_1 = "user1/one"
+ stats = _get_approval_statistics([
+ make_entry("2023-04-03T12:32:00Z", package_1, "user1", "Marked Title as Ready for Review"),
+ make_entry("2023-04-06T12:32:00Z", package_1, "reviewer", "Marked Title as Changes Needed"),
+ make_entry("2023-04-07T12:32:00Z", package_1, "user1", "Marked Title as Ready for Review"),
+ make_entry("2023-04-08T12:32:00Z", package_1, "reviewer", "Approved Title"),
+ ])
+
+ assert stats.packages_info[package_1].total_approval_time == 5*60*60*24
+ assert stats.packages_info[package_1].wait_time == 4*60*60*24
+
+
+def test_average_turnaround():
+ package_1 = "user1/one"
+ package_2 = "user2/two"
+ stats = _get_approval_statistics([
+ make_entry("2023-04-03T12:00:00Z", package_1, "user1", "Marked Title as Ready for Review"),
+ make_entry("2023-04-03T18:00:00Z", package_2, "user2", "Marked Title as Ready for Review"),
+ make_entry("2023-04-06T12:00:00Z", package_1, "reviewer", "Marked Title as Changes Needed"),
+ make_entry("2023-04-07T12:00:00Z", package_1, "user1", "Marked Title as Ready for Review"),
+ make_entry("2023-04-08T12:00:00Z", package_1, "reviewer", "Approved Title"),
+ make_entry("2023-04-10T18:00:00Z", package_2, "reviewer", "Approved Title"),
+ ])
+
+ assert stats.avg_turnaround_time == 316800
+
+
+def test_editor_counts():
+ package_1 = "user1/one"
+ package_2 = "user1/two"
+ package_3 = "user1/three"
+ stats = _get_approval_statistics([
+ make_entry("2023-04-03T12:00:00Z", package_1, "user1", "Marked Title as Ready for Review"),
+ make_entry("2023-04-03T12:00:00Z", package_2, "user1", "Marked Title as Ready for Review"),
+ make_entry("2023-04-03T12:00:00Z", package_3, "user1", "Marked Title as Ready for Review"),
+ make_entry("2023-04-08T12:00:00Z", package_1, "reviewer", "Approved Title"),
+ make_entry("2023-04-10T18:00:00Z", package_2, "reviewer", "Approved Title"),
+ make_entry("2023-04-11T18:00:00Z", package_3, "reviewer2", "Approved Title"),
+ ])
+
+ assert len(stats.editor_approvals) == 2
+ assert stats.editor_approvals["reviewer"] == 2
+ assert stats.editor_approvals["reviewer2"] == 1