mirror of
https://github.com/minetest/contentdb.git
synced 2025-03-26 11:52:37 +01:00
Stats: Add ability to select date range
This commit is contained in:
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())),
|
||||||
|
]
|
||||||
|
Reference in New Issue
Block a user