diff --git a/api/api_specification.json b/api/api_specification.json index 7955ce77c6d8d2bc14e48ef0aaaa40a916e4b0e0..4532455566dca552a901f488a28eb0e9e1ec5e7c 100644 --- a/api/api_specification.json +++ b/api/api_specification.json @@ -2299,6 +2299,128 @@ } ], "url_parameters": null + }, + { + "allow_while_disabled": false, + "allow_while_readonly": true, + "description": "", + "only_mod": true, + "request_objects": {}, + "requires_csrf_token": false, + "response_description": "Note that views only appear some time after they viewer has watched the video (usually a few hours)", + "response_object_id": "", + "response_objects": { + "": { + "fields": { + "": { + "average_watch_speed": { + "config_directly_modifiable": false, + "id": "average_watch_speed", + "notes": "", + "object_variant": null, + "only_mod": false, + "optional": false, + "type": "float" + }, + "daily_views": { + "config_directly_modifiable": false, + "id": "daily_views", + "notes": "", + "object_variant": null, + "only_mod": false, + "optional": false, + "type": "daily_views" + }, + "lecture_views": { + "config_directly_modifiable": false, + "id": "lecture_views", + "notes": "", + "object_variant": null, + "only_mod": false, + "optional": false, + "type": "lecture_views" + }, + "total_watched_seconds": { + "config_directly_modifiable": false, + "id": "total_watched_seconds", + "notes": "", + "object_variant": null, + "only_mod": false, + "optional": false, + "type": "int" + }, + "view_count": { + "config_directly_modifiable": false, + "id": "view_count", + "notes": "", + "object_variant": null, + "only_mod": false, + "optional": false, + "type": "int" + } + } + }, + "id": "" + }, + "daily_views": { + "fields": { + "": { + "dates": { + "config_directly_modifiable": false, + "id": "dates", + "notes": "The dates in order without gaps", + "object_variant": null, + "only_mod": false, + "optional": false, + "type": "string[]" + }, + "view_counts": { + "config_directly_modifiable": false, + "id": "view_counts", + "notes": "The count of views on the date which is at the same position in the 'dates' array", + "object_variant": null, + "only_mod": false, + "optional": false, + "type": "int[]" + } + } + }, + "id": "daily_views" + }, + "lecture_views": { + "fields": { + "": { + "lecture_ids": { + "config_directly_modifiable": false, + "id": "lecture_ids", + "notes": "The lecture ids (in ascending time order)", + "object_variant": null, + "only_mod": false, + "optional": false, + "type": "int[]" + }, + "view_counts": { + "config_directly_modifiable": false, + "id": "view_counts", + "notes": "The count of views for the lecture on the date which is at the same position in the 'dates' array", + "object_variant": null, + "only_mod": false, + "optional": false, + "type": "int[]" + } + } + }, + "id": "lecture_views" + } + }, + "routes": [ + { + "display_path": "/stats/course/{course_id}", + "method": "GET", + "path": "/stats/course/<int:course_id>" + } + ], + "url_parameters": null } ], "global_objects": { diff --git a/api/api_specification.md b/api/api_specification.md index 9f64bd71a6b2005fae23123e783ea21715441334..87bc26b6f6780917a2eb63f02f4526d963bcedc5 100644 --- a/api/api_specification.md +++ b/api/api_specification.md @@ -1,4 +1,4 @@ -# Specification of the Web API for the Video-AG Website (v0.86). +# Specification of the Web API for the Video-AG Website (v0.87). ## Introduction @@ -1743,6 +1743,98 @@ daily_views: Note that views only appear some time after they viewer has watched the video (usually a few hours) +--- +#### `GET /stats/course/{course_id}` + +This may be used while the site is [read-only](#site-status). + +This may only be used by a moderator. + +###### Response: + +<table> +<thead> + <th>Field</th> + <th>Type</th> + <th>Notes</th> +</thead> +<tbody> + <tr> + <td>average_watch_speed</td> + <td>float</td> + <td></td> + </tr> + <tr> + <td>daily_views</td> + <td>daily_views</td> + <td></td> + </tr> + <tr> + <td>lecture_views</td> + <td>lecture_views</td> + <td></td> + </tr> + <tr> + <td>total_watched_seconds</td> + <td>int</td> + <td></td> + </tr> + <tr> + <td>view_count</td> + <td>int</td> + <td></td> + </tr> +</tbody> +</table> + + +daily_views: +<table> +<thead> + <th>Field</th> + <th>Type</th> + <th>Notes</th> +</thead> +<tbody> + <tr> + <td>dates</td> + <td>string[]</td> + <td>The dates in order without gaps</td> + </tr> + <tr> + <td>view_counts</td> + <td>int[]</td> + <td>The count of views on the date which is at the same position in the 'dates' array</td> + </tr> +</tbody> +</table> + + +lecture_views: +<table> +<thead> + <th>Field</th> + <th>Type</th> + <th>Notes</th> +</thead> +<tbody> + <tr> + <td>lecture_ids</td> + <td>int[]</td> + <td>The lecture ids (in ascending time order)</td> + </tr> + <tr> + <td>view_counts</td> + <td>int[]</td> + <td>The count of views for the lecture on the date which is at the same position in the 'dates' array</td> + </tr> +</tbody> +</table> + + +Note that views only appear some time after they viewer has watched the video (usually a few hours) + + --- @@ -3228,6 +3320,10 @@ Possible `error_code`: ## Changelog +### v0.87 + +* Added `GET /stats/course/{course_id}` + ### v0.86 * Updated `ffmpeg_filter_graph_node` diff --git a/api/api_specification_template.md b/api/api_specification_template.md index d60bcbb1b962e6b4f958237d2e992916e39b9bb1..dea99d0895679ce100d09f11aba7f3a54c30adc6 100644 --- a/api/api_specification_template.md +++ b/api/api_specification_template.md @@ -1,4 +1,4 @@ -# Specification of the Web API for the Video-AG Website (v0.86). +# Specification of the Web API for the Video-AG Website (v0.87). ## Introduction @@ -139,6 +139,10 @@ Possible `error_code`: ## Changelog +### v0.87 + +* Added `GET /stats/course/{course_id}` + ### v0.86 * Updated `ffmpeg_filter_graph_node` diff --git a/api/src/api/routes/stats.py b/api/src/api/routes/stats.py index 2fcf35b81e7234fbe7f938f81ba49ef5bf3ec312..c78be0a7512b9bf4c7d36b0391cc22f37155a84b 100755 --- a/api/src/api/routes/stats.py +++ b/api/src/api/routes/stats.py @@ -1,6 +1,6 @@ import math import re -from datetime import timedelta +from datetime import timedelta, date import more_itertools from typing import Sequence @@ -288,8 +288,119 @@ def api_route_get_lecture_stats(lecture_id: int): "total_watched_seconds": generic_lecture_stats.total_watched_seconds if generic_lecture_stats else 0, "average_watch_speed": generic_lecture_stats.average_watch_speed if generic_lecture_stats else 1, "daily_views": { - "dates": [daily_views_dates_json], - "view_counts": [daily_views_counts_json] + "dates": daily_views_dates_json, + "view_counts": daily_views_counts_json + } + } + + +@api_route("/stats/course/<int:course_id>", "GET", allow_while_readonly=True, + response_description="Note that views only appear some time after they viewer has watched the video (usually" + " a few hours)", + response_objects={ + "": [ + ("view_count", "int"), + ("total_watched_seconds", "int"), + ("average_watch_speed", "float"), + ("lecture_views", "lecture_views"), + ("daily_views", "daily_views"), + ], + "lecture_views": [ + ("lecture_ids", "int[]", "The lecture ids (in ascending time order)"), + ("view_counts", "int[]", "The count of views for the lecture on the date which is at the same position in the" + " 'dates' array") + ], + "daily_views": [ + ("dates", "string[]", "The dates in order without gaps"), + ("view_counts", "int[]", "The count of views on the date which is at the same position in the" + " 'dates' array") + ] + }) +@api_moderator_route() +def api_route_get_course_stats(course_id: int): + + def _trans(session: SessionDb) -> tuple[ + Course, + Sequence[tuple[date, int]], + Sequence[tuple[int, int]], + tuple[int, int, float], + ]: + course = session.scalar( + Course.select({ + AC_IS_MOD: True + }, []) + .where(Lecture.id == course_id) + ) + if course is None: + raise ApiClientException(ERROR_UNKNOWN_OBJECT) + daily_stats = session.execute( + sql.select(LectureDailyWatchStats.date, sql.func.sum(LectureDailyWatchStats.view_count)) + .join(Lecture, LectureDailyWatchStats.lecture_id == Lecture.id) + .where(Lecture.course_id == course_id) + .group_by(LectureDailyWatchStats.date) + .order_by(LectureDailyWatchStats.date.asc()) + ).all() + lectures_view_count = session.execute( + sql.select(LectureWatchStats.view_count, LectureWatchStats.lecture_id) + .join(Lecture, LectureWatchStats.lecture_id == Lecture.id) + .where(Lecture.course_id == course_id) + .order_by(Lecture.time.asc(), Lecture.id.asc()) + ).all() + generic_course_stats = session.execute( + sql.select( + sql.func.sum(LectureWatchStats.view_count), + sql.func.sum(LectureWatchStats.total_watched_seconds), + sql.case( + ( + sql.func.sum(LectureWatchStats.view_count) > 0, + sql.func.sum(LectureWatchStats.average_watch_speed * LectureWatchStats.view_count) + / sql.func.sum(LectureWatchStats.view_count) + ), + else_=0 + ) + ) + .join(Lecture, LectureWatchStats.lecture_id == Lecture.id) + .where(Lecture.course_id == course_id) + ).one() + session.expunge_all() + return course, daily_stats, lectures_view_count, generic_course_stats + + course, daily_stats, lectures_view_count, generic_course_stats = database.execute_read_transaction(_trans) + + daily_views_counts_json = [] + daily_views_dates_json = [] + if len(daily_stats) > 0: + current_date = daily_stats[0][0] + daily_stat_iter = more_itertools.peekable(daily_stats) + while daily_stat_iter.peek(None) is not None: # Has next + assert daily_stat_iter.peek()[0] >= current_date + + if daily_stat_iter.peek()[0] == current_date: + view_count = next(daily_stat_iter)[1] + else: + view_count = 0 + daily_views_counts_json.append(view_count) + daily_views_dates_json.append(current_date.strftime("%d.%m.%Y")) + + current_date += timedelta(days=1) + + lecture_views_ids_json = [] + lecture_views_counts_json = [] + for row in lectures_view_count: + lecture_views_ids_json.append(row[1]) + lecture_views_counts_json.append(row[0]) + + return { + "view_count": int(generic_course_stats[0]), + "total_watched_seconds": int(generic_course_stats[1]), + "average_watch_speed": float(generic_course_stats[2]), + "lecture_views": { + "lecture_ids": lecture_views_ids_json, + "view_counts": lecture_views_counts_json + }, + "daily_views": { + "dates": daily_views_dates_json, + "view_counts": daily_views_counts_json } } diff --git a/common_py/src/videoag_common/objects/stats.py b/common_py/src/videoag_common/objects/stats.py index b38d16cf8f2f6489c8a2ba193d7d2ba55288591b..90a53380da97a346f26ee4398094c1b5c6ffbf9a 100755 --- a/common_py/src/videoag_common/objects/stats.py +++ b/common_py/src/videoag_common/objects/stats.py @@ -76,6 +76,7 @@ class PublishMediumWatchStats(Base): # Counts the views per day for a lecture class LectureDailyWatchStats(Base): lecture_id: Mapped[int] = mapped_column(ForeignKey("lecture.id"), nullable=False, primary_key=True) + # TODO remove timezone date: Mapped[Date] = mapped_column(nullable=False, primary_key=True) view_count: Mapped[int] = mapped_column(nullable=False, default=0)