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
app
blueprints
flatpages/help
logic
templates
utils

@ -23,7 +23,6 @@ from flask import request, jsonify, current_app, Response
from flask_login import current_user, login_required from flask_login import current_user, login_required
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
from sqlalchemy.sql.expression import func from sqlalchemy.sql.expression import func
from werkzeug.datastructures import ResponseCacheControl
from app import csrf from app import csrf
from app.logic.graphs import get_package_stats, get_package_stats_for_user, get_all_package_stats 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, \ from app.models import Tag, PackageState, PackageType, Package, db, PackageRelease, Permission, ForumTopic, \
MinetestRelease, APIToken, PackageScreenshot, License, ContentWarning, User, PackageReview, Thread MinetestRelease, APIToken, PackageScreenshot, License, ContentWarning, User, PackageReview, Thread
from app.querybuilder import QueryBuilder 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 . import bp
from .auth import is_api_authd from .auth import is_api_authd
from .support import error, api_create_vcs_release, api_create_zip_release, api_create_screenshot, \ 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 @cors_allowed
@cached(300) @cached(300)
def package_stats(package: Package): 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/") @bp.route("/api/package_stats/")
@ -645,7 +646,9 @@ def user_stats(username: str):
if user is None: if user is None:
error(404, "User not found") 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/") @bp.route("/api/cdb_schema/")

@ -725,8 +725,12 @@ def game_support(package):
@bp.route("/packages/<author>/<name>/stats/") @bp.route("/packages/<author>/<name>/stats/")
@is_package_page @is_package_page
def statistics(package): def statistics(package):
start = request.args.get("start")
end = request.args.get("end")
return render_template("packages/stats.html", 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") @bp.route("/packages/<author>/<name>/stats.csv")

@ -23,8 +23,8 @@ from flask_login import current_user, login_required
from sqlalchemy import func from sqlalchemy import func
from app.models import * from app.models import *
from app.tasks.forumtasks import checkForumAccount
from . import bp from . import bp
from ...utils import get_daterange_options
@bp.route("/users/", methods=["GET"]) @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() 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. * Returns daily stats for package, or null if there is no data.
* Daily date is done based on the UTC timezone. * Daily date is done based on the UTC timezone.
* EXPERIMENTAL. This API may change without warning. * 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: * 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. * `end`: end date, inclusive. Ex: 2022-11-05.
* `platform_minetest`: list of integers per day. * `platform_minetest`: list of integers per day.
* `platform_other`: 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. * Returns daily stats for the user's packages, or null if there is no data.
* Daily date is done based on the UTC timezone. * Daily date is done based on the UTC timezone.
* EXPERIMENTAL. This API may change without warning. * 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: * A table with the following keys:
* `from`: start date, inclusive. Ex: 2022-10-22. * `from`: start date, inclusive. Ex: 2022-10-22.
* `end`: end date, inclusive. Ex: 2022-11-05. * `end`: end date, inclusive. Ex: 2022-11-05.

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

@ -30,6 +30,20 @@
{% endmacro %} {% 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) %} {% macro render_package_stats(source, downloads) %}
<noscript> <noscript>
<p class="alert alert-danger"> <p class="alert alert-danger">

@ -4,7 +4,8 @@
{{ _("Statistics") }} - {{ package.title }} {{ _("Statistics") }} - {{ package.title }}
{% endblock %} {% 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 %} {% block scriptextra %}
{{ render_package_stats_js() }} {{ render_package_stats_js() }}
@ -16,8 +17,9 @@
<i class="fas fa-download mr-1"></i> <i class="fas fa-download mr-1"></i>
{{ _("Download (.csv)") }} {{ _("Download (.csv)") }}
</a> </a>
{{ render_daterange_selector(options) }}
{{ render_package_selector(package.author, package=package) }} {{ render_package_selector(package.author, package=package) }}
</div> </div>
<h2 class="mt-0">{{ _("Statistics") }}</h2> <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 %} {% endblock %}

@ -4,7 +4,8 @@
{{ _("Statistics for %(display_name)s's packages", display_name=user.display_name) }} {{ _("Statistics for %(display_name)s's packages", display_name=user.display_name) }}
{% endblock %} {% 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 %} {% block scriptextra %}
{{ render_package_stats_js() }} {{ render_package_stats_js() }}
@ -12,8 +13,9 @@
{% block content %} {% block content %}
<div class="float-right"> <div class="float-right">
{{ render_daterange_selector(options) }}
{{ render_package_selector(user, package=None) }} {{ render_package_selector(user, package=None) }}
</div> </div>
<h2 class="mt-0">{{ self.title() }}</h2> <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 %} {% endblock %}

@ -14,11 +14,12 @@
# You should have received a copy of the GNU Affero General Public License # 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/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from urllib.parse import urljoin, urlparse, urlunparse from urllib.parse import urljoin, urlparse, urlunparse
import typing
import user_agents import user_agents
from flask import request, abort from flask import request, abort
from flask_babel import LazyString
from werkzeug.datastructures import MultiDict from werkzeug.datastructures import MultiDict
from app.models import * from app.models import *
@ -88,6 +89,7 @@ def url_set_query(**kwargs):
return url_for(request.endpoint, **dargs) return url_for(request.endpoint, **dargs)
def get_int_or_abort(v, default=None): def get_int_or_abort(v, default=None):
if v is None: if v is None:
return default return default
@ -97,6 +99,7 @@ def get_int_or_abort(v, default=None):
except ValueError: except ValueError:
abort(400) abort(400)
def is_user_bot(): def is_user_bot():
user_agent = request.headers.get('User-Agent') user_agent = request.headers.get('User-Agent')
if user_agent is None: if user_agent is None:
@ -104,3 +107,28 @@ def is_user_bot():
user_agent = user_agents.parse(user_agent) user_agent = user_agents.parse(user_agent)
return user_agent.is_bot 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())),
]