mirror of
https://github.com/minetest/contentdb.git
synced 2025-01-03 03:37:28 +01:00
Add approval stats page
This commit is contained in:
parent
a920854796
commit
04b87a4e74
@ -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
|
||||
|
77
app/blueprints/admin/approval_stats.py
Normal file
77
app/blueprints/admin/approval_stats.py
Normal file
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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,
|
||||
},
|
||||
})
|
148
app/logic/approval_stats.py
Normal file
148
app/logic/approval_stats.py
Normal file
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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)
|
116
app/templates/admin/approval_stats.html
Normal file
116
app/templates/admin/approval_stats.html
Normal file
@ -0,0 +1,116 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}
|
||||
Approval Stats
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{{ self.title() }}</h1>
|
||||
|
||||
<p class="float-end">
|
||||
<a href="{{ url_for('admin.approval_stats', start='2020-07-01') }}">Since Aug 2020</a> |
|
||||
<a href="{{ url_for('admin.approval_stats') }}">Last 365 days</a>
|
||||
</p>
|
||||
|
||||
{% if start or end %}
|
||||
<p>
|
||||
From {{ start.date() }} to {{ end.date() }}.
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="row mb-5">
|
||||
<div class="col-md-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-body align-items-center text-center">
|
||||
<div class="mt-0 mb-3">
|
||||
Total packages submitted
|
||||
</div>
|
||||
<div class="my-0 h4">
|
||||
{{ stats.packages_info | length }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-body align-items-center text-center">
|
||||
<div class="mt-0 mb-3">
|
||||
Average turnaround time
|
||||
</div>
|
||||
<div class="my-0 h4">
|
||||
{{ (stats.avg_turnaround_time / (60*60*24)) | round(1) }} days
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-body align-items-center text-center">
|
||||
<div class="mt-0 mb-3">
|
||||
Max turnaround time
|
||||
</div>
|
||||
<div class="my-0 h4">
|
||||
{{ (stats.max_turnaround_time / (60*60*24)) | round(1) }} days
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-muted">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<h2>Editor Approvals</h2>
|
||||
|
||||
<table class="table">
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Count</th>
|
||||
</tr>
|
||||
{% for name, count in stats.editor_approvals.items() | sort(attribute=1, reverse=True) %}
|
||||
<tr>
|
||||
<td>{{ name }}</td>
|
||||
<td>{{ count }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
|
||||
|
||||
<h2>Packages</h2>
|
||||
|
||||
<table class="table">
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>First submitted</th>
|
||||
<th>Approved at</th>
|
||||
<th>Time waiting for review</th>
|
||||
<th>Time to approve</th>
|
||||
</tr>
|
||||
{% for name, info in stats.packages_info.items() | sort(attribute="1", reverse=True) %}
|
||||
{% set parts = name.split("/") %}
|
||||
<tr>
|
||||
<td><a href="{{ url_for('packages.audit', author=parts[0], name=parts[1]) }}">{{ name }}</a></td>
|
||||
<td>{{ info.first_submitted.date() }}</td>
|
||||
<td>{% if info.approved_at %}{{ info.approved_at.date() }}{% endif %}</td>
|
||||
<td>{{ (info.wait_time / (60*60*24)) | round(1) }}</td>
|
||||
<td>
|
||||
{% set approval_time = info.total_approval_time %}
|
||||
{% if approval_time >= 0 %}
|
||||
{{ (approval_time / (60*60*24)) | round(1) }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
|
||||
|
||||
<h2>Export</h2>
|
||||
<p>
|
||||
<a href="{{ url_for('admin.approval_stats_json') }}">approval_stats.json</a>
|
||||
</p>
|
||||
|
||||
{% endblock %}
|
@ -41,6 +41,10 @@
|
||||
<i class="fas fa-search me-2"></i>
|
||||
Zip grep
|
||||
</a>
|
||||
<a class="list-group-item list-group-item-action" href="{{ url_for('admin.approval_stats') }}">
|
||||
<i class="fas fa-chart-line me-2"></i>
|
||||
Approval stats
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<h3>Types</h3>
|
||||
|
96
app/tests/unit/logic/test_approval_stats.py
Normal file
96
app/tests/unit/logic/test_approval_stats.py
Normal file
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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
|
Loading…
Reference in New Issue
Block a user