diff --git a/app/blueprints/api/endpoints.py b/app/blueprints/api/endpoints.py index 375bc51d..73e6566f 100644 --- a/app/blueprints/api/endpoints.py +++ b/app/blueprints/api/endpoints.py @@ -30,7 +30,7 @@ from app.markdown import render_markdown from app.models import Tag, PackageState, PackageType, Package, db, PackageRelease, Permission, \ MinetestRelease, APIToken, PackageScreenshot, License, ContentWarning, User, PackageReview, Thread, Collection, \ - PackageAlias, Language + PackageAlias, Language, PackageDailyStats from app.querybuilder import QueryBuilder from app.utils import is_package_page, get_int_or_abort, url_set_query, abs_url, is_yes, get_request_date, cached, \ cors_allowed @@ -39,6 +39,7 @@ from .auth import is_api_authd from .support import error, api_create_vcs_release, api_create_zip_release, api_create_screenshot, \ api_order_screenshots, api_edit_package, api_set_cover_image +from ...rediscache import make_view_key, set_temp_key, has_key @bp.route("/api/packages/") @@ -99,6 +100,14 @@ def package_view(package): @is_package_page @cors_allowed def package_view_client(package: Package): + ip = request.headers.get("X-Forwarded-For") or request.remote_addr + if ip is not None and (request.headers.get("User-Agent") or "").startswith("Minetest"): + key = make_view_key(ip, package) + if not has_key(key): + set_temp_key(key, "true") + PackageDailyStats.notify_view(package) + db.session.commit() + protocol_version = request.args.get("protocol_version") engine_version = request.args.get("engine_version") if protocol_version or engine_version: diff --git a/app/blueprints/packages/releases.py b/app/blueprints/packages/releases.py index d0625c6a..80ae0876 100644 --- a/app/blueprints/packages/releases.py +++ b/app/blueprints/packages/releases.py @@ -129,7 +129,7 @@ def download_release(package, id): if ip is not None and not is_user_bot(): is_minetest = (request.headers.get("User-Agent") or "").startswith("Minetest") reason = request.args.get("reason") - PackageDailyStats.update(package, is_minetest, reason) + PackageDailyStats.notify_download(package, is_minetest, reason) key = make_download_key(ip, release.package) if not has_key(key): diff --git a/app/flatpages/help/api.md b/app/flatpages/help/api.md index a5e6e9dc..f5a7f499 100644 --- a/app/flatpages/help/api.md +++ b/app/flatpages/help/api.md @@ -157,6 +157,7 @@ curl -X DELETE https://content.minetest.net/api/delete-token/ \ * `reason_new`: list of integers per day. * `reason_dependency`: list of integers per day. * `reason_update`: list of integers per day. + * `views_minetest`: list of integers per day. * GET `/api/package_stats/` * Returns last 30 days of daily stats for _all_ packages. * An object with the following keys: @@ -437,6 +438,7 @@ Example: * `reason_new`: list of integers per day. * `reason_dependency`: list of integers per day. * `reason_update`: list of integers per day. + * `views_minetest`: list of integers per day. ## Topics diff --git a/app/logic/graphs.py b/app/logic/graphs.py index b464568a..6dc85fbc 100644 --- a/app/logic/graphs.py +++ b/app/logic/graphs.py @@ -28,7 +28,7 @@ def daterange(start_date, end_date): keys = ["platform_minetest", "platform_other", "reason_new", - "reason_dependency", "reason_update"] + "reason_dependency", "reason_update", "views_minetest"] def flatten_data(stats): @@ -78,7 +78,8 @@ def get_package_stats_for_user(user: User, start_date: Optional[datetime.date], 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")) \ + func.sum(PackageDailyStats.reason_update).label("reason_update"), + func.sum(PackageDailyStats.views_minetest).label("views_minetest")) \ .filter(PackageDailyStats.package.has(author_id=user.id)) if start_date: diff --git a/app/models/packages.py b/app/models/packages.py index 271acf04..a81ba072 100644 --- a/app/models/packages.py +++ b/app/models/packages.py @@ -1437,8 +1437,10 @@ class PackageDailyStats(db.Model): reason_dependency = db.Column(db.Integer, nullable=False, default=0) reason_update = db.Column(db.Integer, nullable=False, default=0) + views_minetest = db.Column(db.Integer, nullable=False, default=0) + @staticmethod - def update(package: Package, is_minetest: bool, reason: str): + def notify_download(package: Package, is_minetest: bool, reason: str): date = datetime.datetime.utcnow().date() to_update = dict() @@ -1470,3 +1472,19 @@ def update(package: Package, is_minetest: bool, reason: str): conn = db.session.connection() conn.execute(stmt) + + @staticmethod + def notify_view(package: Package): + date = datetime.datetime.utcnow().date() + + to_update = {"views_minetest": PackageDailyStats.views_minetest + 1} + kwargs = {"package_id": package.id, "date": date, "views_minetest": 1} + + stmt = insert(PackageDailyStats).values(**kwargs) + stmt = stmt.on_conflict_do_update( + index_elements=[PackageDailyStats.package_id, PackageDailyStats.date], + set_=to_update + ) + + conn = db.session.connection() + conn.execute(stmt) diff --git a/app/public/static/js/package_charts.js b/app/public/static/js/package_charts.js index 378e4005..30c65de5 100644 --- a/app/public/static/js/package_charts.js +++ b/app/public/static/js/package_charts.js @@ -228,6 +228,16 @@ async function load_data() { }; new Chart(ctx, config); } + + { + const ctx = document.getElementById("chart-views").getContext("2d"); + const data = { + datasets: [ + { label: "Luanti", data: getData(json.views_minetest) }, + ], + }; + setup_chart(ctx, data, annotations); + } } diff --git a/app/rediscache.py b/app/rediscache.py index 4bf27004..4cbf448f 100644 --- a/app/rediscache.py +++ b/app/rediscache.py @@ -15,6 +15,7 @@ # along with this program. If not, see . from . import redis_client +from .models import Package # This file acts as a facade between the rest of the code and redis, # and also means that the rest of the code avoids knowing about `app` @@ -23,10 +24,14 @@ EXPIRY_TIME_S = 2*7*24*60*60 # 2 weeks -def make_download_key(ip, package): +def make_download_key(ip: str, package: Package): return "{}/{}/{}".format(ip, package.author.username, package.name) +def make_view_key(ip: str, package: Package): + return "view/{}/{}/{}".format(ip, package.author.username, package.name) + + def set_temp_key(key, v): redis_client.set(key, v, ex=EXPIRY_TIME_S) diff --git a/app/templates/macros/stats.html b/app/templates/macros/stats.html index 91298380..a536febe 100644 --- a/app/templates/macros/stats.html +++ b/app/templates/macros/stats.html @@ -2,7 +2,7 @@ - + {% endmacro %} @@ -118,6 +118,12 @@

{{ _("Downloads by Reason") }}

+

{{ _("Views inside Luanti") }}

+

+ {{ _("Number of package page views inside the Luanti client. v5.10 and later only.") }} +

+ +

{{ _("Need more stats?") }}

{{ _("Check out the ContentDB Grafana dashboard for CDB-wide stats") }} diff --git a/migrations/versions/d52f6901b707_.py b/migrations/versions/d52f6901b707_.py new file mode 100644 index 00000000..0fd8d800 --- /dev/null +++ b/migrations/versions/d52f6901b707_.py @@ -0,0 +1,26 @@ +"""empty message + +Revision ID: d52f6901b707 +Revises: daa040b727b2 +Create Date: 2024-10-22 21:18:23.929298 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = 'd52f6901b707' +down_revision = 'daa040b727b2' +branch_labels = None +depends_on = None + + +def upgrade(): + with op.batch_alter_table('package_daily_stats', schema=None) as batch_op: + batch_op.add_column(sa.Column('views_minetest', sa.Integer(), nullable=False, server_default="0")) + + +def downgrade(): + with op.batch_alter_table('package_daily_stats', schema=None) as batch_op: + batch_op.drop_column('views_minetest')