From 04b87a4e74db3a0d7372f9ec6abe683f2dc087fe Mon Sep 17 00:00:00 2001 From: rubenwardy Date: Fri, 17 May 2024 18:52:55 +0100 Subject: [PATCH] Add approval stats page --- app/blueprints/admin/__init__.py | 2 +- app/blueprints/admin/approval_stats.py | 77 ++++++++++ app/logic/approval_stats.py | 148 ++++++++++++++++++++ app/templates/admin/approval_stats.html | 116 +++++++++++++++ app/templates/admin/list.html | 4 + app/tests/unit/logic/test_approval_stats.py | 96 +++++++++++++ 6 files changed, 442 insertions(+), 1 deletion(-) create mode 100644 app/blueprints/admin/approval_stats.py create mode 100644 app/logic/approval_stats.py create mode 100644 app/templates/admin/approval_stats.html create mode 100644 app/tests/unit/logic/test_approval_stats.py 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

+ + + + + + + {% for name, count in stats.editor_approvals.items() | sort(attribute=1, reverse=True) %} + + + + + {% endfor %} +
NameCount
{{ name }}{{ count }}
+ + +

Packages

+ + + + + + + + + + {% for name, info in stats.packages_info.items() | sort(attribute="1", reverse=True) %} + {% set parts = name.split("/") %} + + + + + + + + {% endfor %} +
NameFirst submittedApproved atTime waiting for reviewTime to approve
{{ 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 %} +
+ + +

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