diff --git a/otterwiki/statistics.py b/otterwiki/statistics.py new file mode 100644 index 00000000..95710b90 --- /dev/null +++ b/otterwiki/statistics.py @@ -0,0 +1,53 @@ +from datetime import datetime, timedelta +from typing import List, Tuple + +from otterwiki.gitstorage import GitStorage + + +class StatisticsService: + def __init__(self, repository_path: str, top_limit: int = 10): + self.storage = GitStorage(repository_path) + self.top_limit = top_limit + + def _aggregate_commits( + self, + log_entries: List[dict], + since_days: int | None = None, + ) -> List[Tuple[str, int]]: + + stats = {} + now = datetime.now().astimezone() + + for entry in log_entries: + if since_days is not None: + if entry["datetime"] < now - timedelta(days=since_days): + continue + + author = entry["author_name"] + stats[author] = stats.get(author, 0) + 1 + + sorted_stats = sorted(stats.items(), key=lambda x: x[1], reverse=True) + + # If within limit, return directly + if len(sorted_stats) <= self.top_limit: + return sorted_stats + + top_entries = sorted_stats[: self.top_limit] + remainder = sorted_stats[self.top_limit :] + + others_count = sum(count for _, count in remainder) + + top_entries.append(("Others", others_count)) + + return top_entries + + def commit_statistics( + self, + since_days: int | None = None, + ) -> List[Tuple[str, int]]: + """ + Returns aggregated commit counts per author. + Only top contributors are shown; remainder grouped as 'Others'. + """ + log_entries = self.storage.log() + return self._aggregate_commits(log_entries, since_days) diff --git a/otterwiki/templates/admin/statistics.html b/otterwiki/templates/admin/statistics.html new file mode 100644 index 00000000..0e528f0d --- /dev/null +++ b/otterwiki/templates/admin/statistics.html @@ -0,0 +1,34 @@ +{% extends "settings.html" %} + +{% block title %} + Contribution Dashboard +{% endblock %} + +{% block js %} +{{ super() }} +{% set force_load_libraries = true %} +{% include 'snippets/renderer_js.html' %} +{% endblock %} + + +{% block content %} +
+ +
+pie showData + title All Commits +{% for author, count in all_commits %} + "{{ author }}" : {{ count }} +{% endfor %} +
+ +
+pie showData + title Last 30 Days +{% for author, count in last_30_days %} + "{{ author }}" : {{ count }} +{% endfor %} +
+ +
+{% endblock %} diff --git a/otterwiki/templates/settings.html b/otterwiki/templates/settings.html index d2ae5b00..ad581324 100644 --- a/otterwiki/templates/settings.html +++ b/otterwiki/templates/settings.html @@ -58,6 +58,12 @@ Mail Preferences + + + + + Statistics + {% endif %} {% endblock %} {% block content %} diff --git a/otterwiki/views.py b/otterwiki/views.py index bb38f962..f73987cd 100644 --- a/otterwiki/views.py +++ b/otterwiki/views.py @@ -26,6 +26,7 @@ import otterwiki.auth import otterwiki.preferences import otterwiki.tools +from otterwiki.statistics import StatisticsService from otterwiki.renderer import render from otterwiki.helper import ( toast, @@ -36,6 +37,7 @@ from otterwiki.util import sanitize_pagename from flask_login import login_required +from flask import current_app # @@ -248,6 +250,25 @@ def admin_mail_preferences(): return otterwiki.preferences.handle_mail_preferences(request.form) +@app.route("/-/admin/statistics") +@login_required +def admin_statistics(): + + service = StatisticsService(current_app.config["REPOSITORY"]) + + try: + all_commits = service.commit_statistics() + last_30_days = service.commit_statistics(since_days=30) + except Exception as e: + return f"Git log failed: {e}", 500 + + return render_template( + "admin/statistics.html", + all_commits=all_commits, + last_30_days=last_30_days, + ) + + @app.route( "/-/admin", methods=["POST", "GET"] ) # pyright: ignore -- false positive