Stats: Add ability to select date range

This commit is contained in:
rubenwardy 2023-06-14 22:47:08 +01:00
parent 516361345e
commit 80c42637df
9 changed files with 102 additions and 22 deletions

@ -23,7 +23,6 @@ from flask import request, jsonify, current_app, Response
from flask_login import current_user, login_required
from sqlalchemy.orm import joinedload
from sqlalchemy.sql.expression import func
from werkzeug.datastructures import ResponseCacheControl
from app import csrf
from app.logic.graphs import get_package_stats, get_package_stats_for_user, get_all_package_stats
@ -31,7 +30,7 @@ from app.markdown import render_markdown
from app.models import Tag, PackageState, PackageType, Package, db, PackageRelease, Permission, ForumTopic, \
MinetestRelease, APIToken, PackageScreenshot, License, ContentWarning, User, PackageReview, Thread
from app.querybuilder import QueryBuilder
from app.utils import is_package_page, get_int_or_abort, url_set_query, abs_url, isYes
from app.utils import is_package_page, get_int_or_abort, url_set_query, abs_url, isYes, get_request_date
from . import bp
from .auth import is_api_authd
from .support import error, api_create_vcs_release, api_create_zip_release, api_create_screenshot, \
@ -483,7 +482,9 @@ def list_all_reviews():
@cors_allowed
@cached(300)
def package_stats(package: Package):
return jsonify(get_package_stats(package))
start = get_request_date("start")
end = get_request_date("end")
return jsonify(get_package_stats(package, start, end))
@bp.route("/api/package_stats/")
@ -645,7 +646,9 @@ def user_stats(username: str):
if user is None:
error(404, "User not found")
return jsonify(get_package_stats_for_user(user))
start = get_request_date("start")
end = get_request_date("end")
return jsonify(get_package_stats_for_user(user, start, end))
@bp.route("/api/cdb_schema/")

@ -725,8 +725,12 @@ def game_support(package):
@bp.route("/packages/<author>/<name>/stats/")
@is_package_page
def statistics(package):
start = request.args.get("start")
end = request.args.get("end")
return render_template("packages/stats.html",
package=package, tabs=get_package_tabs(current_user, package), current_tab="stats")
package=package, tabs=get_package_tabs(current_user, package), current_tab="stats",
start=start, end=end, options=get_daterange_options(), noindex=start or end)
@bp.route("/packages/<author>/<name>/stats.csv")

@ -23,8 +23,8 @@ from flask_login import current_user, login_required
from sqlalchemy import func
from app.models import *
from app.tasks.forumtasks import checkForumAccount
from . import bp
from ...utils import get_daterange_options
@bp.route("/users/", methods=["GET"])
@ -249,4 +249,7 @@ def statistics(username):
downloads = db.session.query(func.sum(Package.downloads)).filter(Package.author==user).one()
return render_template("users/stats.html", user=user, downloads=downloads[0])
start = request.args.get("start")
end = request.args.get("end")
return render_template("users/stats.html", user=user, downloads=downloads[0],
start=start, end=end, options=get_daterange_options(), noindex=start or end)

@ -125,8 +125,11 @@ Tokens can be attained by visiting [Settings > API Tokens](/user/tokens/).
* Returns daily stats for package, or null if there is no data.
* Daily date is done based on the UTC timezone.
* EXPERIMENTAL. This API may change without warning.
* Query args:
* `start`: start date, inclusive. Optional. Default: 2022-10-01. UTC.
* `end`: end date, inclusive. Optional. Default: today. UTC.
* An object with the following keys:
* `start`: start date, inclusive. Ex: 2022-10-22.
* `start`: start date, inclusive. Ex: 2022-10-22. M
* `end`: end date, inclusive. Ex: 2022-11-05.
* `platform_minetest`: list of integers per day.
* `platform_other`: list of integers per day.
@ -374,6 +377,9 @@ Example:
* Returns daily stats for the user's packages, or null if there is no data.
* Daily date is done based on the UTC timezone.
* EXPERIMENTAL. This API may change without warning.
* Query args:
* `start`: start date, inclusive. Optional. Default: 2022-10-01. UTC.
* `end`: end date, inclusive. Optional. Default: today. UTC.
* A table with the following keys:
* `from`: start date, inclusive. Ex: 2022-10-22.
* `end`: end date, inclusive. Ex: 2022-11-05.

