From 0ee72f30c5c2431e24d237b432046f4eb4ad030d Mon Sep 17 00:00:00 2001 From: bkfox Date: Fri, 12 Apr 2024 15:48:55 +0200 Subject: [PATCH] integrate statistics --- aircox/migrations/0015_program_editors.py | 9 ++ aircox/models/page.py | 2 +- aircox/models/program.py | 34 +------ aircox/permissions.py | 82 +++++++++++++++++ aircox/serializers/__init__.py | 6 +- aircox/serializers/page.py | 12 +++ aircox/static/aircox/css/chunk-common.css | 24 +++++ aircox/static/aircox/js/chunk-common.js | 10 +- aircox/templates/aircox/base.html | 3 +- aircox/templates/aircox/dashboard/base.html | 8 +- .../aircox/dashboard/statistics.html | 92 +++++++++++++++++++ .../dashboard/{ => widgets}/form_field.html | 0 .../dashboard/{ => widgets}/list_editor.html | 0 .../aircox/dashboard/{ => widgets}/nav.html | 10 +- .../{ => widgets}/soundlist_editor.html | 0 .../{ => widgets}/tracklist_editor.html | 0 .../dashboard/{ => widgets}/v_form_field.html | 0 aircox/templates/aircox/episode_form.html | 4 +- aircox/templates/aircox/page_form.html | 2 +- aircox/templates/aircox/widgets/comment.html | 20 ++-- aircox/templates/aircox/widgets/preview.html | 3 +- aircox/templatetags/aircox.py | 13 +++ aircox/urls.py | 3 + aircox/views/admin.py | 2 +- aircox/views/dashboard.py | 26 +++++- aircox/views/episode.py | 6 +- aircox/views/log.py | 1 + aircox/views/program.py | 19 ++-- aircox/viewsets.py | 6 ++ assets/src/app.js | 7 ++ assets/src/components/AActionButton.vue | 8 +- assets/src/components/AStatistics.vue | 5 +- assets/src/components/index.js | 5 +- assets/src/pageLoad.js | 2 +- assets/src/styles/helpers.scss | 7 ++ 35 files changed, 348 insertions(+), 83 deletions(-) create mode 100644 aircox/permissions.py create mode 100644 aircox/serializers/page.py create mode 100644 aircox/templates/aircox/dashboard/statistics.html rename aircox/templates/aircox/dashboard/{ => widgets}/form_field.html (100%) rename aircox/templates/aircox/dashboard/{ => widgets}/list_editor.html (100%) rename aircox/templates/aircox/dashboard/{ => widgets}/nav.html (75%) rename aircox/templates/aircox/dashboard/{ => widgets}/soundlist_editor.html (100%) rename aircox/templates/aircox/dashboard/{ => widgets}/tracklist_editor.html (100%) rename aircox/templates/aircox/dashboard/{ => widgets}/v_form_field.html (100%) diff --git a/aircox/migrations/0015_program_editors.py b/aircox/migrations/0015_program_editors.py index 2c921b0..23737cc 100644 --- a/aircox/migrations/0015_program_editors.py +++ b/aircox/migrations/0015_program_editors.py @@ -4,6 +4,14 @@ from django.db import migrations, models import django.db.models.deletion +def init_groups_and_permissions(app, schema_editor): + from aircox.permissions import program_permissions + + Program = app.get_model("aircox", "Program") + for program in Program.objects.all(): + program_permissions.init(program) + + class Migration(migrations.Migration): dependencies = [ ("auth", "0012_alter_user_first_name_max_length"), @@ -22,4 +30,5 @@ class Migration(migrations.Migration): verbose_name="editors", ), ), + migrations.RunPython(init_groups_and_permissions), ] diff --git a/aircox/models/page.py b/aircox/models/page.py index 84b1ade..9511c97 100644 --- a/aircox/models/page.py +++ b/aircox/models/page.py @@ -336,7 +336,7 @@ class Comment(Renderable, models.Model): return Page.objects.select_subclasses().filter(id=self.page_id).first() def get_absolute_url(self): - return self.parent.get_absolute_url() + f"#comment-{self.pk}" + return self.parent.get_absolute_url() + f"#{self._meta.label_lower}-{self.pk}" class Meta: verbose_name = _("Comment") diff --git a/aircox/models/program.py b/aircox/models/program.py index f9b8ec4..c204971 100644 --- a/aircox/models/program.py +++ b/aircox/models/program.py @@ -1,8 +1,7 @@ import os from django.conf import settings as conf -from django.contrib.auth.models import Group, Permission -from django.contrib.contenttypes.models import ContentType +from django.contrib.auth.models import Group from django.db import models from django.utils.translation import gettext_lazy as _ @@ -117,37 +116,6 @@ class Program(Page): os.makedirs(path, exist_ok=True) return os.path.exists(path) - def can_update(self, user): - """Return True if user can update program.""" - if user.is_superuser: - return True - perm = self._perm_update_codename.format(self=self) - return user.has_perm("aircox." + perm) - - # permissions and editor group format. Use of pk in codename makes it - # consistent in case program title changes. - _editor_group_name = "{self.title}: editors" - _perm_update_codename = "program_{self.pk}_update" - _perm_update_name = "{self.title}: update" - - def init_editor_group(self): - if not self.editors_group: - name = self._editor_group_name.format(self=self) - self.editors_group, created = Group.objects.get_or_create(name=name) - else: - created = False - - if created: - if not self.pk: - self.save(check_groups=False) - permission, _ = Permission.objects.get_or_create( - codename=self._perm_update_codename.format(self=self), - content_type=ContentType.objects.get_for_model(self), - defaults={"name": self._perm_update_name.format(self=self)}, - ) - if permission not in self.editors_group.permissions.all(): - self.editors_group.permissions.add(permission) - class Meta: verbose_name = _("Program") verbose_name_plural = _("Programs") diff --git a/aircox/permissions.py b/aircox/permissions.py new file mode 100644 index 0000000..40d6df1 --- /dev/null +++ b/aircox/permissions.py @@ -0,0 +1,82 @@ +# Provide permissions handling +# we don't import models at module level in order to avoid migration problems +from django.utils.translation import gettext_lazy as _ +from django.contrib.auth.models import Group, Permission +from django.contrib.contenttypes.models import ContentType + +from .models import Program + + +__all__ = ("PagePermissions", "program_permissions") + + +class PagePermissions: + """Handles obj permissions initialization of page subclass.""" + + model = None + groups = ({"label": _("editors"), "field": "editors_group_id", "perms": ["update"]},) + """Groups informations initialized.""" + groups_name_format = "{obj.title}: {group_label}" + """Format used for groups name.""" + perms_name_format = "{obj.title}: can {perm}" + """Format used for permission name (displayed to humans).""" + perms_codename_format = "{obj._meta.label_lower}_{obj.pk}_{perm}" + """Format used for permissions codename.""" + + def __init__(self, model): + self.model = model + + def can(self, user, perm, obj): + """Return True wether if user can edit Program or its children.""" + from .models.page import ChildPage + + breakpoint() + if isinstance(obj, ChildPage): + obj = obj.parent_subclass + + if not isinstance(obj, self.model): + return False + + if user.is_superuser: + return True + + perm = self.perms_codename_format.format(self=self, perm=perm) + return user.has_perm(perm) + + # TODO: bulk init + def init(self, obj): + """Initialize permissions for the provided obj.""" + created_groups = [] + + # init groups + for infos in self.groups: + group = getattr(obj, infos["field"]) + if not group: + group, created = self.init_group(obj, infos) + setattr(obj, infos["field"], group.pk) + created and created_groups.append((group, infos)) + + if created_groups: + obj.save() + + # init perms + for group, infos in created_groups: + self.init_perms(obj, group, infos) + + def init_group(self, obj, infos): + name = self.groups_name_format.format(obj=obj, group_label=infos["label"]) + return Group.objects.get_or_create(name=name) + + def init_perms(self, obj, group, infos): + # TODO: avoid multiple database hits + for name in infos["perms"]: + perm, _ = Permission.objects.get_or_create( + codename=self.perms_codename_format.format(obj=obj, perm=name), + content_type=ContentType.objects.get_for_model(obj), + defaults={"name": self.perms_name_format.format(obj=obj, perm=name)}, + ) + if perm not in group.permissions.all(): + group.permissions.add(perm) + + +program_permissions = PagePermissions(Program) diff --git a/aircox/serializers/__init__.py b/aircox/serializers/__init__.py index 4518172..1558f66 100644 --- a/aircox/serializers/__init__.py +++ b/aircox/serializers/__init__.py @@ -1,9 +1,11 @@ from .admin import TrackSerializer, UserSettingsSerializer -from .log import LogInfo, LogInfoSerializer -from .sound import SoundSerializer from .episode import EpisodeSoundSerializer, EpisodeSerializer +from .log import LogInfo, LogInfoSerializer +from .page import CommentSerializer +from .sound import SoundSerializer __all__ = ( + "CommentSerializer", "LogInfo", "LogInfoSerializer", "EpisodeSoundSerializer", diff --git a/aircox/serializers/page.py b/aircox/serializers/page.py new file mode 100644 index 0000000..3f52fff --- /dev/null +++ b/aircox/serializers/page.py @@ -0,0 +1,12 @@ +from rest_framework import serializers + +from aircox import models + + +__all__ = ("CommentSerializer",) + + +class CommentSerializer(serializers.ModelSerializer): + class Meta: + model = models.Comment + fields = ["page", "nickname", "email", "date", "content"] diff --git a/aircox/static/aircox/css/chunk-common.css b/aircox/static/aircox/css/chunk-common.css index b4f3540..d5a7156 100644 --- a/aircox/static/aircox/css/chunk-common.css +++ b/aircox/static/aircox/css/chunk-common.css @@ -9834,10 +9834,34 @@ a.tag:hover { color: var(--secondary-color); } +.bg-main { + background-color: var(--main-color); +} + +.bg-main-light { + background-color: var(--main-color-light); +} + +.bg-secondary { + background-color: var(--secondary-color); +} + +.bg-secondary-light { + background-color: var(--secondary-color-light); +} + .bg-transparent { background-color: transparent; } +.border-bottom-main { + border-bottom: 1px solid var(--main-color); +} + +.border-bottom-secondary { + border-bottom: 1px solid var(--secondary-color); +} + .is-success { background-color: #0e0 !important; border-color: #0b0 !important; diff --git a/aircox/static/aircox/js/chunk-common.js b/aircox/static/aircox/js/chunk-common.js index 091a1fe..6060c77 100644 --- a/aircox/static/aircox/js/chunk-common.js +++ b/aircox/static/aircox/js/chunk-common.js @@ -15,7 +15,7 @@ \***********************************************************************************************************************************************************************************************/ /***/ (function(__unused_webpack_module, __webpack_exports__, __webpack_require__) { -eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _model__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ../model */ \"./src/model.js\");\n\n\n/**\n * Button that can be used to call API requests on provided url\n */\n/* harmony default export */ __webpack_exports__[\"default\"] = ({\n emit: ['start', 'done'],\n props: {\n //! Component tag, by default, `button`\n tag: {\n type: String,\n default: 'a'\n },\n //! Button icon\n icon: String,\n //! Data or model instance to send\n data: Object,\n //! Action method, by default, `POST`\n method: {\n type: String,\n default: 'POST'\n },\n //! If provided open confirmation box before proceeding\n confirm: {\n type: String,\n default: ''\n },\n //! Action url\n url: String,\n //! Extra request options\n fetchOptions: {\n type: Object,\n default: () => {\n return {};\n }\n },\n //! Component class while action is running\n runClass: String,\n //! Icon class while action is running\n runIcon: String\n },\n computed: {\n //! Input data as model instance\n item() {\n return this.data instanceof _model__WEBPACK_IMPORTED_MODULE_0__[\"default\"] ? this.data : new _model__WEBPACK_IMPORTED_MODULE_0__[\"default\"](this.data);\n },\n //! Computed button class\n buttonClass() {\n return this.promise ? this.runClass : '';\n }\n },\n data() {\n return {\n promise: false\n };\n },\n methods: {\n call() {\n if (this.promise || !this.url) return;\n if (this.confirm && !confirm(this.confirm)) return;\n const options = _model__WEBPACK_IMPORTED_MODULE_0__[\"default\"].getOptions({\n ...this.fetchOptions,\n method: this.method,\n body: JSON.stringify(this.item.data)\n });\n this.promise = fetch(this.url, options).then(data => {\n const response = data.json();\n this.promise = null;\n this.$emit('done', response);\n return response;\n }, data => {\n this.promise = null;\n return data;\n });\n return this.promise;\n }\n }\n});\n\n//# sourceURL=webpack://aircox-assets/./src/components/AActionButton.vue?./node_modules/babel-loader/lib/index.js??clonedRuleSet-40.use%5B0%5D!./node_modules/vue-loader/dist/index.js??ruleSet%5B0%5D.use%5B0%5D"); +eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _model__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ../model */ \"./src/model.js\");\n\n\n/**\n * Button that can be used to call API requests on provided url\n */\n/* harmony default export */ __webpack_exports__[\"default\"] = ({\n emit: ['start', 'done'],\n props: {\n //! Component tag, by default, `button`\n tag: {\n type: String,\n default: 'a'\n },\n //! Button icon\n icon: String,\n //! Data or model instance to send\n data: Object,\n //! Action method, by default, `POST`\n method: {\n type: String,\n default: 'POST'\n },\n //! If provided open confirmation box before proceeding\n confirm: {\n type: String,\n default: ''\n },\n //! Action url\n url: String,\n //! Extra request options\n fetchOptions: {\n type: Object,\n default: () => {\n return {};\n }\n },\n //! Component class while action is running\n runClass: String,\n //! Icon class while action is running\n runIcon: String\n },\n computed: {\n //! Input data as model instance\n item() {\n return this.data instanceof _model__WEBPACK_IMPORTED_MODULE_0__[\"default\"] ? this.data : new _model__WEBPACK_IMPORTED_MODULE_0__[\"default\"](this.data);\n },\n //! Computed button class\n buttonClass() {\n return this.promise ? this.runClass : '';\n }\n },\n data() {\n return {\n promise: false\n };\n },\n methods: {\n call() {\n if (this.promise || !this.url) return;\n if (this.confirm && !confirm(this.confirm)) return;\n const options = _model__WEBPACK_IMPORTED_MODULE_0__[\"default\"].getOptions({\n ...this.fetchOptions,\n method: this.method,\n body: JSON.stringify(this.item.data)\n });\n this.promise = fetch(this.url, options).then(data => data.text()).then(data => {\n data = data && JSON.parse(data) || null;\n this.promise = null;\n this.$emit('done', data);\n return data;\n }, data => {\n this.promise = null;\n return data;\n });\n return this.promise;\n }\n }\n});\n\n//# sourceURL=webpack://aircox-assets/./src/components/AActionButton.vue?./node_modules/babel-loader/lib/index.js??clonedRuleSet-40.use%5B0%5D!./node_modules/vue-loader/dist/index.js??ruleSet%5B0%5D.use%5B0%5D"); /***/ }), @@ -195,7 +195,7 @@ eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var core \*********************************************************************************************************************************************************************************************/ /***/ (function(__unused_webpack_module, __webpack_exports__, __webpack_require__) { -eval("__webpack_require__.r(__webpack_exports__);\nconst splitReg = new RegExp(',\\\\s*', 'g');\n/* harmony default export */ __webpack_exports__[\"default\"] = ({\n data() {\n return {\n counts: {}\n };\n },\n methods: {\n update() {\n const items = this.$el.querySelectorAll('input[name=\"data\"]:checked');\n const counts = {};\n for (var item of items) if (item.value) for (var tag of item.value.split(splitReg)) counts[tag.trim()] = (counts[tag.trim()] || 0) + 1;\n this.counts = counts;\n },\n onclick() {\n // TODO: row click => check checkbox\n }\n },\n mounted() {\n console.log(this.counts);\n this.$refs.form.addEventListener('change', () => this.update());\n this.update();\n }\n});\n\n//# sourceURL=webpack://aircox-assets/./src/components/AStatistics.vue?./node_modules/babel-loader/lib/index.js??clonedRuleSet-40.use%5B0%5D!./node_modules/vue-loader/dist/index.js??ruleSet%5B0%5D.use%5B0%5D"); +eval("__webpack_require__.r(__webpack_exports__);\nconst splitReg = new RegExp(',\\\\s*|\\\\s+', 'g');\n/* harmony default export */ __webpack_exports__[\"default\"] = ({\n data() {\n return {\n counts: {}\n };\n },\n methods: {\n update() {\n const items = this.$el.querySelectorAll('input[name=\"data\"]:checked');\n const counts = {};\n for (var item of items) if (item.value) for (var tag of item.value.split(splitReg)) if (tag.trim()) counts[tag.trim()] = (counts[tag.trim()] || 0) + 1;\n this.counts = counts;\n },\n onclick() {\n // TODO: row click => check checkbox\n }\n },\n mounted() {\n console.log(this.counts);\n this.$refs.form.addEventListener('change', () => this.update());\n this.update();\n }\n});\n\n//# sourceURL=webpack://aircox-assets/./src/components/AStatistics.vue?./node_modules/babel-loader/lib/index.js??clonedRuleSet-40.use%5B0%5D!./node_modules/vue-loader/dist/index.js??ruleSet%5B0%5D.use%5B0%5D"); /***/ }), @@ -455,7 +455,7 @@ eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpac \********************/ /***/ (function(__unused_webpack_module, __webpack_exports__, __webpack_require__) { -eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ PlayerApp: function() { return /* binding */ PlayerApp; }\n/* harmony export */ });\n/* harmony import */ var v_calendar__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! v-calendar */ \"./node_modules/v-calendar/dist/es/index.js\");\n/* harmony import */ var _components__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./components */ \"./src/components/index.js\");\n\n\nconst App = {\n el: '#app',\n delimiters: ['[[', ']]'],\n components: {\n ..._components__WEBPACK_IMPORTED_MODULE_1__[\"default\"],\n ...{\n VCalendar: v_calendar__WEBPACK_IMPORTED_MODULE_0__.Calendar,\n VDatepicker: v_calendar__WEBPACK_IMPORTED_MODULE_0__.DatePicker\n }\n },\n computed: {\n player() {\n return window.aircox.player;\n }\n }\n};\nconst PlayerApp = {\n el: '#player',\n delimiters: ['[[', ']]'],\n components: {\n ..._components__WEBPACK_IMPORTED_MODULE_1__[\"default\"]\n }\n};\n/* harmony default export */ __webpack_exports__[\"default\"] = (App);\n\n//# sourceURL=webpack://aircox-assets/./src/app.js?"); +eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ PlayerApp: function() { return /* binding */ PlayerApp; }\n/* harmony export */ });\n/* harmony import */ var v_calendar__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! v-calendar */ \"./node_modules/v-calendar/dist/es/index.js\");\n/* harmony import */ var _components__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./components */ \"./src/components/index.js\");\n\n\nconst App = {\n el: '#app',\n delimiters: ['[[', ']]'],\n components: {\n ..._components__WEBPACK_IMPORTED_MODULE_1__[\"default\"],\n ...{\n VCalendar: v_calendar__WEBPACK_IMPORTED_MODULE_0__.Calendar,\n VDatepicker: v_calendar__WEBPACK_IMPORTED_MODULE_0__.DatePicker\n }\n },\n computed: {\n player() {\n return window.aircox.player;\n }\n },\n methods: {\n deleteElements(sel) {\n for (var el of document.querySelectorAll(sel)) el.parentNode.removeChild(el);\n }\n }\n};\nconst PlayerApp = {\n el: '#player',\n delimiters: ['[[', ']]'],\n components: {\n ..._components__WEBPACK_IMPORTED_MODULE_1__[\"default\"]\n }\n};\n/* harmony default export */ __webpack_exports__[\"default\"] = (App);\n\n//# sourceURL=webpack://aircox-assets/./src/app.js?"); /***/ }), @@ -465,7 +465,7 @@ eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpac \*********************************/ /***/ (function(__unused_webpack_module, __webpack_exports__, __webpack_require__) { -eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ admin: function() { return /* binding */ admin; },\n/* harmony export */ base: function() { return /* binding */ base; },\n/* harmony export */ dashboard: function() { return /* binding */ dashboard; }\n/* harmony export */ });\n/* harmony import */ var _AActionButton_vue__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./AActionButton.vue */ \"./src/components/AActionButton.vue\");\n/* harmony import */ var _AAutocomplete__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./AAutocomplete */ \"./src/components/AAutocomplete.vue\");\n/* harmony import */ var _ACarousel__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! ./ACarousel */ \"./src/components/ACarousel.vue\");\n/* harmony import */ var _ADropdown__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! ./ADropdown */ \"./src/components/ADropdown.vue\");\n/* harmony import */ var _AEpisode__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(/*! ./AEpisode */ \"./src/components/AEpisode.vue\");\n/* harmony import */ var _AList__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(/*! ./AList */ \"./src/components/AList.vue\");\n/* harmony import */ var _APage__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(/*! ./APage */ \"./src/components/APage.vue\");\n/* harmony import */ var _APlayer__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(/*! ./APlayer */ \"./src/components/APlayer.vue\");\n/* harmony import */ var _APlaylist__WEBPACK_IMPORTED_MODULE_8__ = __webpack_require__(/*! ./APlaylist */ \"./src/components/APlaylist.vue\");\n/* harmony import */ var _AProgress__WEBPACK_IMPORTED_MODULE_9__ = __webpack_require__(/*! ./AProgress */ \"./src/components/AProgress.vue\");\n/* harmony import */ var _ASoundItem__WEBPACK_IMPORTED_MODULE_10__ = __webpack_require__(/*! ./ASoundItem */ \"./src/components/ASoundItem.vue\");\n/* harmony import */ var _ASwitch__WEBPACK_IMPORTED_MODULE_11__ = __webpack_require__(/*! ./ASwitch */ \"./src/components/ASwitch.vue\");\n/* harmony import */ var _AModal__WEBPACK_IMPORTED_MODULE_12__ = __webpack_require__(/*! ./AModal */ \"./src/components/AModal.vue\");\n/* harmony import */ var _AFileUpload__WEBPACK_IMPORTED_MODULE_13__ = __webpack_require__(/*! ./AFileUpload */ \"./src/components/AFileUpload.vue\");\n/* harmony import */ var _ASelectFile__WEBPACK_IMPORTED_MODULE_14__ = __webpack_require__(/*! ./ASelectFile */ \"./src/components/ASelectFile.vue\");\n/* harmony import */ var _AStatistics__WEBPACK_IMPORTED_MODULE_15__ = __webpack_require__(/*! ./AStatistics */ \"./src/components/AStatistics.vue\");\n/* harmony import */ var _AStreamer__WEBPACK_IMPORTED_MODULE_16__ = __webpack_require__(/*! ./AStreamer */ \"./src/components/AStreamer.vue\");\n/* harmony import */ var _AFormSet__WEBPACK_IMPORTED_MODULE_17__ = __webpack_require__(/*! ./AFormSet */ \"./src/components/AFormSet.vue\");\n/* harmony import */ var _ATrackListEditor__WEBPACK_IMPORTED_MODULE_18__ = __webpack_require__(/*! ./ATrackListEditor */ \"./src/components/ATrackListEditor.vue\");\n/* harmony import */ var _ASoundListEditor__WEBPACK_IMPORTED_MODULE_19__ = __webpack_require__(/*! ./ASoundListEditor */ \"./src/components/ASoundListEditor.vue\");\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n/**\n * Core components\n */\nconst base = {\n AAutocomplete: _AAutocomplete__WEBPACK_IMPORTED_MODULE_1__[\"default\"],\n ACarousel: _ACarousel__WEBPACK_IMPORTED_MODULE_2__[\"default\"],\n ADropdown: _ADropdown__WEBPACK_IMPORTED_MODULE_3__[\"default\"],\n AEpisode: _AEpisode__WEBPACK_IMPORTED_MODULE_4__[\"default\"],\n AList: _AList__WEBPACK_IMPORTED_MODULE_5__[\"default\"],\n APage: _APage__WEBPACK_IMPORTED_MODULE_6__[\"default\"],\n APlayer: _APlayer__WEBPACK_IMPORTED_MODULE_7__[\"default\"],\n APlaylist: _APlaylist__WEBPACK_IMPORTED_MODULE_8__[\"default\"],\n AProgress: _AProgress__WEBPACK_IMPORTED_MODULE_9__[\"default\"],\n ASoundItem: _ASoundItem__WEBPACK_IMPORTED_MODULE_10__[\"default\"],\n ASwitch: _ASwitch__WEBPACK_IMPORTED_MODULE_11__[\"default\"]\n};\n/* harmony default export */ __webpack_exports__[\"default\"] = (base);\nconst admin = {\n ...base,\n AStatistics: _AStatistics__WEBPACK_IMPORTED_MODULE_15__[\"default\"],\n AStreamer: _AStreamer__WEBPACK_IMPORTED_MODULE_16__[\"default\"],\n ATrackListEditor: _ATrackListEditor__WEBPACK_IMPORTED_MODULE_18__[\"default\"]\n};\nconst dashboard = {\n ...base,\n AActionButton: _AActionButton_vue__WEBPACK_IMPORTED_MODULE_0__[\"default\"],\n AFileUpload: _AFileUpload__WEBPACK_IMPORTED_MODULE_13__[\"default\"],\n ASelectFile: _ASelectFile__WEBPACK_IMPORTED_MODULE_14__[\"default\"],\n AModal: _AModal__WEBPACK_IMPORTED_MODULE_12__[\"default\"],\n AFormSet: _AFormSet__WEBPACK_IMPORTED_MODULE_17__[\"default\"],\n ATrackListEditor: _ATrackListEditor__WEBPACK_IMPORTED_MODULE_18__[\"default\"],\n ASoundListEditor: _ASoundListEditor__WEBPACK_IMPORTED_MODULE_19__[\"default\"]\n};\n\n//# sourceURL=webpack://aircox-assets/./src/components/index.js?"); +eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ admin: function() { return /* binding */ admin; },\n/* harmony export */ base: function() { return /* binding */ base; },\n/* harmony export */ dashboard: function() { return /* binding */ dashboard; }\n/* harmony export */ });\n/* harmony import */ var _AActionButton_vue__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./AActionButton.vue */ \"./src/components/AActionButton.vue\");\n/* harmony import */ var _AAutocomplete__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./AAutocomplete */ \"./src/components/AAutocomplete.vue\");\n/* harmony import */ var _ACarousel__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! ./ACarousel */ \"./src/components/ACarousel.vue\");\n/* harmony import */ var _ADropdown__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! ./ADropdown */ \"./src/components/ADropdown.vue\");\n/* harmony import */ var _AEpisode__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(/*! ./AEpisode */ \"./src/components/AEpisode.vue\");\n/* harmony import */ var _AList__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(/*! ./AList */ \"./src/components/AList.vue\");\n/* harmony import */ var _APage__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(/*! ./APage */ \"./src/components/APage.vue\");\n/* harmony import */ var _APlayer__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(/*! ./APlayer */ \"./src/components/APlayer.vue\");\n/* harmony import */ var _APlaylist__WEBPACK_IMPORTED_MODULE_8__ = __webpack_require__(/*! ./APlaylist */ \"./src/components/APlaylist.vue\");\n/* harmony import */ var _AProgress__WEBPACK_IMPORTED_MODULE_9__ = __webpack_require__(/*! ./AProgress */ \"./src/components/AProgress.vue\");\n/* harmony import */ var _ASoundItem__WEBPACK_IMPORTED_MODULE_10__ = __webpack_require__(/*! ./ASoundItem */ \"./src/components/ASoundItem.vue\");\n/* harmony import */ var _ASwitch__WEBPACK_IMPORTED_MODULE_11__ = __webpack_require__(/*! ./ASwitch */ \"./src/components/ASwitch.vue\");\n/* harmony import */ var _AModal__WEBPACK_IMPORTED_MODULE_12__ = __webpack_require__(/*! ./AModal */ \"./src/components/AModal.vue\");\n/* harmony import */ var _AFileUpload__WEBPACK_IMPORTED_MODULE_13__ = __webpack_require__(/*! ./AFileUpload */ \"./src/components/AFileUpload.vue\");\n/* harmony import */ var _ASelectFile__WEBPACK_IMPORTED_MODULE_14__ = __webpack_require__(/*! ./ASelectFile */ \"./src/components/ASelectFile.vue\");\n/* harmony import */ var _AStatistics__WEBPACK_IMPORTED_MODULE_15__ = __webpack_require__(/*! ./AStatistics */ \"./src/components/AStatistics.vue\");\n/* harmony import */ var _AStreamer__WEBPACK_IMPORTED_MODULE_16__ = __webpack_require__(/*! ./AStreamer */ \"./src/components/AStreamer.vue\");\n/* harmony import */ var _AFormSet__WEBPACK_IMPORTED_MODULE_17__ = __webpack_require__(/*! ./AFormSet */ \"./src/components/AFormSet.vue\");\n/* harmony import */ var _ATrackListEditor__WEBPACK_IMPORTED_MODULE_18__ = __webpack_require__(/*! ./ATrackListEditor */ \"./src/components/ATrackListEditor.vue\");\n/* harmony import */ var _ASoundListEditor__WEBPACK_IMPORTED_MODULE_19__ = __webpack_require__(/*! ./ASoundListEditor */ \"./src/components/ASoundListEditor.vue\");\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n/**\n * Core components\n */\nconst base = {\n AAutocomplete: _AAutocomplete__WEBPACK_IMPORTED_MODULE_1__[\"default\"],\n ACarousel: _ACarousel__WEBPACK_IMPORTED_MODULE_2__[\"default\"],\n ADropdown: _ADropdown__WEBPACK_IMPORTED_MODULE_3__[\"default\"],\n AEpisode: _AEpisode__WEBPACK_IMPORTED_MODULE_4__[\"default\"],\n AList: _AList__WEBPACK_IMPORTED_MODULE_5__[\"default\"],\n APage: _APage__WEBPACK_IMPORTED_MODULE_6__[\"default\"],\n APlayer: _APlayer__WEBPACK_IMPORTED_MODULE_7__[\"default\"],\n APlaylist: _APlaylist__WEBPACK_IMPORTED_MODULE_8__[\"default\"],\n AProgress: _AProgress__WEBPACK_IMPORTED_MODULE_9__[\"default\"],\n ASoundItem: _ASoundItem__WEBPACK_IMPORTED_MODULE_10__[\"default\"],\n ASwitch: _ASwitch__WEBPACK_IMPORTED_MODULE_11__[\"default\"]\n};\n/* harmony default export */ __webpack_exports__[\"default\"] = (base);\nconst admin = {\n ...base,\n ATrackListEditor: _ATrackListEditor__WEBPACK_IMPORTED_MODULE_18__[\"default\"]\n};\nconst dashboard = {\n ...base,\n AActionButton: _AActionButton_vue__WEBPACK_IMPORTED_MODULE_0__[\"default\"],\n AFileUpload: _AFileUpload__WEBPACK_IMPORTED_MODULE_13__[\"default\"],\n ASelectFile: _ASelectFile__WEBPACK_IMPORTED_MODULE_14__[\"default\"],\n AModal: _AModal__WEBPACK_IMPORTED_MODULE_12__[\"default\"],\n AFormSet: _AFormSet__WEBPACK_IMPORTED_MODULE_17__[\"default\"],\n ATrackListEditor: _ATrackListEditor__WEBPACK_IMPORTED_MODULE_18__[\"default\"],\n ASoundListEditor: _ASoundListEditor__WEBPACK_IMPORTED_MODULE_19__[\"default\"],\n AStatistics: _AStatistics__WEBPACK_IMPORTED_MODULE_15__[\"default\"],\n AStreamer: _AStreamer__WEBPACK_IMPORTED_MODULE_16__[\"default\"]\n};\n\n//# sourceURL=webpack://aircox-assets/./src/components/index.js?"); /***/ }), @@ -505,7 +505,7 @@ eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpac \*************************/ /***/ (function(__unused_webpack_module, __webpack_exports__, __webpack_require__) { -eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ \"default\": function() { return /* binding */ PageLoad; }\n/* harmony export */ });\n/* harmony import */ var core_js_modules_es_array_push_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! core-js/modules/es.array.push.js */ \"./node_modules/core-js/modules/es.array.push.js\");\n/* harmony import */ var core_js_modules_es_array_push_js__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(core_js_modules_es_array_push_js__WEBPACK_IMPORTED_MODULE_0__);\n\n/**\n * Load page without leaving current one (hot-reload).\n */\nclass PageLoad {\n constructor(el, {\n loadingClass = \"loading\",\n append = false\n } = {}) {\n this.el = el;\n this.append = append;\n this.loadingClass = loadingClass;\n }\n get target() {\n if (!this._target) this._target = document.querySelector(this.el);\n return this._target;\n }\n reset() {\n this._target = null;\n }\n\n /**\n * Enable hot reload: catch page change in order to fetch them and\n * load page without actually leaving current one.\n */\n enable(target = null) {\n if (this._pageChanged) throw \"Already enabled, please disable me\";\n if (!target) target = this.target || document.body;\n this.historySave(document.location, true);\n this._pageChanged = event => this.pageChanged(event);\n this._statePopped = event => this.statePopped(event);\n target.addEventListener('click', this._pageChanged, true);\n target.addEventListener('submit', this._pageChanged, true);\n window.addEventListener('popstate', this._statePopped, true);\n }\n\n /**\n * Disable hot reload, remove listeners.\n */\n disable() {\n this.target.removeEventListener('click', this._pageChanged, true);\n this.target.removeEventListener('submit', this._pageChanged, true);\n window.removeEventListener('popstate', this._statePopped, true);\n this._pageChanged = null;\n this._statePopped = null;\n }\n\n /**\n * Fetch url, return promise, similar to standard Fetch API.\n * Default implementation just forward argument to it.\n */\n fetch(url, options) {\n return fetch(url, options);\n }\n\n /**\n * Fetch app from remote and mount application.\n */\n load(url, {\n mount = true,\n scroll = [0, 0],\n ...options\n } = {}) {\n if (this.loadingClass) this.target.classList.add(this.loadingClass);\n if (this.onLoad) this.onLoad({\n url,\n el: this.el,\n options\n });\n if (scroll) window.scroll(...scroll);\n return this.fetch(url, options).then(response => response.text()).then(content => {\n if (this.loadingClass) this.target.classList.remove(this.loadingClass);\n var doc = new DOMParser().parseFromString(content, 'text/html');\n var dom = doc.querySelectorAll(this.el);\n var result = {\n url,\n content: dom || [document.createTextNode(content)],\n title: doc.title,\n append: this.append\n };\n mount && this.mount(result);\n return result;\n });\n }\n\n /**\n * Mount the page on provided target element\n */\n mount({\n content,\n title = null,\n ...options\n } = {}) {\n if (this.onPreMount) this.onPreMount({\n target: this.target,\n content,\n items,\n title\n });\n var items = null;\n if (content) items = this.mountContent(content, options);\n if (title) document.title = title;\n if (this.onMount) this.onMount({\n target: this.target,\n content,\n items,\n title\n });\n }\n\n /**\n * Mount page content\n */\n mountContent(content, {\n append = false\n } = {}) {\n if (typeof content == \"string\") {\n this.target.innerHTML = append ? this.target.innerHTML + content : content;\n // TODO\n return [];\n }\n if (!append) this.target.innerHTML = \"\";\n var fragment = document.createDocumentFragment();\n var items = [];\n for (var node of content) while (node.firstChild) {\n items.push(node.firstChild);\n fragment.appendChild(node.firstChild);\n }\n this.target.append(fragment);\n return items;\n }\n\n /// Save application state into browser history\n historySave(url, replace = false) {\n const state = {\n content: this.target.innerHTML,\n title: document.title\n };\n if (replace) history.replaceState(state, '', url);else history.pushState(state, '', url);\n }\n\n // --- events\n pageChanged(event) {\n let submit = event.type == 'submit';\n let target = submit || event.target.tagName == 'A' ? event.target : event.target.closest('a');\n if (!target || target.hasAttribute('target')) return;\n let url = submit ? target.getAttribute('action') || '' : target.getAttribute('href');\n let domain = window.location.protocol + '//' + window.location.hostname;\n let stay = (url === '' || url.startsWith('/') || url.startsWith('?') || url.startsWith(domain)) && url.indexOf('wp-admin') == -1;\n if (url === null || !stay) {\n return;\n }\n let options = {};\n if (submit) {\n let formData = new FormData(event.target);\n if (target.method == 'get') url += '?' + new URLSearchParams(formData).toString();else options = {\n ...options,\n method: target.method,\n body: formData\n };\n }\n this.load(url, options).then(() => this.historySave(url));\n event.preventDefault();\n event.stopPropagation();\n }\n statePopped(event) {\n const state = event.state;\n if (state && state.content) this.mount({\n content: state.content,\n title: state.title\n });\n }\n}\n\n//# sourceURL=webpack://aircox-assets/./src/pageLoad.js?"); +eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ \"default\": function() { return /* binding */ PageLoad; }\n/* harmony export */ });\n/* harmony import */ var core_js_modules_es_array_push_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! core-js/modules/es.array.push.js */ \"./node_modules/core-js/modules/es.array.push.js\");\n/* harmony import */ var core_js_modules_es_array_push_js__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(core_js_modules_es_array_push_js__WEBPACK_IMPORTED_MODULE_0__);\n\n/**\n * Load page without leaving current one (hot-reload).\n */\nclass PageLoad {\n constructor(el, {\n loadingClass = \"loading\",\n append = false\n } = {}) {\n this.el = el;\n this.append = append;\n this.loadingClass = loadingClass;\n }\n get target() {\n if (!this._target) this._target = document.querySelector(this.el);\n return this._target;\n }\n reset() {\n this._target = null;\n }\n\n /**\n * Enable hot reload: catch page change in order to fetch them and\n * load page without actually leaving current one.\n */\n enable(target = null) {\n if (this._pageChanged) throw \"Already enabled, please disable me\";\n if (!target) target = this.target || document.body;\n this.historySave(document.location, true);\n this._pageChanged = event => this.pageChanged(event);\n this._statePopped = event => this.statePopped(event);\n target.addEventListener('click', this._pageChanged, true);\n target.addEventListener('submit', this._pageChanged, true);\n window.addEventListener('popstate', this._statePopped, true);\n }\n\n /**\n * Disable hot reload, remove listeners.\n */\n disable() {\n this.target.removeEventListener('click', this._pageChanged, true);\n this.target.removeEventListener('submit', this._pageChanged, true);\n window.removeEventListener('popstate', this._statePopped, true);\n this._pageChanged = null;\n this._statePopped = null;\n }\n\n /**\n * Fetch url, return promise, similar to standard Fetch API.\n * Default implementation just forward argument to it.\n */\n fetch(url, options) {\n return fetch(url, options);\n }\n\n /**\n * Fetch app from remote and mount application.\n */\n load(url, {\n mount = true,\n scroll = [0, 0],\n ...options\n } = {}) {\n if (this.loadingClass) this.target.classList.add(this.loadingClass);\n if (this.onLoad) this.onLoad({\n url,\n el: this.el,\n options\n });\n if (scroll) window.scroll(...scroll);\n return this.fetch(url, options).then(response => response.text()).then(content => {\n if (this.loadingClass) this.target.classList.remove(this.loadingClass);\n var doc = new DOMParser().parseFromString(content, 'text/html');\n var dom = doc.querySelectorAll(this.el);\n var result = {\n url,\n content: dom || [document.createTextNode(content)],\n title: doc.title,\n append: this.append\n };\n mount && this.mount(result);\n return result;\n });\n }\n\n /**\n * Mount the page on provided target element\n */\n mount({\n content,\n title = null,\n ...options\n } = {}) {\n if (this.onPreMount) this.onPreMount({\n target: this.target,\n content,\n items,\n title\n });\n var items = null;\n if (content) items = this.mountContent(content, options);\n if (title) document.title = title;\n if (this.onMount) this.onMount({\n target: this.target,\n content,\n items,\n title\n });\n }\n\n /**\n * Mount page content\n */\n mountContent(content, {\n append = false\n } = {}) {\n if (typeof content == \"string\") {\n this.target.innerHTML = append ? this.target.innerHTML + content : content;\n // TODO\n return [];\n }\n if (!append) this.target.innerHTML = \"\";\n var fragment = document.createDocumentFragment();\n var items = [];\n for (var node of content) while (node.firstChild) {\n items.push(node.firstChild);\n fragment.appendChild(node.firstChild);\n }\n this.target.append(fragment);\n return items;\n }\n\n /// Save application state into browser history\n historySave(url, replace = false) {\n const state = {\n content: this.target.innerHTML,\n title: document.title\n };\n if (replace) history.replaceState(state, '', url);else history.pushState(state, '', url);\n }\n\n // --- events\n pageChanged(event) {\n let submit = event.type == 'submit';\n let target = submit || event.target.tagName == 'A' ? event.target : event.target.closest('a');\n if (!target || target.hasAttribute('target') || target.data.forceReload) return;\n let url = submit ? target.getAttribute('action') || '' : target.getAttribute('href');\n let domain = window.location.protocol + '//' + window.location.hostname;\n let stay = (url === '' || url.startsWith('/') || url.startsWith('?') || url.startsWith(domain)) && url.indexOf('wp-admin') == -1;\n if (url === null || !stay) {\n return;\n }\n let options = {};\n if (submit) {\n let formData = new FormData(event.target);\n if (target.method == 'get') url += '?' + new URLSearchParams(formData).toString();else options = {\n ...options,\n method: target.method,\n body: formData\n };\n }\n this.load(url, options).then(() => this.historySave(url));\n event.preventDefault();\n event.stopPropagation();\n }\n statePopped(event) {\n const state = event.state;\n if (state && state.content) this.mount({\n content: state.content,\n title: state.title\n });\n }\n}\n\n//# sourceURL=webpack://aircox-assets/./src/pageLoad.js?"); /***/ }), diff --git a/aircox/templates/aircox/base.html b/aircox/templates/aircox/base.html index 9416902..f100b13 100644 --- a/aircox/templates/aircox/base.html +++ b/aircox/templates/aircox/base.html @@ -37,6 +37,7 @@ Usefull context: {% block head_extra %}{% endblock %} + {% block body-head %}{% endblock %} + +{% endblock %} {% block head-title %} {% block title %}{{ block.super }}{% endblock %} diff --git a/aircox/templates/aircox/dashboard/statistics.html b/aircox/templates/aircox/dashboard/statistics.html new file mode 100644 index 0000000..785de35 --- /dev/null +++ b/aircox/templates/aircox/dashboard/statistics.html @@ -0,0 +1,92 @@ +{% extends "./base.html" %} +{% load i18n aircox %} + +{% block title %}{% translate "Statistics" %}{% endblock %} + +{% block content-container %} +
+ +{# TODO: date subtitle #} +{% comment %} + +{% endcomment %} + + + + + +
+{% endblock %} diff --git a/aircox/templates/aircox/dashboard/form_field.html b/aircox/templates/aircox/dashboard/widgets/form_field.html similarity index 100% rename from aircox/templates/aircox/dashboard/form_field.html rename to aircox/templates/aircox/dashboard/widgets/form_field.html diff --git a/aircox/templates/aircox/dashboard/list_editor.html b/aircox/templates/aircox/dashboard/widgets/list_editor.html similarity index 100% rename from aircox/templates/aircox/dashboard/list_editor.html rename to aircox/templates/aircox/dashboard/widgets/list_editor.html diff --git a/aircox/templates/aircox/dashboard/nav.html b/aircox/templates/aircox/dashboard/widgets/nav.html similarity index 75% rename from aircox/templates/aircox/dashboard/nav.html rename to aircox/templates/aircox/dashboard/widgets/nav.html index 507730d..e29b4ba 100644 --- a/aircox/templates/aircox/dashboard/nav.html +++ b/aircox/templates/aircox/dashboard/widgets/nav.html @@ -10,19 +10,23 @@

{{ field.help_text }}

diff --git a/aircox/templates/aircox/widgets/comment.html b/aircox/templates/aircox/widgets/comment.html index 7595708..3289b77 100644 --- a/aircox/templates/aircox/widgets/comment.html +++ b/aircox/templates/aircox/widgets/comment.html @@ -2,7 +2,6 @@ {% load i18n humanize aircox %} {% block tag-class %}{{ block.super }} comment{% endblock %} -{% block tag-extra %} id="comment-{{ object.pk }}"{% endblock %} {% block outer %} {% with url=object.get_absolute_url %} @@ -33,18 +32,23 @@ {% block actions %} {{ block.super }} -{% if request.user.is_staff %} +{% if admin %} +{% if user.is_staff %} - - - +{% endif %} + {# {{ object.nickname }} #} {% endif %} diff --git a/aircox/templates/aircox/widgets/preview.html b/aircox/templates/aircox/widgets/preview.html index 5f06060..55533ef 100644 --- a/aircox/templates/aircox/widgets/preview.html +++ b/aircox/templates/aircox/widgets/preview.html @@ -20,9 +20,10 @@ Styling related context: - tag_extra: extra tag attributes {% endcomment %} +{% load aircox %} {% block outer %} -<{{ tag|default:"article" }} class="preview {% if not cover %}no-cover {% endif %}{% if is_active %}active {% endif %}{% if is_tiny %}tiny{% elif is_small %}small{% endif %}{% block tag-class %}{{ tag_class|default:"" }} {% endblock %}" {% block tag-extra %}{% endblock %}> +<{{ tag|default:"article" }} id="{{ object|object_id }}" class="preview {% if not cover %}no-cover {% endif %}{% if is_active %}active {% endif %}{% if is_tiny %}tiny{% elif is_small %}small{% endif %}{% block tag-class %}{{ tag_class|default:"" }} {% endblock %}" {% block tag-extra %}{% endblock %}> {% block inner %} {% block headings-container %}
diff --git a/aircox/templatetags/aircox.py b/aircox/templatetags/aircox.py index 60c76d7..13fc595 100644 --- a/aircox/templatetags/aircox.py +++ b/aircox/templatetags/aircox.py @@ -2,6 +2,7 @@ import json import random from django import template, forms +from django.db import models from django.contrib.admin.templatetags.admin_urls import admin_urlname from django.template.loader import render_to_string from django.urls import reverse @@ -19,6 +20,18 @@ def admin_url(obj, action): return reverse(f"admin:{meta.app_label}_{meta.model_name}_{action}", args=[obj.id]) +@register.filter(name="model_label") +def model_label(obj): + if isinstance(obj, models.Model): + obj = type(obj) + return obj._meta.label_lower.replace(".", "-") + + +@register.filter(name="object_id") +def object_id(obj): + return f"{model_label(obj)}-{obj.pk}" + + @register.simple_tag(name="page_widget", takes_context=True) def do_page_widget(context, widget, object, dir="aircox/widgets", **ctx): """Render widget for the provided page and context.""" diff --git a/aircox/urls.py b/aircox/urls.py index 8672308..e3ea272 100755 --- a/aircox/urls.py +++ b/aircox/urls.py @@ -24,6 +24,7 @@ router = DefaultRouter() router.register("images", viewsets.ImageViewSet, basename="image") router.register("sound", viewsets.SoundViewSet, basename="sound") router.register("track", viewsets.TrackROViewSet, basename="track") +router.register("comment", viewsets.CommentViewSet, basename="comment") api = [ @@ -121,6 +122,8 @@ urls = [ path(_("dashboard/"), views.dashboard.DashboardView.as_view(), name="dashboard"), path(_("dashboard/program//"), views.ProgramUpdateView.as_view(), name="program-edit"), path(_("dashboard/episodes//"), views.EpisodeUpdateView.as_view(), name="episode-edit"), + path(_("dashboard/statistics/"), views.dashboard.StatisticsView.as_view(), name="dashboard-statistics"), + path(_("dashboard/statistics//"), views.dashboard.StatisticsView.as_view(), name="dashboard-statistics"), # ---- others path("errors/no-station/", views.errors.NoStationErrorView.as_view(), name="errors-no-station"), ] diff --git a/aircox/views/admin.py b/aircox/views/admin.py index d66a6fd..d3120f7 100644 --- a/aircox/views/admin.py +++ b/aircox/views/admin.py @@ -31,7 +31,7 @@ class AdminMixin(LoginRequiredMixin, UserPassesTestMixin): class StatisticsView(AdminMixin, LogListView, ListView): template_name = "admin/aircox/statistics.html" - redirect_date_url = "admin:tools-stats" + # redirect_date_url = "admin:tools-stats" title = _("Statistics") date = None diff --git a/aircox/views/dashboard.py b/aircox/views/dashboard.py index 9ce8363..09f14cd 100644 --- a/aircox/views/dashboard.py +++ b/aircox/views/dashboard.py @@ -4,13 +4,21 @@ from django.utils.translation import gettext_lazy as _ from django.views.generic.base import TemplateView from aircox import models +from aircox.controllers.log_archiver import LogArchiver from .base import BaseView +from .log import LogListView -class DashboardView(LoginRequiredMixin, BaseView, TemplateView): - template_name = "aircox/dashboard/dashboard.html" +__all__ = ("DashboardBaseView", "DashboardView", "StatisticsView") + + +class DashboardBaseView(LoginRequiredMixin, BaseView): title = _("Dashboard") + +class DashboardView(DashboardBaseView, TemplateView): + template_name = "aircox/dashboard/dashboard.html" + def get_context_data(self, **kwargs): programs = models.Program.objects.editor(self.request.user) comments = models.Comment.objects.filter( @@ -29,3 +37,17 @@ class DashboardView(LoginRequiredMixin, BaseView, TemplateView): } ) return super().get_context_data(**kwargs) + + +class StatisticsView(DashboardBaseView, LogListView): + template_name = "aircox/dashboard/statistics.html" + date = None + redirect_date_url = "dashboard-statistics" + + # TOOD: test_func & perms check + + def get_object_list(self, logs, full=False): + if not logs.exists(): + logs = LogArchiver().load(self.station, self.date) if self.date else [] + objs = super().get_object_list(logs, True) + return objs diff --git a/aircox/views/episode.py b/aircox/views/episode.py index e94e5d3..8f4d48b 100644 --- a/aircox/views/episode.py +++ b/aircox/views/episode.py @@ -2,7 +2,7 @@ from django.contrib.auth.mixins import UserPassesTestMixin from django.urls import reverse from aircox.models import Episode, Program, StaticPage, Track -from aircox import forms, filters +from aircox import forms, filters, permissions from .mixins import VueFormDataMixin from .page import PageDetailView, PageListView, PageUpdateView @@ -51,8 +51,8 @@ class EpisodeUpdateView(UserPassesTestMixin, VueFormDataMixin, PageUpdateView): template_name = "aircox/episode_form.html" def test_func(self): - obj = self.get_object().parent_subclass - return obj.can_update(self.request.user) + obj = self.get_object() + return permissions.program_permissions.can(self.request.user, "update", obj) def get_tracklist_queryset(self, episode): return Track.objects.filter(episode=episode).order_by("position") diff --git a/aircox/views/log.py b/aircox/views/log.py index 929a6f7..b55725b 100644 --- a/aircox/views/log.py +++ b/aircox/views/log.py @@ -55,6 +55,7 @@ class LogListMixin(GetDateMixin): diffs = self.get_diffusions_queryset() if self.request.user.is_staff and full: return sorted(list(logs) + list(diffs), key=lambda obj: obj.start) + print(">>>>", len(logs), len(diffs), Log.merge_diffusions(logs, diffs)) return Log.merge_diffusions(logs, diffs) diff --git a/aircox/views/program.py b/aircox/views/program.py index da78089..393b4bd 100644 --- a/aircox/views/program.py +++ b/aircox/views/program.py @@ -3,8 +3,7 @@ import random from django.contrib.auth.mixins import UserPassesTestMixin from django.urls import reverse -from aircox.forms import ProgramForm -from aircox.models import Article, Episode, Program, StaticPage +from aircox import models, forms, permissions from .page import PageDetailView, PageListView, PageUpdateView __all__ = ( @@ -14,7 +13,7 @@ __all__ = ( class ProgramDetailView(PageDetailView): - model = Program + model = models.Program def get_related_queryset(self): queryset = ( @@ -30,9 +29,9 @@ class ProgramDetailView(PageDetailView): return reverse("program-list") + f"?category__id={self.object.category_id}" def get_context_data(self, **kwargs): - episodes = Episode.objects.program(self.object).published().order_by("-pub_date") + episodes = models.Episode.objects.program(self.object).published().order_by("-pub_date") podcasts = episodes.with_podcasts() - articles = Article.objects.parent(self.object).published().order_by("-pub_date") + articles = models.Article.objects.parent(self.object).published().order_by("-pub_date") return super().get_context_data( articles=articles[: self.related_count], episodes=episodes[: self.related_count], @@ -45,20 +44,20 @@ class ProgramDetailView(PageDetailView): class ProgramListView(PageListView): - model = Program - attach_to_value = StaticPage.Target.PROGRAMS + model = models.Program + attach_to_value = models.StaticPage.Target.PROGRAMS def get_queryset(self): return super().get_queryset().order_by("title") class ProgramUpdateView(UserPassesTestMixin, PageUpdateView): - model = Program - form_class = ProgramForm + model = models.Program + form_class = forms.ProgramForm def test_func(self): obj = self.get_object() - return obj.can_update(self.request.user) + return permissions.program_permissions.can(self.request.user, "update", obj) def get_success_url(self): return reverse("program-detail", kwargs={"slug": self.get_object().slug}) diff --git a/aircox/viewsets.py b/aircox/viewsets.py index 12ef870..756754f 100644 --- a/aircox/viewsets.py +++ b/aircox/viewsets.py @@ -74,6 +74,12 @@ class TrackROViewSet(viewsets.ReadOnlyModelViewSet): return self.list(request) +class CommentViewSet(viewsets.ModelViewSet): + serializer_class = serializers.CommentSerializer + permission_classes = (permissions.IsAuthenticatedOrReadOnly,) + queryset = models.Comment.objects.all() + + # --- admin class UserSettingsViewSet(viewsets.ViewSet): """User's settings specific to aircox. diff --git a/assets/src/app.js b/assets/src/app.js index daa4e6e..3de02bf 100644 --- a/assets/src/app.js +++ b/assets/src/app.js @@ -15,6 +15,13 @@ const App = { computed: { player() { return window.aircox.player; }, }, + + methods: { + deleteElements(sel) { + for(var el of document.querySelectorAll(sel)) + el.parentNode.removeChild(el) + } + } } export const PlayerApp = { diff --git a/assets/src/components/AActionButton.vue b/assets/src/components/AActionButton.vue index 7e1932c..3caaa3d 100644 --- a/assets/src/components/AActionButton.vue +++ b/assets/src/components/AActionButton.vue @@ -70,11 +70,11 @@ export default { method: this.method, body: JSON.stringify(this.item.data), }) - this.promise = fetch(this.url, options).then(data => { - const response = data.json(); + this.promise = fetch(this.url, options).then(data => data.text()).then(data => { + data = data && JSON.parse(data) || null this.promise = null; - this.$emit('done', response) - return response + this.$emit('done', data) + return data }, data => { this.promise = null; return data }) return this.promise }, diff --git a/assets/src/components/AStatistics.vue b/assets/src/components/AStatistics.vue index e679e4d..deb5f2e 100644 --- a/assets/src/components/AStatistics.vue +++ b/assets/src/components/AStatistics.vue @@ -5,7 +5,7 @@