@ -41,24 +41,36 @@ def _flatten_data(stats):
return result
def get_package_stats(package: Package):
stats = package.daily_stats.order_by(db.asc(PackageDailyStats.date)).all()
def get_package_stats(package: Package, start_date: Optional[datetime.date], end_date: Optional[datetime.date]):
query = package.daily_stats.order_by(db.asc(PackageDailyStats.date))
if start_date:
query = query.filter(PackageDailyStats.date >= start_date)
if end_date:
query = query.filter(PackageDailyStats.date <= end_date)
stats = query.all()
if len(stats) == 0:
return None
return _flatten_data(stats)
def get_package_stats_for_user(user: User):
stats = db.session \
def get_package_stats_for_user(user: User, start_date: Optional[datetime.date], end_date: Optional[datetime.date]):
query = db.session \
.query(PackageDailyStats.date,
func.sum(PackageDailyStats.platform_minetest).label("platform_minetest"),
func.sum(PackageDailyStats.platform_other).label("platform_other"),
func.sum(PackageDailyStats.reason_new).label("reason_new"),
func.sum(PackageDailyStats.reason_dependency).label("reason_dependency"),
func.sum(PackageDailyStats.reason_update).label("reason_update")) \
.filter(PackageDailyStats.package.has(author_id=user.id)) \
.order_by(db.asc(PackageDailyStats.date)) \
.filter(PackageDailyStats.package.has(author_id=user.id))
if start_date:
query = query.filter(PackageDailyStats.date >= start_date)
if end_date:
query = query.filter(PackageDailyStats.date <= end_date)
stats = query.order_by(db.asc(PackageDailyStats.date)) \
.group_by(PackageDailyStats.date) \
.all()
if len(stats) == 0:
@ -122,9 +134,15 @@ def get_package_overview_for_user(user: Optional[User], start_date: datetime.dat
return result
def get_all_package_stats():
end_date = datetime.datetime.utcnow().date()
start_date = (datetime.datetime.utcnow() - datetime.timedelta(days=29)).date()
def get_all_package_stats(start_date: Optional[datetime.date], end_date: Optional[datetime.date]):
now_date = datetime.datetime.utcnow().date()
if end_date is None or end_date > now_date:
end_date = now_date
min_start_date = (datetime.datetime.utcnow() - datetime.timedelta(days=29)).date()
if start_date is None or start_date < min_start_date:
start_date = min_start_date
return {
"start": start_date.isoformat(),
"end": end_date.isoformat(),

@ -30,6 +30,20 @@
{% endmacro %}
{% macro render_daterange_selector(options) %}
<nav class="dropdown d-inline-block">
<button class="btn btn-secondary dropdown-toggle" type="button" id="dropdownDateRange" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
{{ _("Date range...") }}
</button>
<div class="dropdown-menu" aria-labelledby="dropdownDateRange">
{% for option in options %}
<a class="dropdown-item" href="{{ option[1] }}">{{ option[0] }}</a>
{% endfor %}
</div>
</nav>
{% endmacro %}
{% macro render_package_stats(source, downloads) %}
<noscript>
<p class="alert alert-danger">

@ -4,7 +4,8 @@
{{ _("Statistics") }} - {{ package.title }}
{% endblock %}
{% from "macros/stats.html" import render_package_stats, render_package_stats_js, render_package_selector %}
{% from "macros/stats.html" import render_package_stats, render_package_stats_js,
render_package_selector, render_daterange_selector with context %}
{% block scriptextra %}
{{ render_package_stats_js() }}
@ -16,8 +17,9 @@
<i class="fas fa-download mr-1"></i>
{{ _("Download (.csv)") }}
</a>
{{ render_daterange_selector(options) }}
{{ render_package_selector(package.author, package=package) }}
</div>
<h2 class="mt-0">{{ _("Statistics") }}</h2>
{{ render_package_stats(package.getURL('api.package_stats'), package.downloads) }}
{{ render_package_stats(package.getURL('api.package_stats', start=start, end=end), package.downloads) }}
{% endblock %}

@ -4,7 +4,8 @@
{{ _("Statistics for %(display_name)s's packages", display_name=user.display_name) }}
{% endblock %}
{% from "macros/stats.html" import render_package_stats, render_package_stats_js, render_package_selector %}
{% from "macros/stats.html" import render_package_stats, render_package_stats_js,
render_package_selector, render_daterange_selector with context %}
{% block scriptextra %}
{{ render_package_stats_js() }}
@ -12,8 +13,9 @@
{% block content %}
<div class="float-right">
{{ render_daterange_selector(options) }}
{{ render_package_selector(user, package=None) }}
</div>
<h2 class="mt-0">{{ self.title() }}</h2>
{{ render_package_stats(url_for("api.user_stats", username=user.username), downloads) }}
{{ render_package_stats(url_for("api.user_stats", username=user.username, start=start, end=end), downloads) }}
{% endblock %}

@ -14,11 +14,12 @@
# 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/>.
from urllib.parse import urljoin, urlparse, urlunparse
import typing
import user_agents
from flask import request, abort
from flask_babel import LazyString
from werkzeug.datastructures import MultiDict
from app.models import *
@ -88,6 +89,7 @@ def url_set_query(**kwargs):
return url_for(request.endpoint, **dargs)
def get_int_or_abort(v, default=None):
if v is None:
return default
@ -97,6 +99,7 @@ def get_int_or_abort(v, default=None):
except ValueError:
abort(400)
def is_user_bot():
user_agent = request.headers.get('User-Agent')
if user_agent is None:
@ -104,3 +107,28 @@ def is_user_bot():
user_agent = user_agents.parse(user_agent)
return user_agent.is_bot
def get_request_date(key: str) -> typing.Optional[datetime.date]:
val = request.args.get(key)
if val is None:
return None
try:
return datetime.datetime.strptime(val, "%Y-%m-%d").date()
except ValueError:
abort(400)
def get_daterange_options() -> List[Tuple[LazyString, str]]:
now = datetime.datetime.utcnow().date()
days30 = (datetime.datetime.utcnow() - datetime.timedelta(days=30)).date()
days90 = (datetime.datetime.utcnow() - datetime.timedelta(days=90)).date()
year_start = datetime.date(now.year, 1, 1)
return [
(lazy_gettext("All time"), url_set_query(start="2022-10-23", end=now.isoformat())),
(lazy_gettext("Last 30 days"), url_set_query(start=days30.isoformat(), end=now.isoformat())),
(lazy_gettext("Last 90 days"), url_set_query(start=days90.isoformat(), end=now.isoformat())),
(lazy_gettext("Year to date"), url_set_query(start=year_start, end=now.isoformat())),
]