diff --git a/CHANGELOG.rst b/CHANGELOG.rst index a84ef32e46e3cddfecf361f065ec4c59d89c39cb..bd2d94859e32abf0c17f3b8dd568cd4def22ae72 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -9,12 +9,73 @@ and this project adheres to `Semantic Versioning`_. Unreleased ---------- +Changes +======= +The "managed models" feature is mandatory for all models derived from `ExtensibleModel` +and requires creating a migration for all downstream models to add the respective +field. + Added ~~~~~ +* Frontend for managing rooms. * Introduce Holiday model to track information about holidays. +* [Dev] Components for implementing standard CRUD operations in new frontend. +* [Dev] Options for filtering and sorting of GraphQL queries at the server. +* [Dev] Managed models for instances handled by other apps. +* [Dev] Upload slot sytem for out-of-band uploads in GraphQL clients -Changes +Changed +~~~~~~~ + +* Management of school terms was migrated to new frontend. + +Fixed +~~~~~ + +* [Docker] The build could silently continue even if frontend bundling failed, resulting + in an incomplete AlekSIS frontend app. +* GraphQL mutations did not return errors in case of exceptions. +* Rendering of "simple" PDF templates failed when used with S3 storage. + +`3.1.2`_ - 2023-07-05 +--------------------- + +Changed +~~~~~~~ + +* uWSGI is now installed together with AlekSIS-Core per default. + +Fixed +~~~~~ + +* Notifications were not properly shown in the frontend. +* [Dev] Log levels were not correctly propagated to all loggers +* [Dev] Log format did not contain all essential information +* When navigating from legacy to legacy page, the latter would reload once for no reason. +* The oauth authorization page was not accessible when the service worker was active. +* [Docker] Clear obsolete bundle parts when adding apps using ONBUILD +* Extensible forms that used a subset of fields did not render properly + +`3.1.1`_ - 2023-07-01 +--------------------- + +Fixed +~~~~~ + +* Progress page didn't work properly. +* About page failed to load for apps with an unknown licence. +* QUeries for persons with partial permissions failed. +* Some pages couldn't be scrolled when a task progress popup was open. +* Notification query failed on admin users without persons. +* Querying for notification caused unnecessary database requests. +* Loading bar didn't disappear on some pages after loading was finished. +* Support newer versions of django-oauth-toolkit. + +`3.1`_ - 2023-05-30 +------------------- + +Changed ~~~~~~~ * The frontend is now able to display headings in the main toolbar. @@ -22,16 +83,16 @@ Changes Fixed ~~~~~ -* Default translations from vuetify were not loaded. +* Default translations from Vuetify were not loaded. * Browser locale was not the default locale in the entire frontend. -* In some cases, some items in the sidenav menu were not shown due to its height being higher than the visible page area. -* The search bar in the sidenav menu is shown even though the user has no permission to see it. -* Add permission check to accept invitation menu point in order to hide it when this feature is disabled. +* In some cases, items in the sidenav menu were not shown. +* The search bar in the sidenav menu was shown even though the user had no permission to see it. +* Accept invitation menu item was shown when the invitation feature was disabled. * Metrics endpoint for Prometheus was at the wrong URL. -* Polling behavior of the whoAmI and permission queries was fixed. +* Polling behavior of the whoAmI and permission queries was improved. * Confirmation e-mail contained a wrong link. -`3.0`_ - 2022-05-11 +`3.0`_ - 2023-05-11 ------------------- Added @@ -152,13 +213,13 @@ Changed * Show languages in local language * Rewrite of frontend (base template) using Vuetify - * Frontend bundling migrated from Webpack to Vite (cf. installation docs) - * [Dev] The runuwsgi dev server now starts a Vite dev server with HMR in the - background + * Frontend bundling migrated from Webpack to Vite (cf. installation docs) + * [Dev] The runuwsgi dev server now starts a Vite dev server with HMR in the + background * OIDC scope "profile" now exposes the avatar instead of the official photo * Based on Django 4.0 - * Use built-in Redis cache backend - * Introduce PBKDF2-SHA1 password hashing + * Use built-in Redis cache backend + * Introduce PBKDF2-SHA1 password hashing * Persistent database connections are now health-checked as to not fail requests * [Dev] The undocumented field `check` on `DataCheckResult` was renamed to `data_check` @@ -184,8 +245,8 @@ Removed * iCal feed URLs for birthdays (will be reintroduced later) * [Dev] Django debug toolbar - * It caused major performance issues and is not useful with the new - frontend anymore + * It caused major performance issues and is not useful with the new + frontend anymore `2.12.3`_ - 2023-03-07 ---------------------- @@ -336,9 +397,7 @@ Fixed * The menu button used to be displayed twice on smaller screens. * The icons were loaded from external servers instead from local server. * Weekdays were not translated if system locales were missing - - * Added locales-all to base image and note to docs - + * Added locales-all to base image and note to docs * The icons in the account menu were still the old ones. * Due to a merge error, the once removed account menu in the sidenav appeared again. * Scheduled notifications were shown on dashboard before time. @@ -542,11 +601,9 @@ Changed * Configuration files are now deep merged by default * Improvements for shell_plus module loading - - * core.Group model now takes precedence over auth.Group - * Name collisions are resolved by prefixing with the app label - * Apps can extend SHELL_PLUS_APP_PREFIXES and SHELL_PLUS_DONT_LOAD - + * core.Group model now takes precedence over auth.Group + * Name collisions are resolved by prefixing with the app label + * Apps can extend SHELL_PLUS_APP_PREFIXES and SHELL_PLUS_DONT_LOAD * [Docker] Base image now contains curl, grep, less, sed, and pspg * Views raising a 404 error can now customise the message that is displayed on the error page * OpenID Connect is enabled by default now, without RSA support @@ -1120,50 +1177,53 @@ Fixed .. _Keep a Changelog: https://keepachangelog.com/en/1.0.0/ .. _Semantic Versioning: https://semver.org/spec/v2.0.0.html -.. _1.0a1: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/1.0a1 -.. _1.0a2: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/1.0a2 -.. _1.0a4: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/1.0a4 -.. _2.0a1: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.0a1 -.. _2.0a2: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.0a2 -.. _2.0b0: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.0b0 -.. _2.0b1: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.0b1 -.. _2.0b2: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.0b2 -.. _2.0rc1: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.0rc1 -.. _2.0rc2: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.0rc2 -.. _2.0rc3: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.0rc3 -.. _2.0rc4: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.0rc4 -.. _2.0rc5: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.0rc5 -.. _2.0rc6: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.0rc6 -.. _2.0rc7: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.0rc7 -.. _2.0: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.0 -.. _2.1: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.1 -.. _2.1.1: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.1.1 -.. _2.2: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.2 -.. _2.2.1: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.2.1 -.. _2.3: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.3 -.. _2.3.1: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.3.1 -.. _2.4: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.4 -.. _2.5: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.5 -.. _2.6: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.6 -.. _2.7: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.7 -.. _2.7.1: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.7.1 -.. _2.7.2: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.7.2 -.. _2.7.3: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.7.3 -.. _2.7.4: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.7.4 -.. _2.8: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.8 -.. _2.8.1: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.8.1 -.. _2.9: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.9 -.. _2.10: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.10 -.. _2.10.1: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.10.1 -.. _2.10.2: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.10.2 -.. _2.11: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.11 -.. _2.11.1: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.11.1 -.. _2.12: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.12 -.. _2.12.1: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.12.1 -.. _2.12.2: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.12.2 -.. _2.12.3: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.12.3 -.. _3.0b0: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/3.0b0 -.. _3.0b1: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/3.0b1 -.. _3.0b2: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/3.0b2 -.. _3.0b3: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/3.0b3 -.. _3.0: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/3.0 +.. _1.0a1: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/1.0a1 +.. _1.0a2: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/1.0a2 +.. _1.0a4: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/1.0a4 +.. _2.0a1: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/2.0a1 +.. _2.0a2: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/2.0a2 +.. _2.0b0: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/2.0b0 +.. _2.0b1: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/2.0b1 +.. _2.0b2: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/2.0b2 +.. _2.0rc1: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/2.0rc1 +.. _2.0rc2: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/2.0rc2 +.. _2.0rc3: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/2.0rc3 +.. _2.0rc4: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/2.0rc4 +.. _2.0rc5: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/2.0rc5 +.. _2.0rc6: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/2.0rc6 +.. _2.0rc7: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/2.0rc7 +.. _2.0: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/2.0 +.. _2.1: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/2.1 +.. _2.1.1: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/2.1.1 +.. _2.2: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/2.2 +.. _2.2.1: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/2.2.1 +.. _2.3: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/2.3 +.. _2.3.1: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/2.3.1 +.. _2.4: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/2.4 +.. _2.5: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/2.5 +.. _2.6: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/2.6 +.. _2.7: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/2.7 +.. _2.7.1: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/2.7.1 +.. _2.7.2: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/2.7.2 +.. _2.7.3: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/2.7.3 +.. _2.7.4: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/2.7.4 +.. _2.8: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/2.8 +.. _2.8.1: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/2.8.1 +.. _2.9: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/2.9 +.. _2.10: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/2.10 +.. _2.10.1: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/2.10.1 +.. _2.10.2: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/2.10.2 +.. _2.11: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/2.11 +.. _2.11.1: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/2.11.1 +.. _2.12: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/2.12 +.. _2.12.1: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/2.12.1 +.. _2.12.2: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/2.12.2 +.. _2.12.3: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/2.12.3 +.. _3.0b0: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/3.0b0 +.. _3.0b1: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/3.0b1 +.. _3.0b2: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/3.0b2 +.. _3.0b3: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/3.0b3 +.. _3.0: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/3.0 +.. _3.1: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/3.1 +.. _3.1.1: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/3.1.1 +.. _3.1.2: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/3.1.2 diff --git a/Dockerfile b/Dockerfile index 2134ae81150a13dbab8282149a04df0fb8d20235..4a01dcc62c57fbcef4f56299f39f08d43675fb84 100644 --- a/Dockerfile +++ b/Dockerfile @@ -126,7 +126,7 @@ ONBUILD RUN set -e; \ eatmydata pip install $APPS; \ fi; \ eatmydata aleksis-admin vite build; \ - eatmydata aleksis-admin collectstatic --no-input; \ + eatmydata aleksis-admin collectstatic --no-input --clear; \ rm -rf /usr/local/share/.cache; \ eatmydata apt-get remove --purge -y yarnpkg $BUILD_DEPS; \ eatmydata apt-get autoremove --purge -y; \ diff --git a/aleksis/core/forms.py b/aleksis/core/forms.py index c6c15e76465feb96363eb95f225549d0b65b3d92..e26390e7f7bee01ce8f63cdca699ac770c49be24 100644 --- a/aleksis/core/forms.py +++ b/aleksis/core/forms.py @@ -32,7 +32,6 @@ from .models import ( OAuthApplication, Person, PersonInvitation, - SchoolTerm, ) from .registries import ( group_preferences_registry, @@ -379,16 +378,6 @@ class EditGroupTypeForm(forms.ModelForm): fields = ["name", "description"] -class SchoolTermForm(ExtensibleForm): - """Form for managing school years.""" - - layout = Layout("name", Row("date_start", "date_end")) - - class Meta: - model = SchoolTerm - fields = ["name", "date_start", "date_end"] - - class DashboardWidgetOrderForm(ExtensibleForm): pk = forms.ModelChoiceField( queryset=None, diff --git a/aleksis/core/frontend/app/dateTimeFormats.js b/aleksis/core/frontend/app/dateTimeFormats.js index a0707ae477aecd56b7d18a01c24d7925735c9f85..2cea6431fa0714ebd4fd303adab620bcda94da92 100644 --- a/aleksis/core/frontend/app/dateTimeFormats.js +++ b/aleksis/core/frontend/app/dateTimeFormats.js @@ -33,6 +33,10 @@ const dateTimeFormats = { hour: "numeric", minute: "numeric", }, + shortTime: { + hour: "numeric", + minute: "numeric", + }, }, de: { short: { @@ -67,6 +71,10 @@ const dateTimeFormats = { hour: "numeric", minute: "numeric", }, + shortTime: { + hour: "numeric", + minute: "numeric", + }, }, }; diff --git a/aleksis/core/frontend/app/vuetify.js b/aleksis/core/frontend/app/vuetify.js index e2d4439a6dac88f00af6d7ff57d8bda0b963ea40..56eb8effa2da7cd552ad4aed5455f4acd7911df6 100644 --- a/aleksis/core/frontend/app/vuetify.js +++ b/aleksis/core/frontend/app/vuetify.js @@ -10,9 +10,10 @@ const vuetifyOpts = { icons: { iconfont: "mdi", // default - only for display purposes values: { - cancel: "mdi-close-circle-outline", - delete: "mdi-close-circle-outline", - success: "mdi-check-circle-outline", + cancel: "mdi-close", + delete: "mdi-close", // Not a trashcan due to vuetify using this icon inside chips for closing etc. + deleteContent: "mdi-delete-outline", + success: "mdi-check", info: "mdi-information-outline", warning: "mdi-alert-outline", error: "mdi-alert-octagon-outline", @@ -22,6 +23,11 @@ const vuetifyOpts = { checkboxIndeterminate: "mdi-minus-box-outline", edit: "mdi-pencil-outline", preferences: "mdi-cog-outline", + save: "mdi-content-save-outline", + search: "mdi-magnify", + filterEmpty: "mdi-filter-outline", + filterSet: "mdi-filter", + send: "mdi-send-outline", }, }, }; diff --git a/aleksis/core/frontend/components/LegacyBaseTemplate.vue b/aleksis/core/frontend/components/LegacyBaseTemplate.vue index 790fc10c592bac5acc1e3bbadabcae63e065804f..6dc73749d8bb716bd32089e11afe36f6d93455c9 100644 --- a/aleksis/core/frontend/components/LegacyBaseTemplate.vue +++ b/aleksis/core/frontend/components/LegacyBaseTemplate.vue @@ -21,10 +21,9 @@ </message-box> <iframe v-else - :src="'/django' + $route.path + queryString" + :src="iFrameSrc" :height="iFrameHeight + 'px'" class="iframe-fullsize" - @load="load" ref="contentIFrame" ></iframe> </template> @@ -41,6 +40,7 @@ export default { data: function () { return { iFrameHeight: 0, + iFrameSrc: undefined, }; }, computed: { @@ -53,13 +53,16 @@ export default { }, }, methods: { + getIFrameURL() { + const location = this.$refs.contentIFrame.contentWindow.location; + const url = new URL(location); + return url; + }, /** Handle iframe data after inner page loaded */ load() { // Write new location of iframe back to Vue Router - const location = this.$refs.contentIFrame.contentWindow.location; - const url = new URL(location); - const path = url.pathname.replace(/^\/django/, ""); - const pathWithQueryString = path + encodeURI(url.search); + const path = this.getIFrameURL().pathname.replace(/^\/django/, ""); + const pathWithQueryString = path + encodeURI(this.getIFrameURL().search); const routePath = path.charAt(path.length - 1) === "/" && this.$route.path.charAt(path.length - 1) !== "/" @@ -71,7 +74,9 @@ export default { // Show loader if iframe starts to change its content, even if the $route stays the same this.$refs.contentIFrame.contentWindow.onpagehide = () => { - this.$root.contentLoading = true; + if (this.$root.isLegacyBaseTemplate) { + this.$root.contentLoading = true; + } }; // Write title of iframe to SPA window @@ -101,15 +106,36 @@ export default { }, }, watch: { - $route() { + $route(newRoute) { // Show loading animation once route changes this.$root.contentLoading = true; + // Only reload iFrame content when navigation comes from outsite the iFrame + const path = this.getIFrameURL().pathname.replace(/^\/django/, ""); + const routePath = + path.charAt(path.length - 1) === "/" && + newRoute.path.charAt(path.length - 1) !== "/" + ? newRoute.path + "/" + : newRoute.path; + + if (path !== routePath) { + this.$refs.contentIFrame.contentWindow.location = + "/django" + this.$route.path + this.queryString; + } else { + this.$root.contentLoading = false; + } + // Scroll to top only when route changes to not affect form submits etc. // A small duration to avoid flashing of the UI this.$vuetify.goTo(0, { duration: 10 }); }, }, + mounted() { + this.$refs.contentIFrame.addEventListener("load", (e) => { + this.load(); + }); + this.iFrameSrc = "/django" + this.$route.path + this.queryString; + }, name: "LegacyBaseTemplate", }; </script> diff --git a/aleksis/core/frontend/components/app/App.vue b/aleksis/core/frontend/components/app/App.vue index f650517ae88d122ea6684de14819c4a5f00cc241..52e2801f3953024fd5071679b92ff1a1bb716f01 100644 --- a/aleksis/core/frontend/components/app/App.vue +++ b/aleksis/core/frontend/components/app/App.vue @@ -315,4 +315,9 @@ export default { }; </script> -<style scoped></style> +<style> +div[aria-required="true"] .v-input .v-label::after { + content: " *"; + color: red; +} +</style> diff --git a/aleksis/core/frontend/components/celery_progress/CeleryProgressBottom.vue b/aleksis/core/frontend/components/celery_progress/CeleryProgressBottom.vue index 912fb779241ef577c6f900abc78a26008df008f6..1866c3d819ace7f6d329926f6dbe7bdc3b9b0276 100644 --- a/aleksis/core/frontend/components/celery_progress/CeleryProgressBottom.vue +++ b/aleksis/core/frontend/components/celery_progress/CeleryProgressBottom.vue @@ -1,5 +1,11 @@ <template> - <v-bottom-sheet :value="show" persistent hide-overlay max-width="400px"> + <v-bottom-sheet + :value="show" + persistent + hide-overlay + max-width="400px" + ref="sheet" + > <v-expansion-panels accordion v-model="open"> <v-expansion-panel> <v-expansion-panel-header color="primary" class="white--text px-4"> @@ -33,6 +39,13 @@ export default { data() { return { open: 0 }; }, + mounted() { + // Vuetify uses the hideScroll method to disable scrolling by setting an event listener + // to the window. As event listeners can only be removed by referencing the listener + // method and because vuetify this method is called on every state change of the dialog, + // we simply replace the method in this component instance + this.$refs.sheet.hideScroll = this.$refs.sheet.showScroll; + }, computed: { show() { return this.celeryProgressByUser && this.celeryProgressByUser.length > 0; diff --git a/aleksis/core/frontend/components/generic/InlineCRUDList.vue b/aleksis/core/frontend/components/generic/InlineCRUDList.vue new file mode 100644 index 0000000000000000000000000000000000000000..8a93ff8cc6c7f3a3a0b1f7bbe477f7dae1641b59 --- /dev/null +++ b/aleksis/core/frontend/components/generic/InlineCRUDList.vue @@ -0,0 +1,717 @@ +<template> + <div> + <delete-dialog + v-if="deletionEnabled" + :gql-mutation="gqlDeleteMutation" + :gql-query="$apollo.queries.items" + v-model="deletionDialog" + :item="itemToDelete" + @input="handleDeleteDone" + :item-attribute="itemTitleAttribute" + /> + + <delete-multiple-dialog + v-if="multipleDeletionEnabled" + :gql-mutation="gqlDeleteMultipleMutation" + :gql-query="$apollo.queries.items" + :items="itemsToDelete" + v-model="multipleDeletionDialog" + @input="handleDeleteDone" + :item-attribute="itemTitleAttribute" + /> + + <v-form v-model="valid"> + <v-data-table + :headers="tableHeaders" + :items="editableItems" + :loading="$apollo.loading" + :class="elevationClass" + :items-per-page="15" + :search="search" + :sort-by.sync="sortBy" + :sort-desc.sync="sortDesc" + multi-sort + @update:sort-by="handleSortChange" + @update:sort-desc="handleSortChange" + :show-select="generatedActions.length > 0" + selectable-key="canDelete" + @item-selected="handleItemSelected" + @toggle-select-all="handleToggleAll" + @current-items="checkSelectAll" + > + <template #top> + <v-toolbar flat class="height-fit child-height-fit"> + <v-row class="flex-wrap gap align-baseline pt-4"> + <v-toolbar-title class="d-flex flex-wrap w-100 gap"> + <filter-button + class="my-1 button-40" + :num-filters="numFilters" + v-if="filter" + @click="requestFilter" + @clear="clearFilters" + /> + + <filter-dialog + v-model="filterDialog" + :filters="filters" + @filters="handleFiltersChanged" + > + <template #default="slotProps"> + <slot + name="filters" + v-if="filter" + v-bind="slotProps" + ></slot> + </template> + </filter-dialog> + + <div class="my-1"> + <v-text-field + v-model="search" + type="search" + clearable + flat + filled + hide-details + single-line + prepend-inner-icon="$search" + dense + outlined + :label="$t('actions.search')" + ></v-text-field> + </div> + + <div + v-if="generatedActions.length > 0 && selectedItems.length > 0" + class="my-1" + > + <v-autocomplete + auto-select-first + clearable + :items="generatedActions" + v-model="selectedAction" + return-object + :label="$t('actions.select_action')" + item-text="name" + outlined + dense + :hint=" + $tc('selection.num_items_selected', selectedItems.length) + " + persistent-hint + append-outer-icon="$send" + @click:append-outer="handleAction" + > + <template #item="{ item, attrs, on }"> + <v-list-item dense v-bind="attrs" v-on="on"> + <v-list-item-icon v-if="item.icon"> + <v-icon>{{ item.icon }}</v-icon> + </v-list-item-icon> + <v-list-item-content> + <v-list-item-title>{{ item.name }}</v-list-item-title> + </v-list-item-content> + </v-list-item> + </template> + </v-autocomplete> + </div> + </v-toolbar-title> + + <v-spacer + class="flex-grow-0 flex-sm-grow-1 mx-n1 mx-sm-0" + ></v-spacer> + <slot + v-if="!editMode && showCreate" + name="createComponent" + :attrs="{ + value: createMode, + getCreateData: getCreateData, + defaultItem: defaultItem, + gqlQuery: gqlQuery, + gqlCreateMutation: gqlCreateMutation, + gqlPatchMutation: gqlPatchMutation, + isCreate: true, + fields: editableHeaders, + createItemI18nKey: createItemI18nKey, + }" + :on="{ + input: (i) => (i ? requestCreate() : null), + cancel: cancelCreate, + save: handleCreateDone, + error: handleError, + }" + :create-mode="createMode" + :form-field-slot-name="formFieldSlotName" + > + <dialog-object-form + v-model="createMode" + :get-create-data="getCreateData" + :default-item="defaultItem" + :gql-create-mutation="gqlCreateMutation" + :gql-patch-mutation="gqlPatchMutation" + :is-create="true" + :fields="editableHeaders" + :create-item-i18n-key="createItemI18nKey" + @cancel="cancelCreate" + @save="handleCreateDone" + @error="handleError" + > + <template #activator="{ props }"> + <create-button + color="secondary" + @click="requestCreate" + :disabled="createMode" + /> + </template> + + <template + v-for="header in editableHeaders" + #[formFieldSlotName(header)]="{ item, isCreate, on, attrs }" + > + <slot + :name="formFieldSlotName(header)" + :attrs="attrs" + :on="on" + :item="item" + :is-create="isCreate" + /> + </template> + </dialog-object-form> + </slot> + <edit-button + v-if="!editMode && editingEnabled" + @click="requestEdit" + :disabled="createMode" + /> + <cancel-button v-if="editMode" @click="cancelEdit" /> + <save-button + v-if="editMode" + @click="saveEdit" + :loading="loading" + :disabled="!valid" + /> + </v-row> + </v-toolbar> + </template> + + <template + v-for="(header, idx) in headers" + #[tableItemSlotName(header)]="{ item }" + > + <v-scroll-x-transition mode="out-in" :key="idx"> + <span key="value" v-if="!editMode || header.disableEdit"> + <slot :name="header.value" :item="item">{{ + item[header.value] + }}</slot> + </span> + <span key="field" v-else-if="editMode"> + <slot + :name="header.value + '.field'" + :item="item" + :is-create="false" + :attrs="buildAttrs(item[header.value])" + :on="buildOn(dynamicSetter(item, header.value))" + > + <v-text-field + filled + dense + hide-details="auto" + v-model="item[header.value]" + ></v-text-field> + </slot> + </span> + </v-scroll-x-transition> + </template> + + <!-- eslint-disable-next-line vue/valid-v-slot --> + <template #item.actions="{ item }"> + <slot name="actions" :item="item" /> + <v-btn + v-if="'canDelete' in item && item.canDelete" + icon + :title="$t(`actions.delete`)" + color="error" + @click="handleDeleteClick(item)" + > + <v-icon>$deleteContent</v-icon> + </v-btn> + </template> + </v-data-table> + </v-form> + + <closable-snackbar :color="snackbarState" v-model="snackbar"> + {{ snackbarText }} + </closable-snackbar> + </div> +</template> + +<script> +import CreateButton from "./buttons/CreateButton.vue"; +import EditButton from "./buttons/EditButton.vue"; +import SaveButton from "./buttons/SaveButton.vue"; +import CancelButton from "./buttons/CancelButton.vue"; +import DeleteDialog from "./dialogs/DeleteDialog.vue"; +import DeleteMultipleDialog from "./dialogs/DeleteMultipleDialog.vue"; +import DialogObjectForm from "./dialogs/DialogObjectForm.vue"; +import ClosableSnackbar from "./dialogs/ClosableSnackbar.vue"; +import FilterButton from "./buttons/FilterButton.vue"; +import FilterDialog from "./dialogs/FilterDialog.vue"; + +export default { + name: "InlineCRUDList", + components: { + FilterDialog, + FilterButton, + ClosableSnackbar, + DeleteDialog, + DeleteMultipleDialog, + DialogObjectForm, + CancelButton, + SaveButton, + EditButton, + CreateButton, + }, + + apollo: { + items() { + return { + query: this.gqlQuery, + variables() { + return { + orderBy: this.gqlOrderBy, + filters: this.filterString, + }; + }, + error: function (error) { + this.handleError(error); + }, + result: function (data) { + this.editableItems = data.data + ? this.getGqlData(JSON.parse(JSON.stringify(data.data.items))) + : []; + }, + }; + }, + }, + data() { + return { + editMode: false, + createMode: false, + loading: false, + createModel: {}, + editableItems: [], + snackbar: false, + snackbarText: null, + snackbarState: "success", + valid: false, + deletionDialog: false, + multipleDeletionDialog: false, + itemToDelete: null, + itemsToDelete: [], + search: "", + filterDialog: false, + filters: {}, + filterString: "{}", + sortBy: [], + sortDesc: [], + gqlOrderBy: [], + selectedAction: null, + selectedItems: [], + allSelected: false, + }; + }, + computed: { + tableHeaders() { + return this.headers + .concat( + this.deletionEnabled + ? [ + { + text: this.$t("actions.title"), + value: "actions", + sortable: false, + align: "right", + }, + ] + : [] + ) + .filter((header) => this.hiddenColumns.indexOf(header.value) === -1); + }, + editableHeaders() { + return this.headers.filter((header) => !header.disableEdit); + }, + elevationClass() { + return this.elevated ? "elevation-2" : ""; + }, + editingEnabled() { + return ( + this.gqlPatchMutation && this.items && this.items.some((i) => i.canEdit) + ); + }, + deletionEnabled() { + return ( + this.gqlDeleteMutation && + this.items && + this.items.some((i) => i.canDelete) + ); + }, + multipleDeletionEnabled() { + return ( + this.multipleDeletion && + this.gqlDeleteMultipleMutation && + this.items && + this.items.some((i) => i.canDelete) + ); + }, + numFilters() { + // This needs to use the json string, as vue reactivity doesn't work for objects with dynamic properties + return Object.keys(JSON.parse(this.filterString)).length; + }, + generatedActions() { + if (!this.multipleDeletionEnabled) { + return this.actions; + } + return [ + ...this.actions, + { + name: this.$t("actions.delete"), + icon: "$deleteContent", + handler: (items) => { + this.itemsToDelete = items; + this.multipleDeletionDialog = true; + }, + clearSelection: true, + }, + ]; + }, + }, + props: { + i18nKey: { + type: String, + required: true, + }, + createItemI18nKey: { + type: String, + required: false, + default: "actions.create", + }, + createSuccessMessageKey: { + type: String, + required: false, + default: "status.object_create_success", + }, + gqlQuery: { + type: Object, + required: true, + }, + gqlCreateMutation: { + type: Object, + required: false, + default: undefined, + }, + gqlPatchMutation: { + type: Object, + required: false, + default: undefined, + }, + gqlDeleteMutation: { + type: Object, + required: false, + default: undefined, + }, + gqlDeleteMultipleMutation: { + type: Object, + required: false, + default: undefined, + }, + headers: { + type: Array, + required: true, + }, + itemTitleAttribute: { + type: String, + required: false, + default: "name", + }, + defaultItem: { + type: Object, + required: true, + }, + showCreate: { + type: Boolean, + required: false, + default: true, + }, + elevated: { + type: Boolean, + required: false, + default: true, + }, + hiddenColumns: { + type: Array, + required: false, + default: () => [], + }, + getGqlData: { + type: Function, + required: false, + default: (item) => item, + }, + getPatchData: { + type: Function, + required: false, + default: (items, headers) => { + return items.map((item) => { + let dto = {}; + headers.map((header) => (dto[header.value] = item[header.value])); + return dto; + }); + }, + }, + getCreateData: { + type: Function, + required: false, + default: (item) => item, + }, + filter: { + type: Boolean, + required: false, + default: false, + }, + actions: { + type: Array, + required: false, + default: () => [], + }, + multipleDeletion: { + type: Boolean, + required: false, + default: true, + }, + }, + methods: { + requestCreate() { + if (this.loading) return; + + this.createMode = true; + this.editMode = false; + }, + requestEdit() { + if (this.loading) return; + + this.editMode = true; + this.createMode = false; + }, + saveEdit() { + this.loading = true; + + if (!this.editableItems || !this.editingEnabled) return; + + this.$apollo + .mutate({ + mutation: this.gqlPatchMutation, + variables: { + input: this.getPatchData( + this.editableItems, + this.headers.concat({ title: "id", value: "id" }) + ), + }, + }) + .then((data) => { + this.items = data.data.batchMutation.items; + this.editableItems = this.getGqlData(data.data.batchMutation.items); + + this.handleSuccess("status.saved"); + }) + .catch((error) => { + this.handleError(error); + }) + .finally(() => { + this.loading = false; + this.editMode = false; + }); + }, + cancelEdit() { + this.editMode = false; + this.editableItems = this.getGqlData( + JSON.parse(JSON.stringify(this.items)) + ); + }, + saveCreate() { + if (!this.gqlCreateMutation) return; + + this.loading = true; + this.$apollo + .mutate({ + mutation: this.gqlCreateMutation, + variables: { + input: this.createModel, + }, + }) + .then((data) => { + this.$apollo.queries.items.refetch(); + this.createModel = {}; + }) + .catch((error) => { + this.handleError(error); + }) + .finally(() => { + this.loading = false; + this.createMode = false; + }); + }, + cancelCreate() { + this.createMode = false; + this.createModel = {}; + }, + tableItemSlotName(headerEntry) { + return "item." + headerEntry.value; + }, + formFieldSlotName(headerEntry) { + return headerEntry.value + ".field"; + }, + dynamicSetter(item, fieldName) { + return (value) => { + this.$set(item, fieldName, value); + }; + }, + handleError(error) { + console.error(error); + if (error instanceof String) { + // error is a translation key or simply a string + this.snackbarText = this.$t(error); + } else if (error instanceof Object && error.message) { + this.snackbarText = error.message; + } else { + this.snackbarText = this.$t("graphql.snackbar_error_message"); + } + this.snackbarState = "error"; + this.snackbar = true; + }, + handleSuccess(success) { + this.snackbarText = this.$t( + success || "graphql.snackbar_success_message" + ); + + this.snackbarState = "success"; + this.snackbar = true; + }, + handleDeleteClick(item) { + if (!item) { + console.warn("Delete handler called without item parameter"); + return; + } + + this.itemToDelete = item; + this.deletionDialog = true; + }, + handleDeleteDone() { + this.itemToDelete = null; + this.itemsToDelete = []; + }, + handleCreateDone() { + this.$apollo.queries.items.refetch(); + this.createMode = false; + }, + requestFilter() { + if (this.filter) { + this.filterDialog = true; + } + }, + handleFiltersChanged(event) { + this.filters = event; + this.filterString = JSON.stringify(this.filters); + }, + clearFilters() { + this.handleFiltersChanged({}); + }, + buildAttrs(value) { + return { + dense: true, + filled: true, + hideDetails: "auto", + value: value, + inputValue: value, + }; + }, + buildOn(setter) { + return { + input: setter, + change: setter, + }; + }, + snakeCase(string) { + return string + .replace(/\W+/g, " ") + .split(/ |\B(?=[A-Z])/) + .map((word) => word.toLowerCase()) + .join("_"); + }, + orderKey(value, desc) { + const key = + this.headers.find((header) => header.value === value).orderKey || + this.snakeCase(value); + return (desc ? "-" : "") + key; + }, + handleSortChange() { + this.gqlOrderBy = this.sortBy.map((value, key) => + this.orderKey(value, this.sortDesc[key]) + ); + }, + handleItemSelected({ item, value }) { + if (value) { + this.selectedItems.push(item); + } else { + const index = this.selectedItems.indexOf(item); + if (index >= 0) { + this.selectedItems.splice(index, 1); + } + } + }, + handleToggleAll({ items, value }) { + if (value) { + // There is a bug in vuetify: items contains all elements, even those that aren't selectable + this.selectedItems = items.filter((item) => item.canDelete || false); + } else { + this.selectedItems = []; + } + this.allSelected = value; + }, + checkSelectAll(newItems) { + if (this.allSelected) { + this.handleToggleAll({ + items: newItems, + value: true, + }); + } + }, + handleAction() { + if (this.selectedAction) { + this.selectedAction.handler(this.selectedItems); + + if (this.selectedAction.clearSelection) { + this.selectedItems = []; + } + + this.selectedAction = null; + } + }, + }, + mounted() { + this.$setToolBarTitle(this.$t(`${this.i18nKey}.title_plural`), null); + }, +}; +</script> + +<style> +.gap { + gap: 0.5rem; +} +.height-fit, +.child-height-fit > * { + height: fit-content !important; +} + +.button-40 { + min-height: 40px; +} +</style> diff --git a/aleksis/core/frontend/components/generic/ObjectCRUDList.vue b/aleksis/core/frontend/components/generic/ObjectCRUDList.vue new file mode 100644 index 0000000000000000000000000000000000000000..be792e3d825ec0d98c54a21b81f9b3a385f82203 --- /dev/null +++ b/aleksis/core/frontend/components/generic/ObjectCRUDList.vue @@ -0,0 +1,331 @@ +<script setup> +import CreateButton from "./buttons/CreateButton.vue"; +import DialogObjectForm from "./dialogs/DialogObjectForm.vue"; +</script> + +<template> + <v-card> + <v-data-iterator + :items="items" + :items-per-page="itemsPerPage" + :loading="$apollo.queries.items.loading" + hide-default-footer + > + <template #loading> + <slot name="loading"> + <v-skeleton-loader + type="card-heading, list-item-avatar-two-line@3, actions" + /> + </slot> + </template> + + <template #no-data> + <v-card-text>{{ $t(noItemsI18nKey) }}</v-card-text> + </template> + + <template #header> + <v-card-title>{{ title }}</v-card-title> + </template> + + <template #default="props"> + <slot + v-if="items.length" + name="iteratorContent" + :items="props.items" + :editing-enabled="editingEnabled" + :deletion-enabled="deletionEnabled" + :handle-edit="handleEdit" + :handle-delete="handleDelete" + > + <v-list> + <template v-for="(item, index) in items"> + <v-list-item :key="item.id"> + <v-list-item-avatar> + <slot + name="listIteratorItemAvatar" + :item="item" + :index="index" + /> + </v-list-item-avatar> + <v-list-item-content> + <slot + name="listIteratorItemContent" + :item="item" + :index="index" + > + <v-list-item-title> + {{ item.name }} + </v-list-item-title> + </slot> + </v-list-item-content> + <v-list-item-action> + <v-btn + v-if="editingEnabled && item.canEdit" + icon + @click="handleEdit(item)" + > + <v-icon>mdi-pencil-outline</v-icon> + </v-btn> + <v-btn + v-if="deletionEnabled && item.canDelete" + icon + @click="handleDelete(item)" + > + <v-icon>mdi-delete-outline</v-icon> + </v-btn> + </v-list-item-action> + </v-list-item> + <v-divider + v-if="index < items.length - 1" + :key="index" + inset + ></v-divider> + </template> + </v-list> + </slot> + </template> + + <template #footer> + <v-card-actions> + <slot + v-if="creatingEnabled || editingEnabled" + name="createComponent" + :attrs="{ + value: objectFormModel, + defaultItem: defaultItem, + editItem: editItem, + gqlCreateMutation: gqlCreateMutation, + gqlPatchMutation: gqlPatchMutation, + isCreate: isCreate, + fields: fields, + getCreateData: getCreateData, + createItemI18nKey: createItemI18nKey, + }" + :on="{ + input: (i) => (objectFormModel = i), + cancel: () => (objectFormModel = false), + save: handleCreateDone, + error: handleError, + }" + > + <dialog-object-form + v-model="objectFormModel" + :get-create-data="getCreateData" + :get-patch-data="getPatchData" + :default-item="defaultItem" + :edit-item="editItem" + :force-model-item-update="true" + :gql-create-mutation="gqlCreateMutation" + :gql-patch-mutation="gqlPatchMutation" + :is-create="isCreate" + :fields="fields" + :create-item-i18n-key="createItemI18nKey" + @cancel="objectFormModel = false" + @save="handleCreateDone" + @error="handleError" + > + <template #activator="{ props }" v-if="creatingEnabled"> + <create-button + @click="handleCreate" + :disabled="objectFormModel" + /> + </template> + + <template + v-for="field in fields" + #[formFieldSlotName(field)]="{ item, isCreate, on, attrs }" + > + <slot + :name="formFieldSlotName(field)" + :attrs="attrs" + :on="on" + :item="item" + :is-create="isCreate" + /> + </template> + </dialog-object-form> + </slot> + </v-card-actions> + </template> + </v-data-iterator> + </v-card> +</template> + +<script> +export default { + name: "ObjectCRUDList", + props: { + titleI18nKey: { + type: String, + required: false, + default: "", + }, + titleString: { + type: String, + required: false, + default: "", + }, + noItemsI18nKey: { + type: String, + required: true, + }, + createItemI18nKey: { + type: String, + required: false, + default: "actions.create", + }, + fields: { + type: Array, + required: false, + default: undefined, + }, + defaultItem: { + type: Object, + required: false, + default: undefined, + }, + getGqlData: { + type: Function, + required: false, + default: (data) => data.items, + }, + gqlQuery: { + type: Object, + required: true, + }, + gqlVariables: { + type: Object, + required: false, + default: undefined, + }, + gqlCreateMutation: { + type: Object, + required: false, + default: undefined, + }, + gqlPatchMutation: { + type: Object, + required: false, + default: undefined, + }, + gqlDeleteMutation: { + type: Object, + required: false, + default: undefined, + }, + getCreateData: { + type: Function, + required: false, + default: (item) => item, + }, + getPatchData: { + type: Function, + required: false, + default: (item) => { + let { id, __typename, ...patchItem } = item; + return patchItem; + }, + }, + itemsPerPage: { + type: Number, + required: false, + default: 5, + }, + }, + components: { + CreateButton, + }, + data() { + return { + objectFormModel: false, + editItem: undefined, + isCreate: true, + }; + }, + apollo: { + items() { + return { + query: this.gqlQuery, + variables() { + if (this.gqlVariables) { + return this.gqlVariables; + } + return {}; + }, + error: function (error) { + this.handleError(error); + }, + update(data) { + return this.getGqlData(data); + }, + }; + }, + }, + methods: { + handleCreate() { + this.editItem = undefined; + this.isCreate = true; + this.objectFormModel = true; + }, + handleEdit(item) { + if (!item || !this.editingEnabled) { + return; + } + + this.editItem = item; + this.isCreate = false; + this.objectFormModel = true; + }, + handleDelete() {}, + handleCreateDone() { + this.$apollo.queries.items.refetch(); + }, + handleError(error) { + console.error(error); + let snackbarText = ""; + if (error instanceof String) { + // error is a translation key or simply a string + snackbarText = this.$t(error); + } else if (error instanceof Object && error.message) { + snackbarText = error.message; + } else { + snackbarText = this.$t("graphql.snackbar_error_message"); + } + this.$root.snackbarItems.push({ + id: crypto.randomUUID(), + timeout: 5000, + messageKey: snackbarText, + color: "error", + }); + }, + formFieldSlotName(headerEntry) { + return headerEntry.value + ".field"; + }, + }, + computed: { + creatingEnabled() { + return this.gqlCreateMutation && this.fields && this.defaultItem; + }, + editingEnabled() { + return ( + this.gqlPatchMutation && + this.fields && + this.items && + this.items.some((i) => i.canEdit) + ); + }, + deletionEnabled() { + return ( + this.gqlDeleteMutation && + this.items && + this.items.some((i) => i.canDelete) + ); + }, + title() { + return this.titleI18nKey ? this.$t(this.titleI18nKey) : this.titleString; + }, + }, +}; +</script> + +<style scoped></style> diff --git a/aleksis/core/frontend/components/generic/buttons/BaseButton.vue b/aleksis/core/frontend/components/generic/buttons/BaseButton.vue new file mode 100644 index 0000000000000000000000000000000000000000..5dd63039fe22d0d57534eb2f62f039ea101f2897 --- /dev/null +++ b/aleksis/core/frontend/components/generic/buttons/BaseButton.vue @@ -0,0 +1,29 @@ +<template> + <v-btn v-bind="{ ...$props, ...$attrs }" @click="$emit('click', $event)"> + <slot> + <v-icon v-if="iconText" left>{{ iconText }}</v-icon> + <span v-t="i18nKey" /> + </slot> + </v-btn> +</template> + +<script> +export default { + name: "BaseButton", + inheritAttrs: true, + extends: "v-btn", + props: { + i18nKey: { + type: String, + required: true, + }, + iconText: { + type: String, + required: false, + default: undefined, + }, + }, +}; +</script> + +<style scoped></style> diff --git a/aleksis/core/frontend/components/generic/buttons/CancelButton.vue b/aleksis/core/frontend/components/generic/buttons/CancelButton.vue new file mode 100644 index 0000000000000000000000000000000000000000..2d12aa4576106a3bce51f77bfd8e0d401f2ab16a --- /dev/null +++ b/aleksis/core/frontend/components/generic/buttons/CancelButton.vue @@ -0,0 +1,25 @@ +<script> +import SecondaryActionButton from "./SecondaryActionButton.vue"; + +export default { + name: "CancelButton", + extends: SecondaryActionButton, + props: { + iconText: { + type: String, + required: false, + default: "$cancel", + }, + i18nKey: { + type: String, + required: false, + default: "actions.cancel", + }, + color: { + type: String, + required: false, + default: "error", + }, + }, +}; +</script> diff --git a/aleksis/core/frontend/components/generic/buttons/CreateButton.vue b/aleksis/core/frontend/components/generic/buttons/CreateButton.vue new file mode 100644 index 0000000000000000000000000000000000000000..6a03704c176f9bfaf952a1529cbad4ed6c996846 --- /dev/null +++ b/aleksis/core/frontend/components/generic/buttons/CreateButton.vue @@ -0,0 +1,20 @@ +<script> +import PrimaryActionButton from "./PrimaryActionButton.vue"; + +export default { + name: "CreateButton", + extends: PrimaryActionButton, + props: { + iconText: { + type: String, + required: false, + default: "$plus", + }, + i18nKey: { + type: String, + required: false, + default: "actions.create", + }, + }, +}; +</script> diff --git a/aleksis/core/frontend/components/generic/buttons/DeleteButton.vue b/aleksis/core/frontend/components/generic/buttons/DeleteButton.vue new file mode 100644 index 0000000000000000000000000000000000000000..d742345b799e20d860cca01796f75d85e0d0d6a5 --- /dev/null +++ b/aleksis/core/frontend/components/generic/buttons/DeleteButton.vue @@ -0,0 +1,20 @@ +<script> +import PrimaryActionButton from "./PrimaryActionButton.vue"; + +export default { + name: "DeleteButton", + extends: PrimaryActionButton, + props: { + iconText: { + type: String, + required: false, + default: "$delete", + }, + i18nKey: { + type: String, + required: false, + default: "actions.delete", + }, + }, +}; +</script> diff --git a/aleksis/core/frontend/components/generic/buttons/EditButton.vue b/aleksis/core/frontend/components/generic/buttons/EditButton.vue new file mode 100644 index 0000000000000000000000000000000000000000..45c6918bfb18c19796e5a6e3fc8ccca9e1542b65 --- /dev/null +++ b/aleksis/core/frontend/components/generic/buttons/EditButton.vue @@ -0,0 +1,20 @@ +<script> +import PrimaryActionButton from "./PrimaryActionButton.vue"; + +export default { + name: "EditButton", + extends: PrimaryActionButton, + props: { + iconText: { + type: String, + required: false, + default: "$edit", + }, + i18nKey: { + type: String, + required: false, + default: "actions.edit", + }, + }, +}; +</script> diff --git a/aleksis/core/frontend/components/generic/buttons/FilterButton.vue b/aleksis/core/frontend/components/generic/buttons/FilterButton.vue new file mode 100644 index 0000000000000000000000000000000000000000..5b7a2435dcd11b2ee29f178f34c02a0138318453 --- /dev/null +++ b/aleksis/core/frontend/components/generic/buttons/FilterButton.vue @@ -0,0 +1,55 @@ +<template> + <secondary-action-button + v-bind="$attrs" + v-on="$listeners" + :i18n-key="i18nKey" + > + <v-icon v-if="icon" left>{{ icon }}</v-icon> + <v-badge color="secondary" :value="numFilters" :content="numFilters" inline> + <span v-t="i18nKey" /> + </v-badge> + <v-btn + icon + @click.stop="$emit('clear')" + small + v-if="numFilters" + class="mr-n1" + > + <v-icon>$clear</v-icon> + </v-btn> + </secondary-action-button> +</template> + +<script> +import SecondaryActionButton from "./SecondaryActionButton.vue"; + +export default { + name: "FilterButton", + components: { SecondaryActionButton }, + extends: SecondaryActionButton, + computed: { + icon() { + return this.hasFilters || this.numFilters > 0 + ? "$filterSet" + : "$filterEmpty"; + }, + }, + props: { + i18nKey: { + type: String, + required: false, + default: "actions.filter", + }, + hasFilters: { + type: Boolean, + required: false, + default: false, + }, + numFilters: { + type: Number, + required: false, + default: 0, + }, + }, +}; +</script> diff --git a/aleksis/core/frontend/components/generic/buttons/PrimaryActionButton.vue b/aleksis/core/frontend/components/generic/buttons/PrimaryActionButton.vue new file mode 100644 index 0000000000000000000000000000000000000000..b33e8fe073a0e8b62b03fe593564b91a8e375ebb --- /dev/null +++ b/aleksis/core/frontend/components/generic/buttons/PrimaryActionButton.vue @@ -0,0 +1,19 @@ +<script> +import BaseButton from "./BaseButton.vue"; +export default { + name: "PrimaryActionButton", + components: { BaseButton }, + extends: BaseButton, + props: { + i18nKey: { + type: String, + required: true, + }, + color: { + type: String, + required: false, + default: "primary", + }, + }, +}; +</script> diff --git a/aleksis/core/frontend/components/generic/buttons/SaveButton.vue b/aleksis/core/frontend/components/generic/buttons/SaveButton.vue new file mode 100644 index 0000000000000000000000000000000000000000..16380f83f56c965c1eacbc66f541c0020fedb6fd --- /dev/null +++ b/aleksis/core/frontend/components/generic/buttons/SaveButton.vue @@ -0,0 +1,20 @@ +<script> +import PrimaryActionButton from "./PrimaryActionButton.vue"; + +export default { + name: "SaveButton", + extends: PrimaryActionButton, + props: { + iconText: { + type: String, + required: false, + default: "$save", + }, + i18nKey: { + type: String, + required: false, + default: "actions.save", + }, + }, +}; +</script> diff --git a/aleksis/core/frontend/components/generic/buttons/SecondaryActionButton.vue b/aleksis/core/frontend/components/generic/buttons/SecondaryActionButton.vue new file mode 100644 index 0000000000000000000000000000000000000000..209d10d102e4f45a26efa72e03c1d00b39378caf --- /dev/null +++ b/aleksis/core/frontend/components/generic/buttons/SecondaryActionButton.vue @@ -0,0 +1,24 @@ +<script> +import BaseButton from "./BaseButton.vue"; +export default { + name: "SecondaryActionButton", + components: { BaseButton }, + extends: BaseButton, + props: { + i18nKey: { + type: String, + required: true, + }, + color: { + type: String, + required: false, + default: "secondary", + }, + outlined: { + type: Boolean, + required: false, + default: true, + }, + }, +}; +</script> diff --git a/aleksis/core/frontend/components/generic/dialogs/ClosableSnackbar.vue b/aleksis/core/frontend/components/generic/dialogs/ClosableSnackbar.vue new file mode 100644 index 0000000000000000000000000000000000000000..16fd418c34bbc3f3ffe3549e3a86635ffd45ec11 --- /dev/null +++ b/aleksis/core/frontend/components/generic/dialogs/ClosableSnackbar.vue @@ -0,0 +1,24 @@ +<template> + <v-snackbar v-bind="$attrs" v-on="$listeners"> + <slot /> + <template #action="{ attrs }"> + <v-btn v-bind="attrs" @click="close()" icon> + <v-icon>$close</v-icon> + </v-btn> + </template> + </v-snackbar> +</template> + +<script> +export default { + name: "ClosableSnackbar", + extends: "v-snackbar", + methods: { + close() { + this.$emit("input", false); + }, + }, +}; +</script> + +<style scoped></style> diff --git a/aleksis/core/frontend/components/generic/dialogs/DeleteDialog.vue b/aleksis/core/frontend/components/generic/dialogs/DeleteDialog.vue index a56e3960cd1ae29e60f966a44ae69adeb0332949..4824d2e8f6eaf3c5046de9611b6bf47458ceb195 100644 --- a/aleksis/core/frontend/components/generic/dialogs/DeleteDialog.vue +++ b/aleksis/core/frontend/components/generic/dialogs/DeleteDialog.vue @@ -1,3 +1,9 @@ +<script setup> +import CancelButton from "../buttons/CancelButton.vue"; +import DeleteButton from "../buttons/DeleteButton.vue"; +import MobileFullscreenDialog from "./MobileFullscreenDialog.vue"; +</script> + <template> <ApolloMutation v-if="dialogOpen" @@ -7,39 +13,29 @@ @done="close(true)" > <template #default="{ mutate, loading, error }"> - <v-dialog v-model="dialogOpen" max-width="500px"> - <v-card> - <v-card-title class="text-h5"> - <slot name="title"> - {{ $t("actions.confirm_deletion") }} - </slot> - </v-card-title> - <v-card-text> - <slot name="body"> - <p class="text-body-1">{{ nameOfObject }}</p> + <mobile-fullscreen-dialog v-model="dialogOpen"> + <template #title> + <slot name="title"> + {{ $t("actions.confirm_deletion") }} + </slot> + </template> + <template #content> + <slot name="body"> + <p class="text-body-1">{{ nameOfObject }}</p> + </slot> + </template> + <template #actions> + <cancel-button @click="close(false)" :disabled="loading"> + <slot name="cancelContent"> + <v-icon left>$cancel</v-icon> + {{ $t("actions.cancel") }} </slot> - </v-card-text> - <v-card-actions> - <v-spacer></v-spacer> - <v-btn text @click="close(false)" :disabled="loading"> - <slot name="cancelContent"> - {{ $t("actions.cancel") }} - </slot> - </v-btn> - <v-btn - color="error" - text - @click="mutate" - :loading="loading" - :disabled="loading" - > - <slot name="deleteContent"> - {{ $t("actions.delete") }} - </slot> - </v-btn> - </v-card-actions> - </v-card> - </v-dialog> + </cancel-button> + <delete-button @click="mutate" :loading="loading" :disabled="loading"> + <slot name="deleteContent" /> + </delete-button> + </template> + </mobile-fullscreen-dialog> <v-snackbar :value="error !== null"> {{ error }} @@ -71,16 +67,27 @@ export default { this.$emit("input", val); }, }, + query() { + if ("options" in this.gqlQuery) { + return { + ...this.gqlQuery.options, + variables: JSON.parse(this.gqlQuery.previousVariablesJson), + }; + } + return { query: this.gqlQuery }; + }, }, methods: { update(store) { + this.$emit("update", store); + if (!this.gqlQuery) { // There is no GraphQL query to update return; } // Read the data from cache for query - const storedData = store.readQuery({ query: this.gqlQuery }); + const storedData = store.readQuery(this.query); if (!storedData) { // There are no data in the cache yet @@ -96,12 +103,19 @@ export default { storedData[storedDataKey].splice(index, 1); // Write data back to the cache - store.writeQuery({ query: this.gqlQuery, data: storedData }); + store.writeQuery({ ...this.query, data: storedData }); }, close(success) { this.$emit("input", false); if (success) { this.$emit("success"); + + this.$root.snackbarItems.push({ + id: crypto.randomUUID(), + timeout: 5000, + messageKey: this.$t(this.deleteSuccessMessageI18nKey), + color: "success", + }); } else { this.$emit("cancel"); } @@ -131,6 +145,11 @@ export default { required: false, default: null, }, + deleteSuccessMessageI18nKey: { + type: String, + required: false, + default: "status.object_delete_success", + }, }, }; </script> diff --git a/aleksis/core/frontend/components/generic/dialogs/DeleteMultipleDialog.vue b/aleksis/core/frontend/components/generic/dialogs/DeleteMultipleDialog.vue new file mode 100644 index 0000000000000000000000000000000000000000..1e09c18ba6a517a6c053c2faac09e35bbfc62f71 --- /dev/null +++ b/aleksis/core/frontend/components/generic/dialogs/DeleteMultipleDialog.vue @@ -0,0 +1,161 @@ +<script setup> +import CancelButton from "../buttons/CancelButton.vue"; +import DeleteButton from "../buttons/DeleteButton.vue"; +import MobileFullscreenDialog from "./MobileFullscreenDialog.vue"; +</script> + +<template> + <ApolloMutation + v-if="dialogOpen" + :mutation="gqlMutation" + :variables="{ ids: ids }" + :update="update" + @done="close(true)" + > + <template #default="{ mutate, loading, error }"> + <mobile-fullscreen-dialog v-model="dialogOpen"> + <template #title> + <slot name="title"> + {{ $t("actions.confirm_deletion_multiple") }} + </slot> + </template> + <template #content> + <slot name="body"> + <ul class="text-body-1"> + <li v-for="(item, idx) in items" :key="idx"> + {{ nameOfItem(item) }} + </li> + </ul> + </slot> + </template> + <template #actions> + <cancel-button @click="close(false)" :disabled="loading"> + <slot name="cancelContent"> + <v-icon left>$cancel</v-icon> + {{ $t("actions.cancel") }} + </slot> + </cancel-button> + <delete-button @click="mutate" :loading="loading" :disabled="loading"> + <slot name="deleteContent" /> + </delete-button> + </template> + </mobile-fullscreen-dialog> + </template> + </ApolloMutation> +</template> + +<script> +export default { + name: "DeleteDialog", + computed: { + dialogOpen: { + get() { + return this.value; + }, + + set(val) { + this.$emit("input", val); + }, + }, + ids() { + return this.items.map((item) => item[this.itemId]); + }, + query() { + if ("options" in this.gqlQuery) { + return { + ...this.gqlQuery.options, + variables: JSON.parse(this.gqlQuery.previousVariablesJson), + }; + } + return { query: this.gqlQuery }; + }, + }, + methods: { + update(store) { + this.$emit("update", store); + + if (!this.gqlQuery) { + // There is no GraphQL query to update + return; + } + + // Read the data from cache for query + const storedData = store.readQuery(this.query); + + if (!storedData) { + // There are no data in the cache yet + return; + } + + const storedDataKey = Object.keys(storedData)[0]; + + for (const item of this.items) { + console.debug("Removing item from store:", item); + // Remove item from stored data + const index = storedData[storedDataKey].findIndex( + (m) => m.id === item.id + ); + storedData[storedDataKey].splice(index, 1); + } + + // Write data back to the cache + store.writeQuery({ ...this.query, data: storedData }); + }, + close(success) { + this.$emit("input", false); + if (success) { + this.$emit("success"); + + this.$root.snackbarItems.push({ + id: crypto.randomUUID(), + timeout: 5000, + messageKey: this.$t(this.deleteSuccessMessageI18nKey), + color: "success", + }); + } else { + this.$emit("cancel"); + } + }, + nameOfItem(item) { + return this.itemAttribute in item || {} + ? item[this.itemAttribute] + : item.toString(); + }, + }, + props: { + value: { + type: Boolean, + required: true, + }, + items: { + type: Array, + required: false, + default: () => [], + }, + itemAttribute: { + type: String, + required: false, + default: "name", + }, + itemId: { + type: String, + required: false, + default: "id", + }, + gqlMutation: { + type: Object, + required: true, + }, + gqlQuery: { + type: Object, + required: false, + default: null, + }, + deleteSuccessMessageI18nKey: { + type: String, + required: false, + default: "status.objects_delete_success", + }, + }, +}; +</script> diff --git a/aleksis/core/frontend/components/generic/dialogs/DialogObjectForm.vue b/aleksis/core/frontend/components/generic/dialogs/DialogObjectForm.vue new file mode 100644 index 0000000000000000000000000000000000000000..90bf452b66cd7c1aa0ab2843e005ef7f82c4e84b --- /dev/null +++ b/aleksis/core/frontend/components/generic/dialogs/DialogObjectForm.vue @@ -0,0 +1,250 @@ +<template> + <mobile-fullscreen-dialog v-model="dialog" max-width="500px"> + <template #activator="{ on, attrs }"> + <slot name="activator" v-bind="{ on, attrs }" /> + </template> + + <template #title> + <slot name="title"> + <span class="text-h5">{{ + isCreate ? $t(createItemI18nKey) : $t(editItemI18nKey) + }}</span> + </slot> + </template> + + <template #content> + <v-form v-model="valid"> + <v-container> + <v-row> + <v-col cols="12" sm="6" v-for="field in fields" :key="field.value"> + <slot + :label="field.text" + :name="field.value + '.field'" + :attrs="buildAttrs(itemModel, field)" + :on="buildOn(dynamicSetter(itemModel, field.value))" + :is-create="isCreate" + :item="itemModel" + > + <v-text-field + :label="field.text" + filled + v-model="itemModel[field.value]" + ></v-text-field> + </slot> + </v-col> + </v-row> + </v-container> + </v-form> + </template> + + <template #actions> + <cancel-button @click="$emit('cancel')" /> + <save-button @click="save" :loading="loading" :disabled="!valid" /> + </template> + </mobile-fullscreen-dialog> +</template> + +<script> +import SaveButton from "../buttons/SaveButton.vue"; +import CancelButton from "../buttons/CancelButton.vue"; +import MobileFullscreenDialog from "./MobileFullscreenDialog.vue"; + +export default { + name: "DialogObjectForm", + components: { + CancelButton, + SaveButton, + MobileFullscreenDialog, + }, + data() { + return { + loading: false, + valid: false, + firstInitDone: false, + itemModel: {}, + }; + }, + props: { + value: { + type: Boolean, + default: false, + }, + createItemI18nKey: { + type: String, + required: false, + default: "actions.create", + }, + createSuccessMessageI18nKey: { + type: String, + required: false, + default: "status.object_create_success", + }, + editItemI18nKey: { + type: String, + required: false, + default: "actions.edit", + }, + editSuccessMessageI18nKey: { + type: String, + required: false, + default: "status.object_edit_success", + }, + gqlCreateMutation: { + type: Object, + required: false, + default: undefined, + }, + gqlPatchMutation: { + type: Object, + required: false, + default: undefined, + }, + fields: { + type: Array, + required: true, + }, + itemTitleAttribute: { + type: String, + required: false, + default: "name", + }, + defaultItem: { + type: Object, + required: true, + }, + editItem: { + type: Object, + required: false, + default: undefined, + }, + forceModelItemUpdate: { + type: Boolean, + required: false, + default: false, + }, + getCreateData: { + type: Function, + required: false, + default: (item) => item, + }, + getPatchData: { + type: Function, + required: false, + default: (item) => { + let { id, __typename, ...patchItem } = item; + return patchItem; + }, + }, + isCreate: { + type: Boolean, + required: true, + }, + }, + computed: { + dialog: { + get() { + return this.value; + }, + set(newValue) { + this.$emit("input", newValue); + }, + }, + }, + methods: { + save() { + this.loading = true; + + if ( + !this.itemModel || + (this.isCreate && !this.gqlCreateMutation) || + (!this.isCreate && !this.gqlPatchMutation) + ) + return; + + let mutation = this.isCreate + ? this.gqlCreateMutation + : this.gqlPatchMutation; + + let variables = this.isCreate + ? { input: this.getCreateData(this.itemModel) } + : { input: this.getPatchData(this.itemModel), id: this.itemModel.id }; + + this.$apollo + .mutate({ + mutation: mutation, + variables: variables, + update: (store, data) => { + this.$emit( + "update", + store, + data.data[mutation.definitions[0].name.value].item + ); + }, + }) + .then((data) => { + this.$emit("save", data); + + this.handleSuccess(); + }) + .catch((error) => { + console.error(error); + this.$emit("error", error); + }) + .finally(() => { + this.loading = false; + this.dialog = false; + }); + }, + dynamicSetter(item, fieldName) { + return (value) => { + this.$set(item, fieldName, value); + }; + }, + buildAttrs(item, field) { + return { + dense: true, + filled: true, + value: item[field.value], + inputValue: item[field.value], + label: field.text, + }; + }, + buildOn(setter) { + return { + input: setter, + change: setter, + }; + }, + handleSuccess() { + let snackbarTextKey = this.isCreate + ? this.createSuccessMessageI18nKey + : this.editSuccessMessageI18nKey; + + this.$root.snackbarItems.push({ + id: crypto.randomUUID(), + timeout: 5000, + messageKey: snackbarTextKey, + color: "success", + }); + }, + updateModel() { + // Only update the model if the dialog is hidden or has just been mounted + if (this.forceModelItemUpdate || !this.firstInitDone || !this.dialog) { + this.itemModel = JSON.parse( + JSON.stringify(this.isCreate ? this.defaultItem : this.editItem) + ); + } + }, + }, + mounted() { + this.updateModel(); + this.firstInitDone = true; + + this.$watch("isCreate", this.updateModel); + this.$watch("defaultItem", this.updateModel, { deep: true }); + this.$watch("editItem", this.updateModel, { deep: true }); + }, +}; +</script> + +<style scoped></style> diff --git a/aleksis/core/frontend/components/generic/dialogs/FilterDialog.vue b/aleksis/core/frontend/components/generic/dialogs/FilterDialog.vue new file mode 100644 index 0000000000000000000000000000000000000000..24561d9f5bbe55fb53d6504edb35df756bcb8947 --- /dev/null +++ b/aleksis/core/frontend/components/generic/dialogs/FilterDialog.vue @@ -0,0 +1,78 @@ +<template> + <mobile-fullscreen-dialog v-bind="$attrs" v-on="$listeners"> + <template #title>{{ $t("actions.filter") }}</template> + + <template #content> + <form ref="form" @submit.prevent="save"> + <slot :attrs="attrs" :on="on"></slot> + </form> + </template> + + <template #actions> + <cancel-button + i18n-key="actions.clear_filters" + @click="clearFilters" + ></cancel-button> + <save-button + i18n-key="actions.filter" + icon-text="$filterEmpty" + @click="save" + ></save-button> + </template> + </mobile-fullscreen-dialog> +</template> + +<script> +import MobileFullscreenDialog from "./MobileFullscreenDialog.vue"; +import CancelButton from "../buttons/CancelButton.vue"; +import SaveButton from "../buttons/SaveButton.vue"; + +export default { + name: "FilterDialog", + components: { SaveButton, CancelButton, MobileFullscreenDialog }, + props: { + filters: { + type: Object, + required: true, + }, + }, + methods: { + save() { + // Drop values that are null, as we don't want to apply empty filter + for (const key in this.filters) { + if (key in this.filters && this.filters[key] === null) { + // eslint-disable-next-line vue/no-mutating-props + delete this.filters[key]; + } + } + + this.$emit("filters", this.filters); + this.$emit("input", false); + }, + clearFilters() { + this.$refs.form.reset(); + this.$emit("filters", {}); + this.$emit("input", false); + }, + on(field) { + return { + // eslint-disable-next-line vue/no-mutating-props + change: (i) => (this.filters[field] = i), + // eslint-disable-next-line vue/no-mutating-props + input: (i) => (this.filters[field] = i), + }; + }, + attrs(field, defaultValue) { + if ([null, undefined].includes(this.filters[field]) && !!defaultValue) { + // eslint-disable-next-line vue/no-mutating-props + this.filters[field] = defaultValue; + } + return { + value: this.filters[field], + }; + }, + }, +}; +</script> + +<style scoped></style> diff --git a/aleksis/core/frontend/components/generic/dialogs/MobileFullscreenDialog.vue b/aleksis/core/frontend/components/generic/dialogs/MobileFullscreenDialog.vue new file mode 100644 index 0000000000000000000000000000000000000000..91df04e68bfff958105a377d1049211497a81a5e --- /dev/null +++ b/aleksis/core/frontend/components/generic/dialogs/MobileFullscreenDialog.vue @@ -0,0 +1,40 @@ +<template> + <v-dialog + v-bind="$attrs" + v-on="$listeners" + :fullscreen="$vuetify.breakpoint.xs" + :hide-overlay="$vuetify.breakpoint.xs" + max-width="600px" + > + <template #activator="activator"> + <slot name="activator" v-bind="activator"></slot> + </template> + <template #default> + <slot> + <v-card class="d-flex flex-column"> + <v-card-title> + <slot name="title"></slot> + </v-card-title> + <v-card-text> + <slot name="content"></slot> + </v-card-text> + <v-spacer /> + <v-divider /> + <v-card-actions> + <v-spacer></v-spacer> + <slot name="actions"></slot> + </v-card-actions> + </v-card> + </slot> + </template> + </v-dialog> +</template> + +<script> +export default { + name: "MobileFullscreenDialog", + extends: "v-dialog", +}; +</script> + +<style scoped></style> diff --git a/aleksis/core/frontend/components/generic/forms/ColorField.vue b/aleksis/core/frontend/components/generic/forms/ColorField.vue new file mode 100644 index 0000000000000000000000000000000000000000..6f11516c877fe4a2a9df213c751251e13ced397c --- /dev/null +++ b/aleksis/core/frontend/components/generic/forms/ColorField.vue @@ -0,0 +1,61 @@ +<template> + <v-menu + ref="menu" + v-model="menu" + :close-on-content-click="false" + transition="scale-transition" + offset-y + min-width="auto" + eager + > + <template #activator="{ on, attrs }"> + <v-text-field + v-model="color" + v-bind="$attrs" + v-on="$listeners" + placeholder="#AABBCC" + :rules="rules" + > + <template #prepend-inner> + <v-icon :color="color" v-bind="attrs" v-on="on"> mdi-circle </v-icon> + </template> + </v-text-field> + </template> + <v-color-picker v-if="menu" v-model="color" ref="picker"></v-color-picker> + </v-menu> +</template> + +<script> +export default { + name: "DateField", + extends: "v-text-field", + data() { + return { + menu: false, + rules: [ + (value) => + /^(#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8}))?$/i.test(value) || + this.$t("forms.errors.invalid_color"), + ], + }; + }, + props: { + value: { + type: String, + default: undefined, + }, + }, + computed: { + color: { + get() { + return this.value; + }, + set(newValue) { + this.$emit("input", newValue); + }, + }, + }, +}; +</script> + +<style scoped></style> diff --git a/aleksis/core/frontend/components/generic/forms/DateField.vue b/aleksis/core/frontend/components/generic/forms/DateField.vue new file mode 100644 index 0000000000000000000000000000000000000000..68a8772fad15172b319711bb7ce9a4a6f5c79f7b --- /dev/null +++ b/aleksis/core/frontend/components/generic/forms/DateField.vue @@ -0,0 +1,109 @@ +<template> + <v-menu + ref="menu" + v-model="menu" + :close-on-content-click="false" + transition="scale-transition" + offset-y + min-width="auto" + eager + > + <template #activator="{ on, attrs }"> + <v-text-field + v-model="date" + v-bind="{ ...$attrs, ...attrs }" + @click="handleClick" + @focusin="handleFocusIn" + @focusout="handleFocusOut" + @click:clear="handleClickClear" + placeholder="YYYY-MM-DD" + @keydown.esc="menu = false" + @keydown.enter="menu = false" + :rules="rules" + ></v-text-field> + </template> + <v-date-picker + v-model="date" + ref="picker" + no-title + scrollable + :min="min" + :max="max" + :locale="$i18n.locale" + first-day-of-week="1" + show-adjacent-months + @input="menu = false" + ></v-date-picker> + </v-menu> +</template> + +<script> +export default { + name: "DateField", + extends: "v-text-field", + data() { + return { + menu: false, + innerDate: this.value, + openDueToFocus: true, + rules: [ + (value) => + !value || !!Date.parse(value) || this.$t("forms.errors.invalid_date"), + ], + }; + }, + props: { + value: { + type: String, + required: false, + default: undefined, + }, + min: { + type: String, + required: false, + default: undefined, + }, + max: { + type: String, + required: false, + default: undefined, + }, + }, + computed: { + date: { + get() { + return this.innerDate; + }, + set(value) { + this.innerDate = value; + this.$emit("input", value); + }, + }, + }, + methods: { + handleClickClear() { + if (this.clearable) { + this.date = null; + } + }, + handleClick() { + this.menu = true; + this.openDueToFocus = false; + }, + handleFocusIn() { + this.openDueToFocus = true; + this.menu = true; + }, + handleFocusOut() { + if (this.openDueToFocus) this.menu = false; + }, + }, + watch: { + value(newValue) { + this.innerDate = newValue; + }, + }, +}; +</script> + +<style scoped></style> diff --git a/aleksis/core/frontend/components/generic/forms/DateTimeField.vue b/aleksis/core/frontend/components/generic/forms/DateTimeField.vue new file mode 100644 index 0000000000000000000000000000000000000000..da046a69b36014968995db908d5190e5f027ca30 --- /dev/null +++ b/aleksis/core/frontend/components/generic/forms/DateTimeField.vue @@ -0,0 +1,115 @@ +<script setup> +import DateField from "./DateField.vue"; +import TimeField from "./TimeField.vue"; +</script> + +<template> + <v-row> + <v-col cols="7"> + <date-field + v-model="date" + v-bind="{ ...$attrs }" + :label="$t('forms.date_time.date')" + :min="minDate" + :max="maxDate" + /> + </v-col> + <v-col cols="5"> + <time-field + v-model="time" + v-bind="{ ...$attrs }" + :label="$t('forms.date_time.time')" + :min="minTime" + :max="maxTime" + /> + </v-col> + </v-row> +</template> + +<script> +export default { + name: "DateTimeField", + data() { + return { + innerDateTime: this.value, + }; + }, + props: { + value: { + type: String, + required: false, + default: undefined, + }, + minDate: { + type: String, + required: false, + default: undefined, + }, + maxDate: { + type: String, + required: false, + default: undefined, + }, + minTime: { + type: String, + required: false, + default: undefined, + }, + maxTime: { + type: String, + required: false, + default: undefined, + }, + }, + computed: { + dateTime: { + get() { + return new Date(this.innerDateTime); + }, + set(value) { + this.innerDateTime = value; + this.$emit("input", value); + }, + }, + date: { + get() { + return this.dateTime.toISOString().split("T")[0]; + }, + set(value) { + let newDateTime = this.dateTime; + const [year, month, day] = value.split("-"); + + newDateTime.setFullYear(year); + newDateTime.setMonth(month - 1); + newDateTime.setDate(day); + + this.dateTime = newDateTime.toISOString(); + }, + }, + time: { + get() { + return `${("0" + this.dateTime.getHours()).slice(-2)}:${( + "0" + this.dateTime.getMinutes() + ).slice(-2)}`; + }, + set(value) { + let newDateTime = this.dateTime; + + const [hours, minutes] = value.split(":"); + + newDateTime.setHours(hours); + newDateTime.setMinutes(minutes); + + this.dateTime = newDateTime.toISOString(); + }, + }, + }, + watch: { + value(newValue) { + this.innerDateTime = newValue; + }, + }, +}; +</script> + +<style scoped></style> diff --git a/aleksis/core/frontend/components/generic/forms/ForeignKeyField.vue b/aleksis/core/frontend/components/generic/forms/ForeignKeyField.vue new file mode 100644 index 0000000000000000000000000000000000000000..b32859065e17cda75c2697b577ad3b9bf09d3aba --- /dev/null +++ b/aleksis/core/frontend/components/generic/forms/ForeignKeyField.vue @@ -0,0 +1,183 @@ +<template> + <v-autocomplete + v-bind="$attrs" + v-on="$listeners" + :items="items" + item-value="id" + :item-text="itemName" + class="fc-my-auto" + > + <template #append-outer> + <v-btn icon @click="menu = true"> + <v-icon>$plus</v-icon> + </v-btn> + + <slot + name="createComponent" + :attrs="{ + value: menu, + defaultItem: defaultItem, + gqlQuery: gqlQuery, + gqlCreateMutation: gqlCreateMutation, + gqlPatchMutation: gqlPatchMutation, + isCreate: true, + fields: fields, + getCreateData: getCreateData, + createItemI18nKey: createItemI18nKey, + }" + :on="{ + input: (i) => (menu = i), + cancel: () => (menu = false), + save: handleSave, + update: handleUpdate, + }" + > + <dialog-object-form + v-model="menu" + @cancel="menu = false" + @update="handleUpdate" + @save="handleSave" + @error="handleError" + :is-create="true" + :default-item="defaultItem" + :fields="fields" + :gql-query="gqlQuery" + :gql-patch-mutation="gqlPatchMutation" + :gql-create-mutation="gqlCreateMutation" + :create-item-i18n-key="createItemI18nKey" + :get-create-data="getCreateData" + > + <template + v-for="(_, name) in $scopedSlots" + :slot="name" + slot-scope="slotData" + > + <slot :name="name" v-bind="slotData" /> + </template> + </dialog-object-form> + </slot> + + <closable-snackbar :color="snackbarState" v-model="snackbar"> + {{ snackbarText }} + </closable-snackbar> + </template> + </v-autocomplete> +</template> + +<script> +import ClosableSnackbar from "../dialogs/ClosableSnackbar.vue"; +import DialogObjectForm from "../dialogs/DialogObjectForm.vue"; + +export default { + name: "ForeignKeyField", + components: { ClosableSnackbar, DialogObjectForm }, + extends: "v-autocomplete", + data() { + return { + menu: false, + snackbar: false, + snackbarState: "error", + snackbarText: "", + }; + }, + apollo: { + items() { + return { + query: this.gqlQuery, + fetchPolicy: "cache-first", + }; + }, + }, + methods: { + handleUpdate(store, createdObject) { + // Read the data from cache for query + const storedData = store.readQuery({ query: this.gqlQuery }); + + if (!storedData) { + // There are no data in the cache yet + return; + } + + const storedDataKey = Object.keys(storedData)[0]; + + // Add item to stored data + storedData[storedDataKey].push(createdObject); + + // Write data back to the cache + store.writeQuery({ query: this.gqlQuery, data: storedData }); + }, + handleSave(data) { + let newItem = + data.data[this.gqlCreateMutation.definitions[0].name.value].item; + let newValue = this.$attrs["return-object"] ? newItem : newItem.id; + let modelValue = + "multiple" in this.$attrs + ? Array.isArray(this.$attrs.value) + ? this.$attrs.value.concat(newValue) + : [newValue] + : newValue; + + this.$emit("input", modelValue); + }, + slotName(field) { + return field.value + ".field"; + }, + handleError(error) { + console.error(error); + if (error instanceof String) { + // error is a translation key or simply a string + this.snackbarText = this.$t(error); + } else if (error instanceof Object && error.message) { + this.snackbarText = error.message; + } else { + this.snackbarText = this.$t("graphql.snackbar_error_message"); + } + this.snackbarState = "error"; + this.snackbar = true; + }, + }, + props: { + defaultItem: { + type: Object, + required: true, + }, + fields: { + type: Array, + required: true, + }, + gqlQuery: { + type: Object, + required: true, + }, + gqlCreateMutation: { + type: Object, + required: true, + }, + gqlPatchMutation: { + type: Object, + required: true, + }, + getCreateData: { + type: Function, + required: false, + default: (item) => item, + }, + itemName: { + type: String, + required: false, + default: "name", + }, + createItemI18nKey: { + type: String, + required: false, + default: "actions.create", + }, + }, +}; +</script> + +<style scoped> +.fc-my-auto > :first-child { + margin-block: auto; +} +</style> diff --git a/aleksis/core/frontend/components/generic/forms/PositiveSmallIntegerField.vue b/aleksis/core/frontend/components/generic/forms/PositiveSmallIntegerField.vue new file mode 100644 index 0000000000000000000000000000000000000000..5deaab572366481ee22ff147028bfb32a79e00eb --- /dev/null +++ b/aleksis/core/frontend/components/generic/forms/PositiveSmallIntegerField.vue @@ -0,0 +1,56 @@ +<template> + <v-text-field + v-bind="$attrs" + v-on="on" + :rules="rules" + type="number" + inputmode="decimal" + ></v-text-field> +</template> + +<script> +export default { + name: "PositiveSmallIntegerField", + extends: "v-text-field", + methods: { + handleInput(event) { + let num = parseInt(event); + if (!isNaN(num) && num >= 0 && num <= 32767 && num % 1 === 0) { + this.$emit("input", parseInt(event)); + } + }, + }, + data() { + return { + rules: [ + (value) => + !value || + !isNaN(parseInt(value)) || + this.$t("forms.errors.not_a_number"), + (value) => + !value || + value % 1 === 0 || + this.$t("forms.errors.not_a_whole_number"), + (value) => + !value || + parseInt(value) >= 0 || + this.$t("forms.errors.number_too_small"), + (value) => + !value || + parseInt(value) <= 32767 || + this.$t("forms.errors.number_too_big"), + ], + }; + }, + computed: { + on() { + return { + ...this.$listeners, + input: this.handleInput, + }; + }, + }, +}; +</script> + +<style scoped></style> diff --git a/aleksis/core/frontend/components/generic/forms/TimeField.vue b/aleksis/core/frontend/components/generic/forms/TimeField.vue new file mode 100644 index 0000000000000000000000000000000000000000..bff97c880f8cf7cea92924ec49987ce9f78a22ef --- /dev/null +++ b/aleksis/core/frontend/components/generic/forms/TimeField.vue @@ -0,0 +1,114 @@ +<template> + <v-menu + ref="menu" + v-model="menu" + :close-on-content-click="false" + transition="scale-transition" + offset-y + min-width="290" + eager + > + <template #activator="{ on, attrs }"> + <v-text-field + v-model="time" + v-bind="{ ...$attrs, ...attrs }" + @click="handleClick" + @focusin="handleFocusIn" + @focusout="handleFocusOut" + @click:clear="handleClickClear" + placeholder="HH:MM[:SS]" + @keydown.esc="menu = false" + @keydown.enter="menu = false" + :prepend-icon="prependIcon" + :rules="rules" + ></v-text-field> + </template> + <v-time-picker + v-model="time" + ref="picker" + :min="min" + :max="max" + full-width + format="24hr" + @click:minute="menu = false" + ></v-time-picker> + </v-menu> +</template> + +<script> +export default { + name: "TimeField", + extends: "v-text-field", + data() { + return { + menu: false, + innerTime: this.value, + openDueToFocus: true, + rules: [ + (v) => + !v || + /^([01]\d|2[0-3]):([0-5]\d)(:([0-5]\d))?$/.test(v) || + this.$t("forms.errors.invalid_time"), + ], + }; + }, + props: { + value: { + type: String, + required: false, + default: undefined, + }, + min: { + type: String, + required: false, + default: undefined, + }, + max: { + type: String, + required: false, + default: undefined, + }, + prependIcon: { + type: String, + required: false, + default: undefined, + }, + }, + computed: { + time: { + get() { + return this.innerTime; + }, + set(value) { + this.innerTime = value; + this.$emit("input", value); + }, + }, + }, + methods: { + handleClickClear() { + if (this.clearable) { + this.time = null; + } + }, + handleClick() { + this.menu = true; + this.openDueToFocus = false; + }, + handleFocusIn() { + this.openDueToFocus = true; + this.menu = true; + }, + handleFocusOut() { + if (this.openDueToFocus) this.menu = false; + }, + }, + watch: { + value(newValue) { + this.innerTime = newValue; + }, + }, +}; +</script> + +<style scoped></style> diff --git a/aleksis/core/frontend/components/generic/forms/WeekDayField.vue b/aleksis/core/frontend/components/generic/forms/WeekDayField.vue new file mode 100644 index 0000000000000000000000000000000000000000..4e8c359343f799c43974ea923a27c8648bfa314c --- /dev/null +++ b/aleksis/core/frontend/components/generic/forms/WeekDayField.vue @@ -0,0 +1,68 @@ +<template> + <v-autocomplete + v-bind="$attrs" + v-on="$listeners" + :items="items" + :item-value="valueKey" + ></v-autocomplete> +</template> + +<script> +export default { + name: "WeekDayField", + extends: "v-autocomplete", + data() { + return { + items: [ + { + value: "A_0", + valueInt: 0, + text: this.$t("weekdays.A_0"), + }, + { + value: "A_1", + valueInt: 1, + text: this.$t("weekdays.A_1"), + }, + { + value: "A_2", + valueInt: 2, + text: this.$t("weekdays.A_2"), + }, + { + value: "A_3", + valueInt: 3, + text: this.$t("weekdays.A_3"), + }, + { + value: "A_4", + valueInt: 4, + text: this.$t("weekdays.A_4"), + }, + { + value: "A_5", + valueInt: 5, + text: this.$t("weekdays.A_5"), + }, + { + value: "A_6", + valueInt: 6, + text: this.$t("weekdays.A_6"), + }, + ], + }; + }, + props: { + returnInt: { + type: Boolean, + default: false, + required: false, + }, + }, + computed: { + valueKey() { + return this.returnInt ? "valueInt" : "value"; + }, + }, +}; +</script> diff --git a/aleksis/core/frontend/components/notifications/NotificationList.vue b/aleksis/core/frontend/components/notifications/NotificationList.vue index 1c51c00661816fe3e244e6d511a0e70c747dbca6..e48d9f2c203a2d2730af1a7208f3590667ede7bf 100644 --- a/aleksis/core/frontend/components/notifications/NotificationList.vue +++ b/aleksis/core/frontend/components/notifications/NotificationList.vue @@ -20,7 +20,7 @@ v-if=" myNotifications && myNotifications.person && - myNotifications.person.unreadNotificationsCount > 0 + unreadNotifications.length > 0 " > mdi-bell-badge-outline @@ -86,5 +86,12 @@ export default { pollInterval: 30000, }, }, + computed: { + unreadNotifications() { + return this.myNotifications.person.notifications + ? this.myNotifications.person.notifications.filter((n) => !n.read) + : []; + }, + }, }; </script> diff --git a/aleksis/core/frontend/components/notifications/myNotifications.graphql b/aleksis/core/frontend/components/notifications/myNotifications.graphql index b8287ea2f50664f556d82bdb58e4b508c7ece1d4..89e91562086607e6c9e7649fa1295d234d189bd3 100644 --- a/aleksis/core/frontend/components/notifications/myNotifications.graphql +++ b/aleksis/core/frontend/components/notifications/myNotifications.graphql @@ -1,7 +1,6 @@ { myNotifications: whoAmI { person { - unreadNotificationsCount notifications { id title diff --git a/aleksis/core/frontend/components/room/RoomInlineList.vue b/aleksis/core/frontend/components/room/RoomInlineList.vue new file mode 100644 index 0000000000000000000000000000000000000000..5d65067ee94a6b0c8c9dce3964200c315bc583c7 --- /dev/null +++ b/aleksis/core/frontend/components/room/RoomInlineList.vue @@ -0,0 +1,70 @@ +<script setup> +import InlineCRUDList from "../generic/InlineCRUDList.vue"; +</script> + +<template> + <inline-c-r-u-d-list + :headers="headers" + :i18n-key="i18nKey" + create-item-i18n-key="rooms.create_room" + :gql-query="gqlQuery" + :gql-create-mutation="gqlCreateMutation" + :gql-patch-mutation="gqlPatchMutation" + :gql-delete-mutation="gqlDeleteMutation" + :gql-delete-multiple-mutation="gqlDeleteMultipleMutation" + :default-item="defaultItem" + > + <!-- eslint-disable-next-line vue/valid-v-slot --> + <template #shortName.field="{ attrs, on, isCreate }"> + <div aria-required="true"> + <v-text-field + v-bind="attrs" + v-on="on" + required + :rules="shortNameRules" + ></v-text-field> + </div> + </template> + </inline-c-r-u-d-list> +</template> + +<script> +import { + rooms, + createRoom, + deleteRoom, + deleteRooms, + updateRooms, +} from "./room.graphql"; + +export default { + name: "RoomInlineList", + data() { + return { + headers: [ + { + text: this.$t("rooms.name"), + value: "name", + }, + { + text: this.$t("rooms.short_name"), + value: "shortName", + }, + ], + i18nKey: "rooms", + gqlQuery: rooms, + gqlCreateMutation: createRoom, + gqlPatchMutation: updateRooms, + gqlDeleteMutation: deleteRoom, + gqlDeleteMultipleMutation: deleteRooms, + defaultItem: { + name: "", + shortName: "", + }, + shortNameRules: [(value) => !!value || this.$t("forms.errors.required")], + }; + }, +}; +</script> + +<style scoped></style> diff --git a/aleksis/core/frontend/components/room/room.graphql b/aleksis/core/frontend/components/room/room.graphql new file mode 100644 index 0000000000000000000000000000000000000000..d8cd9138dd0fa5c4f3af7a7d47fe7b44ed94f6fd --- /dev/null +++ b/aleksis/core/frontend/components/room/room.graphql @@ -0,0 +1,45 @@ +query rooms($orderBy: [String], $filters: JSONString) { + items: rooms(orderBy: $orderBy, filters: $filters) { + id + name + shortName + canEdit + canDelete + } +} + +mutation createRoom($input: CreateRoomInput!) { + createRoom(input: $input) { + room { + id + name + shortName + canEdit + canDelete + } + } +} + +mutation deleteRoom($id: ID!) { + deleteRoom(id: $id) { + ok + } +} + +mutation deleteRooms($ids: [ID]!) { + deleteRooms(ids: $ids) { + deletionCount + } +} + +mutation updateRooms($input: [BatchPatchRoomInput]!) { + batchMutation: updateRooms(input: $input) { + items: rooms { + id + name + shortName + canEdit + canDelete + } + } +} diff --git a/aleksis/core/frontend/components/school_term/SchoolTermField.vue b/aleksis/core/frontend/components/school_term/SchoolTermField.vue new file mode 100644 index 0000000000000000000000000000000000000000..5a2ba1a752a69b56aed7d26db24f073d776238f6 --- /dev/null +++ b/aleksis/core/frontend/components/school_term/SchoolTermField.vue @@ -0,0 +1,98 @@ +<script setup> +import DateField from "../generic/forms/DateField.vue"; +</script> + +<template> + <foreign-key-field + :gql-patch-mutation="{}" + :gql-create-mutation="gqlCreateMutation" + :gql-query="gqlQuery" + :fields="fields" + create-item-i18n-key="school_term.create_school_term" + :default-item="defaultItem" + v-bind="$attrs" + v-on="$listeners" + > + <!-- eslint-disable-next-line vue/valid-v-slot --> + <template #name.field="{ attrs, on }"> + <div aria-required="true"> + <v-text-field + v-bind="attrs" + v-on="on" + required + :rules="required" + ></v-text-field> + </div> + </template> + + <template #dateStart="{ item }"> + {{ $d(new Date(item.dateStart), "short") }} + </template> + <!-- eslint-disable-next-line vue/valid-v-slot --> + <template #dateStart.field="{ attrs, on, item }"> + <div aria-required="true"> + <date-field + v-bind="attrs" + v-on="on" + required + :rules="required" + :max="item ? item.dateEnd : undefined" + ></date-field> + </div> + </template> + + <template #dateEnd="{ item }"> + {{ $d(new Date(item.dateEnd), "short") }} + </template> + <!-- eslint-disable-next-line vue/valid-v-slot --> + <template #dateEnd.field="{ attrs, on, item }"> + <div aria-required="true"> + <date-field + v-bind="attrs" + v-on="on" + required + :rules="required" + :min="item ? item.dateStart : undefined" + ></date-field> + </div> + </template> + </foreign-key-field> +</template> + +<script> +import ForeignKeyField from "../generic/forms/ForeignKeyField.vue"; +import { createSchoolTerm, schoolTerms } from "./schoolTerm.graphql"; + +export default { + name: "SchoolTermField", + components: { ForeignKeyField }, + data() { + return { + gqlQuery: schoolTerms, + gqlCreateMutation: createSchoolTerm, + fields: [ + { + text: this.$t("school_term.name"), + value: "name", + }, + { + text: this.$t("school_term.date_start"), + value: "dateStart", + }, + { + text: this.$t("school_term.date_end"), + value: "dateEnd", + }, + ], + defaultItem: { + name: "", + dateStart: "", + dateEnd: "", + }, + required: [(value) => !!value || this.$t("forms.errors.required")], + }; + }, +}; +</script> + +<style scoped></style> diff --git a/aleksis/core/frontend/components/school_term/SchoolTermInlineList.vue b/aleksis/core/frontend/components/school_term/SchoolTermInlineList.vue new file mode 100644 index 0000000000000000000000000000000000000000..b18c13f1a88196b9769262e3a5f3347a29d9ead4 --- /dev/null +++ b/aleksis/core/frontend/components/school_term/SchoolTermInlineList.vue @@ -0,0 +1,117 @@ +<script setup> +import InlineCRUDList from "../generic/InlineCRUDList.vue"; +import DateField from "../generic/forms/DateField.vue"; +</script> + +<template> + <inline-c-r-u-d-list + :headers="headers" + :i18n-key="i18nKey" + create-item-i18n-key="school_term.create_school_term" + :gql-query="gqlQuery" + :gql-create-mutation="gqlCreateMutation" + :gql-patch-mutation="gqlPatchMutation" + :gql-delete-mutation="gqlDeleteMutation" + :gql-delete-multiple-mutation="gqlDeleteMultipleMutation" + :default-item="defaultItem" + filter + > + <!-- eslint-disable-next-line vue/valid-v-slot --> + <template #name.field="{ attrs, on, item }"> + <div aria-required="true"> + <v-text-field v-bind="attrs" v-on="on" :rules="required"></v-text-field> + </div> + </template> + + <template #dateStart="{ item }">{{ + $d(new Date(item.dateStart), "short") + }}</template> + <!-- eslint-disable-next-line vue/valid-v-slot --> + <template #dateStart.field="{ attrs, on, item }"> + <div aria-required="true"> + <date-field + v-bind="attrs" + v-on="on" + :rules="required" + :max="item ? item.dateEnd : undefined" + ></date-field> + </div> + </template> + + <template #dateEnd="{ item }">{{ + $d(new Date(item.dateEnd), "short") + }}</template> + <!-- eslint-disable-next-line vue/valid-v-slot --> + <template #dateEnd.field="{ attrs, on, item }"> + <div aria-required="true"> + <date-field + v-bind="attrs" + v-on="on" + required + :rules="required" + :min="item ? item.dateStart : undefined" + ></date-field> + </div> + </template> + + <template #filters="{ attrs, on }"> + <date-field + v-bind="attrs('date_end__gte')" + v-on="on('date_end__gte')" + :label="$t('school_term.after')" + /> + + <date-field + v-bind="attrs('date_start__lte')" + v-on="on('date_start__lte')" + :label="$t('school_term.before')" + /> + </template> + </inline-c-r-u-d-list> +</template> + +<script> +import { + schoolTerms, + createSchoolTerm, + deleteSchoolTerm, + deleteSchoolTerms, + updateSchoolTerms, +} from "./schoolTerm.graphql"; + +export default { + name: "SchoolTermInlineList", + data() { + return { + headers: [ + { + text: this.$t("school_term.name"), + value: "name", + }, + { + text: this.$t("school_term.date_start"), + value: "dateStart", + }, + { + text: this.$t("school_term.date_end"), + value: "dateEnd", + }, + ], + i18nKey: "school_term", + gqlQuery: schoolTerms, + gqlCreateMutation: createSchoolTerm, + gqlPatchMutation: updateSchoolTerms, + gqlDeleteMutation: deleteSchoolTerm, + gqlDeleteMultipleMutation: deleteSchoolTerms, + defaultItem: { + name: "", + dateStart: "", + dateEnd: "", + }, + required: [(value) => !!value || this.$t("forms.errors.required")], + }; + }, +}; +</script> + +<style scoped></style> diff --git a/aleksis/core/frontend/components/school_term/schoolTerm.graphql b/aleksis/core/frontend/components/school_term/schoolTerm.graphql new file mode 100644 index 0000000000000000000000000000000000000000..bfd681b2eee97d781e4d489a9c82cdfc95d9bf5f --- /dev/null +++ b/aleksis/core/frontend/components/school_term/schoolTerm.graphql @@ -0,0 +1,48 @@ +query schoolTerms($orderBy: [String], $filters: JSONString) { + items: schoolTerms(orderBy: $orderBy, filters: $filters) { + id + name + dateStart + dateEnd + canEdit + canDelete + } +} + +mutation createSchoolTerm($input: CreateSchoolTermInput!) { + createSchoolTerm(input: $input) { + schoolTerm { + id + name + dateStart + dateEnd + canEdit + canDelete + } + } +} + +mutation deleteSchoolTerm($id: ID!) { + deleteSchoolTerm(id: $id) { + ok + } +} + +mutation deleteSchoolTerms($ids: [ID]!) { + deleteSchoolTerms(ids: $ids) { + deletionCount + } +} + +mutation updateSchoolTerms($input: [BatchPatchSchoolTermInput]!) { + batchMutation: updateSchoolTerms(input: $input) { + items: schoolTerms { + id + name + dateStart + dateEnd + canEdit + canDelete + } + } +} diff --git a/aleksis/core/frontend/messages/de.json b/aleksis/core/frontend/messages/de.json index 3fb6c19b89ea2dcdfd80a00c88496c4fabb89271..207db33feb3d4b49edb79ebae96230df241fa8fd 100644 --- a/aleksis/core/frontend/messages/de.json +++ b/aleksis/core/frontend/messages/de.json @@ -80,7 +80,9 @@ "edit": "Bearbeiten", "save": "Speichern", "search": "Suchen", - "stop_editing": "Bearbeiten beenden" + "stop_editing": "Bearbeiten beenden", + "filter": "Filter", + "clear_filters": "Filter zurücksetzen" }, "administration": { "backend_admin": { diff --git a/aleksis/core/frontend/messages/en.json b/aleksis/core/frontend/messages/en.json index c700f281d1c0ef0b2f9a0c12a69bb8a5fd47f8ad..d7713a56e7dc0f637952f9c402806124c9b41acb 100644 --- a/aleksis/core/frontend/messages/en.json +++ b/aleksis/core/frontend/messages/en.json @@ -72,10 +72,13 @@ } }, "actions": { + "title": "Actions", + "select_action": "Select Action", "back": "Back", "cancel": "Cancel", "close": "Close", "confirm_deletion": "Are you sure you want to delete this item?", + "confirm_deletion_multiple": "Are you sure you want to delete these items?", "delete": "Delete", "edit": "Edit", "close": "Close", @@ -84,7 +87,11 @@ "copied": "Copied", "save": "Save", "search": "Search", - "stop_editing": "Stop editing" + "stop_editing": "Stop editing", + "create": "Add", + "filter": "Filter", + "clear_filters": "Clear Filters", + "update": "Update" }, "administration": { "backend_admin": { @@ -137,9 +144,6 @@ "notice": "If the download does not start automatically, please click the button below.", "title": "Downloading PDF file ..." }, - "graphql": { - "snackbar_error_message": "There was an error retrieving the page data. Please try again." - }, "group": { "additional_field": { "menu_title": "Additional Fields", @@ -239,7 +243,13 @@ "school_term": { "menu_title": "School Terms", "title": "School Term", - "title_plural": "School Terms" + "title_plural": "School Terms", + "create_school_term": "Create School Term", + "date_start": "Start Date", + "date_end": "End Date", + "name": "Name", + "before": "Starts before", + "after": "Ends after" }, "service_worker": { "dismiss": "Dismiss", @@ -259,10 +269,53 @@ "my_calendars": "My Calendars", "download_all": "Download all" }, + "graphql": { + "snackbar_error_message": "There was an error retrieving the page data. Please try again.", + "snackbar_success_message": "The operation has been finished successfully." + }, "status": { "changes": "You have unsaved changes.", "error": "There has been an error while saving the latest changes.", "saved": "All changes are saved.", - "updating": "Changes are being synced." + "updating": "Changes are being synced.", + "object_create_success": "The object was created successfully.", + "object_edit_success": "The object was edited successfully.", + "object_delete_success": "The object was deleted successfully.", + "objects_delete_success": "The objects were deleted successfully." + }, + "rooms": { + "menu_title": "Rooms", + "title_plural": "Rooms", + "name": "Name", + "short_name": "Short Name", + "create_room": "Create new room" + }, + "forms": { + "errors": { + "required": "This field is required.", + "invalid_date": "This is not a valid date.", + "invalid_time": "This is not a valid time.", + "invalid_color": "This is not a valid color.", + "not_a_number": "Not a valid number", + "not_a_whole_number": "Please enter a whole number", + "number_too_small": "Please enter a bigger number.", + "number_too_big": "Please enter a smaller number." + }, + "date_time": { + "date": "Date", + "time": "Time" + } + }, + "weekdays": { + "A_0": "Monday", + "A_1": "Tuesday", + "A_2": "Wednesday", + "A_3": "Thursday", + "A_4": "Friday", + "A_5": "Saturday", + "A_6": "Sunday" + }, + "selection": { + "num_items_selected": "No items selected | 1 item selected | {n} items selected" } } diff --git a/aleksis/core/frontend/messages/ru.json b/aleksis/core/frontend/messages/ru.json index a5a2775a341b0c3f29e2dc12b5fa6d9f93cf8340..161a4efd7b2dafb90d0785a3a69b224dbe6827dd 100644 --- a/aleksis/core/frontend/messages/ru.json +++ b/aleksis/core/frontend/messages/ru.json @@ -45,7 +45,24 @@ "menu_title": "Учётные запиÑи третьих Ñторон" }, "two_factor": { - "menu_title": "2FA" + "add_authentication_method": "Добавить метод аутентификации", + "backup_codes_count": "У Ð’Ð°Ñ Ð½Ðµ оÑталоÑÑŒ резервных кодов.|У Ð’Ð°Ñ Ð¾ÑталÑÑ Ð¾Ð´Ð¸Ð½ резервный код.|У Ð’Ð°Ñ Ð¾ÑталоÑÑŒ {counter} резервных кодов.", + "backup_codes_description": "ЕÑли Ð’Ñ‹ не Ñможете воÑпользоватьÑÑ Ð½Ð¸ одним из Ñвоих уÑтройÑтв, Ð’Ñ‹ Ñможете получить доÑтуп к учётке, иÑÐ¿Ð¾Ð»ÑŒÐ·ÑƒÑ Ñ€ÐµÐ·ÐµÑ€Ð²Ð½Ñ‹Ðµ коды.", + "backup_codes_title": "Резервные коды", + "disable_button": "Отключить двухфакторную аутентификацию", + "disable_description": "ÐеÑÐ¼Ð¾Ñ‚Ñ€Ñ Ð½Ð° то, что мы крайне предоÑтерегаем ВаÑ, Ð’Ñ‹ вÑÑ‘ же можете отключить двухфакторную аутентификацию Ð´Ð»Ñ Ñвоей учётки.", + "disable_title": "Отключить двухфакторную аутентификацию", + "enable_button": "Включить двухфакторную аутентификацию", + "enable_description": "Ð”Ð²ÑƒÑ…Ñ„Ð°ÐºÑ‚Ð¾Ñ€Ð½Ð°Ñ Ð°ÑƒÑ‚ÐµÐ½Ñ‚Ð¸Ñ„Ð¸ÐºÐ°Ñ†Ð¸Ñ Ð² Вашей учётке не включена. Ð”Ð»Ñ Ð»ÑƒÑ‡ÑˆÐµÐ¹ безопаÑноÑти, рекомендуем её включить.", + "enable_title": "Ð”Ð²ÑƒÑ…Ñ„Ð°ÐºÑ‚Ð¾Ñ€Ð½Ð°Ñ Ð°ÑƒÑ‚ÐµÐ½Ñ‚Ð¸Ñ„Ð¸ÐºÐ°Ñ†Ð¸Ñ ÑÐµÐ¹Ñ‡Ð°Ñ Ð¾Ñ‚ÐºÐ»ÑŽÑ‡ÐµÐ½Ð°", + "menu_title": "2FA", + "methods": { + "call": "Мы позвоним на Ваш мобильный и продиктуем одноразовый код.", + "email": "Мы отправим Вам одноразовые коды на Ñл.почту.", + "generator": "Ð’Ñ‹ генерируете одноразовые коды Ñ Ð¿Ð¾Ð¼Ð¾Ñ‰ÑŒÑŽ генератора кодов.", + "sms": "Мы отправим Вам одноразовые коды на Ваш мобильный.", + "webauthn": "Ð’Ñ‹ иÑпользуете ключ безопаÑноÑти (как внешнее уÑтройÑтво или вÑтроенное в Ваше переноÑное уÑтройÑтво)." + } } }, "actions": { diff --git a/aleksis/core/frontend/messages/uk.json b/aleksis/core/frontend/messages/uk.json index 6781b5af5e49332b898e1d62762d42e2df2a3f3d..09e2470b8ea214f5afe57ffdfeb1600dc3d888b7 100644 --- a/aleksis/core/frontend/messages/uk.json +++ b/aleksis/core/frontend/messages/uk.json @@ -73,9 +73,14 @@ }, "actions": { "back": "Ðазад", + "cancel": "СкаÑувати", "close": "Закрити", + "confirm_deletion": "Ви дійÑно хочете видалити цей об'єкт?", + "delete": "Видалити", "edit": "Редагувати", - "search": "Пошук" + "save": "Зберегти", + "search": "Пошук", + "stop_editing": "Завершити редагуваннÑ" }, "administration": { "backend_admin": { @@ -173,6 +178,17 @@ "title": "Додаток OAuth2", "title_plural": "Додатки OAuth2" }, + "authorized_application": { + "access_since": "ДоÑтуп з {date}", + "description": "Згадані Ñторонні додатки мають доÑтуп до Вашого облікового запиÑу. ДоÑтуп, Ñкий більше не потрібен або Ñкому більше не довірÑєте, можете відкликати будь-коли.", + "has_access_to": "Має доÑтуп до:", + "menu_title": "Сторонні додатки", + "revoke": "Відкликати доÑтуп", + "revoke_question": "Ви дійÑно хочете відкликати доÑтуп Ð´Ð»Ñ Ñ†ÑŒÐ¾Ð³Ð¾ додатку?", + "subtitle": "Сторонні додатки з доÑтупом до Вашого облікового запиÑу", + "title": "Сторонні додатки", + "valid_until": "ДійÑне до {date}" + }, "authorized_token": { "menu_title": "Ðвторизовані додатки" } @@ -225,5 +241,11 @@ "dismiss": "ВідмовитиÑÑ", "new_version_available": "ДоÑтупна нова верÑÑ–Ñ Ð¿Ñ€Ð¾Ð³Ñ€Ð°Ð¼Ð¸", "update": "Оновити" + }, + "status": { + "changes": "У Ð’Ð°Ñ Ñ” незбережені зміни.", + "error": "Під Ñ‡Ð°Ñ Ð·Ð±ÐµÑ€ÐµÐ¶ÐµÐ½Ð½Ñ Ð¾Ñтанньої зміни виникла помилка.", + "saved": "УÑÑ– зміни збережені.", + "updating": "Зміни ÑинхронізуютьÑÑ." } } diff --git a/aleksis/core/frontend/routes.js b/aleksis/core/frontend/routes.js index c0228890bb92d21666d3b01c603263391f82d3ad..df94bb0e461e7a01bd76ac55801865d76973bfcb 100644 --- a/aleksis/core/frontend/routes.js +++ b/aleksis/core/frontend/routes.js @@ -288,14 +288,23 @@ const routes = [ permission: "core.invite_rule", }, }, + { + path: "/rooms/", + component: () => import("./components/room/RoomInlineList.vue"), + name: "core.rooms", + meta: { + inMenu: true, + titleKey: "rooms.menu_title", + toolbarTitle: "rooms.menu_title", + icon: "mdi-floor-plan", + permission: "core.view_rooms_rule", + }, + }, ], }, { path: "#", - component: () => import("./components/LegacyBaseTemplate.vue"), - props: { - byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true, - }, + component: () => import("./components/Parent.vue"), name: "core.administration", meta: { inMenu: true, @@ -344,10 +353,8 @@ const routes = [ }, { path: "/school_terms/", - component: () => import("./components/LegacyBaseTemplate.vue"), - props: { - byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true, - }, + component: () => + import("./components/school_term/SchoolTermInlineList.vue"), name: "core.school_terms", meta: { inMenu: true, @@ -356,22 +363,6 @@ const routes = [ permission: "core.view_schoolterm_rule", }, }, - { - path: "/school_terms/create/", - component: () => import("./components/LegacyBaseTemplate.vue"), - props: { - byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true, - }, - name: "core.create_school_term", - }, - { - path: "/school_terms/:pk(\\d+)/", - component: () => import("./components/LegacyBaseTemplate.vue"), - props: { - byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true, - }, - name: "core.editSchoolTerm", - }, { path: "/dashboard_widgets/", component: () => import("./components/LegacyBaseTemplate.vue"), diff --git a/aleksis/core/locale/ru/LC_MESSAGES/django.po b/aleksis/core/locale/ru/LC_MESSAGES/django.po index ac2e3f0fd5a295ab08281388f545cbe3bfed179b..e5977044ca69fff8d2af319bdcc54c59dd11387a 100644 --- a/aleksis/core/locale/ru/LC_MESSAGES/django.po +++ b/aleksis/core/locale/ru/LC_MESSAGES/django.po @@ -7,46 +7,44 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-05-09 18:40+0200\n" -"PO-Revision-Date: 2023-02-08 22:40+0000\n" +"POT-Creation-Date: 2023-02-27 13:23+0100\n" +"PO-Revision-Date: 2023-05-26 04:37+0000\n" "Last-Translator: Serhii Horichenko <m@sgg.im>\n" -"Language-Team: Russian <https://translate.edugit.org/projects/aleksis/aleksis-core/ru/>\n" +"Language-Team: Russian <https://translate.edugit.org/projects/aleksis/" +"aleksis-core/ru/>\n" "Language: ru\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=4; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<12 || n%100>14) ? 1 : n%10==0 || (n%10>=5 && n%10<=9) || (n%100>=11 && n%100<=14)? 2 : 3);\n" +"Plural-Forms: nplurals=4; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n" +"%10<=4 && (n%100<12 || n%100>14) ? 1 : n%10==0 || (n%10>=5 && n%10<=9) || (n" +"%100>=11 && n%100<=14)? 2 : 3);\n" "X-Generator: Weblate 4.12.1\n" -#: aleksis/core/apps.py:151 -#, fuzzy -#| msgid "The preferences have been saved successfully." -msgid "You have been logged out successfully." -msgstr "СвойÑтва Ñохранены." - -#: aleksis/core/apps.py:161 +#: aleksis/core/apps.py:155 aleksis/core/apps.py:161 msgid "OpenID Connect scope" msgstr "Граница дейÑÑ‚Ð²Ð¸Ñ OpenID Connect" -#: aleksis/core/apps.py:162 +#: aleksis/core/apps.py:156 aleksis/core/apps.py:162 msgid "Given name, family name, link to profile and picture if existing." msgstr "ИмÑ, фамилиÑ, ÑÑылка на профиль и фото, еÑли еÑть." -#: aleksis/core/apps.py:163 +#: aleksis/core/apps.py:157 aleksis/core/apps.py:163 msgid "Full home postal address" msgstr "Полный домашний почтовый адреÑ" -#: aleksis/core/apps.py:164 +#: aleksis/core/apps.py:158 aleksis/core/apps.py:164 msgid "Email address" msgstr "ÐÐ´Ñ€ÐµÑ Ñл.почты" -#: aleksis/core/apps.py:165 +#: aleksis/core/apps.py:159 aleksis/core/apps.py:165 msgid "Home and mobile phone" msgstr "Домашний и мобильный телефоны" -#: aleksis/core/apps.py:166 aleksis/core/forms.py:221 -#: aleksis/core/models.py:495 aleksis/core/templates/core/group/list.html:8 -#: aleksis/core/templates/core/group/list.html:9 +#: aleksis/core/apps.py:160 aleksis/core/forms.py:220 +#: aleksis/core/models.py:494 aleksis/core/templates/core/group/list.html:8 +#: aleksis/core/templates/core/group/list.html:9 aleksis/core/apps.py:166 +#: aleksis/core/forms.py:221 aleksis/core/models.py:495 msgid "Groups" msgstr "Группы" @@ -104,172 +102,187 @@ msgstr "Разрешение" msgid "Content type" msgstr "Тип Ñодержимого" -#: aleksis/core/filters.py:113 aleksis/core/models.py:721 +#: aleksis/core/filters.py:113 aleksis/core/models.py:720 +#: aleksis/core/models.py:721 msgid "User" msgstr "Пользователь" -#: aleksis/core/filters.py:135 aleksis/core/models.py:494 +#: aleksis/core/filters.py:135 aleksis/core/models.py:493 +#: aleksis/core/models.py:494 msgid "Group" msgstr "Группа" -#: aleksis/core/forms.py:51 aleksis/core/forms.py:582 +#: aleksis/core/forms.py:50 aleksis/core/forms.py:581 aleksis/core/forms.py:51 +#: aleksis/core/forms.py:582 msgid "Base data" msgstr "ОÑновные данные" -#: aleksis/core/forms.py:56 aleksis/core/tables.py:47 +#: aleksis/core/forms.py:55 aleksis/core/tables.py:47 aleksis/core/forms.py:56 msgid "Address" msgstr "ÐдреÑ" -#: aleksis/core/forms.py:57 aleksis/core/forms.py:591 +#: aleksis/core/forms.py:56 aleksis/core/forms.py:590 aleksis/core/forms.py:57 +#: aleksis/core/forms.py:591 msgid "Contact data" msgstr "Контактные данные" -#: aleksis/core/forms.py:59 +#: aleksis/core/forms.py:58 aleksis/core/forms.py:59 msgid "Advanced personal data" msgstr "Дополнительные личные данные" -#: aleksis/core/forms.py:107 +#: aleksis/core/forms.py:106 aleksis/core/forms.py:107 msgid "New user" msgstr "Ðовый пользователь" -#: aleksis/core/forms.py:107 +#: aleksis/core/forms.py:106 aleksis/core/forms.py:107 msgid "Create a new account" msgstr "Создать новую учётную запиÑÑŒ" -#: aleksis/core/forms.py:133 +#: aleksis/core/forms.py:132 aleksis/core/forms.py:133 msgid "You cannot set a new username when also selecting an existing user." msgstr "ПоÑле выбора ÑущеÑтвующего Ð¿Ð¾Ð»ÑŒÐ·Ð¾Ð²Ð°Ñ‚ÐµÐ»Ñ Ñоздать новый логин нельзÑ." -#: aleksis/core/forms.py:137 +#: aleksis/core/forms.py:136 aleksis/core/forms.py:137 msgid "This username is already in use." msgstr "Ðтот логин уже занÑÑ‚." +#: aleksis/core/forms.py:153 aleksis/core/models.py:141 #: aleksis/core/forms.py:154 aleksis/core/models.py:142 msgid "School term" msgstr "Учебный год" -#: aleksis/core/forms.py:155 +#: aleksis/core/forms.py:154 aleksis/core/forms.py:155 msgid "Common data" msgstr "Общие данные" -#: aleksis/core/forms.py:156 aleksis/core/forms.py:208 -#: aleksis/core/models.py:165 aleksis/core/templates/core/person/list.html:8 -#: aleksis/core/templates/core/person/list.html:9 +#: aleksis/core/forms.py:155 aleksis/core/forms.py:207 +#: aleksis/core/models.py:164 aleksis/core/templates/core/person/list.html:8 +#: aleksis/core/templates/core/person/list.html:9 aleksis/core/forms.py:156 +#: aleksis/core/forms.py:208 aleksis/core/models.py:165 msgid "Persons" msgstr "Люди" +#: aleksis/core/forms.py:156 aleksis/core/forms.py:592 #: aleksis/core/forms.py:157 aleksis/core/forms.py:593 msgid "Additional data" msgstr "Дополнительные данные" +#: aleksis/core/forms.py:157 aleksis/core/models.py:217 +#: aleksis/core/models.py:546 aleksis/core/tables.py:46 #: aleksis/core/forms.py:158 aleksis/core/models.py:218 -#: aleksis/core/models.py:547 aleksis/core/tables.py:46 +#: aleksis/core/models.py:547 msgid "Photo" msgstr "Фото" -#: aleksis/core/forms.py:200 aleksis/core/forms.py:203 -#: aleksis/core/models.py:85 +#: aleksis/core/forms.py:199 aleksis/core/forms.py:202 +#: aleksis/core/models.py:84 aleksis/core/forms.py:200 +#: aleksis/core/forms.py:203 aleksis/core/models.py:85 msgid "Date" msgstr "Дата" -#: aleksis/core/forms.py:201 aleksis/core/forms.py:204 -#: aleksis/core/models.py:93 +#: aleksis/core/forms.py:200 aleksis/core/forms.py:203 +#: aleksis/core/models.py:92 aleksis/core/forms.py:201 +#: aleksis/core/forms.py:204 aleksis/core/models.py:93 msgid "Time" msgstr "ВремÑ" -#: aleksis/core/forms.py:234 +#: aleksis/core/forms.py:233 aleksis/core/forms.py:234 msgid "From when until when should the announcement be displayed?" msgstr "С какого и по какое Ð²Ñ€ÐµÐ¼Ñ Ñто объÑвление должно отображатьÑÑ?" -#: aleksis/core/forms.py:237 +#: aleksis/core/forms.py:236 aleksis/core/forms.py:237 msgid "Who should see the announcement?" msgstr "Кто должен видеть Ñто объÑвление?" -#: aleksis/core/forms.py:238 +#: aleksis/core/forms.py:237 aleksis/core/forms.py:238 msgid "Write your announcement:" msgstr "Ðапишите Ñвое объÑвление:" -#: aleksis/core/forms.py:277 -msgid "You are not allowed to create announcements which are only valid in the past." +#: aleksis/core/forms.py:276 aleksis/core/forms.py:277 +msgid "" +"You are not allowed to create announcements which are only valid in the past." msgstr "ОбъÑÐ²Ð»ÐµÐ½Ð¸Ñ Ð´Ð»Ñ Ð¿Ñ€Ð¾ÑˆÐ»Ð¾Ð³Ð¾ Вам Ñоздавать не разрешено." -#: aleksis/core/forms.py:281 +#: aleksis/core/forms.py:280 aleksis/core/forms.py:281 msgid "The from date and time must be earlier then the until date and time." msgstr "Дата и Ð²Ñ€ÐµÐ¼Ñ Ð½Ð°Ñ‡Ð°Ð»Ð° должны быть до даты и времени окончаниÑ." -#: aleksis/core/forms.py:290 +#: aleksis/core/forms.py:289 aleksis/core/forms.py:290 msgid "You need at least one recipient." msgstr "Ðужен Ñ…Ð¾Ñ‚Ñ Ð±Ñ‹ один получатель." -#: aleksis/core/forms.py:399 +#: aleksis/core/forms.py:398 aleksis/core/forms.py:399 msgid "Invitation code" msgstr "Код приглашениÑ" -#: aleksis/core/forms.py:400 +#: aleksis/core/forms.py:399 aleksis/core/forms.py:400 msgid "Please enter your invitation code." msgstr "Укажите, пожалуйÑта, код приглашениÑ." +#: aleksis/core/forms.py:418 aleksis/core/models.py:192 #: aleksis/core/forms.py:419 aleksis/core/models.py:193 msgid "First name" msgstr "ИмÑ" +#: aleksis/core/forms.py:419 aleksis/core/models.py:193 #: aleksis/core/forms.py:420 aleksis/core/models.py:194 msgid "Last name" msgstr "ФамилиÑ" -#: aleksis/core/forms.py:429 +#: aleksis/core/forms.py:428 aleksis/core/forms.py:429 msgid "A person is using this e-mail address" msgstr "Ðтот Ñл.Ð°Ð´Ñ€ÐµÑ ÐºÐµÐ¼-то иÑпользуетÑÑ" -#: aleksis/core/forms.py:457 +#: aleksis/core/forms.py:456 aleksis/core/forms.py:457 msgid "Who should get the permission?" msgstr "Кто должен получить такое разрешение?" -#: aleksis/core/forms.py:458 +#: aleksis/core/forms.py:457 aleksis/core/forms.py:458 msgid "On what?" msgstr "Ð’ Ñлучае чего?" -#: aleksis/core/forms.py:484 +#: aleksis/core/forms.py:483 aleksis/core/forms.py:484 msgid "Select objects which the permission should be granted for:" msgstr "Отметьте объекты, к которым будет предоÑтавлен доÑтуп:" -#: aleksis/core/forms.py:487 +#: aleksis/core/forms.py:486 aleksis/core/forms.py:487 msgid "Grant the permission for all objects" msgstr "ПредоÑтавить доÑтуп ко вÑем объектам" -#: aleksis/core/forms.py:495 -msgid "You must select at least one group or person which should get the permission." +#: aleksis/core/forms.py:494 aleksis/core/forms.py:495 +msgid "" +"You must select at least one group or person which should get the permission." msgstr "Вам нужно выбрать Ñ…Ð¾Ñ‚Ñ Ð±Ñ‹ одну группу или физлицо, кто получит доÑтуп." -#: aleksis/core/forms.py:500 +#: aleksis/core/forms.py:499 aleksis/core/forms.py:500 msgid "You must grant the permission to all objects or to specific objects." msgstr "Ð’Ñ‹ должны предоÑтавить доÑтуп ко вÑем или к конкретным объектам." -#: aleksis/core/forms.py:587 +#: aleksis/core/forms.py:586 aleksis/core/forms.py:587 msgid "Address data" msgstr "ПодробноÑти адреÑа" -#: aleksis/core/forms.py:599 +#: aleksis/core/forms.py:598 aleksis/core/forms.py:599 msgid "Account data" msgstr "Данные учётной запиÑи" -#: aleksis/core/forms.py:606 +#: aleksis/core/forms.py:605 aleksis/core/forms.py:606 msgid "Password" msgstr "Пароль" -#: aleksis/core/forms.py:609 +#: aleksis/core/forms.py:608 aleksis/core/forms.py:609 msgid "Password (again)" msgstr "Пароль (ещё раз)" -#: aleksis/core/forms.py:762 +#: aleksis/core/forms.py:761 aleksis/core/forms.py:762 msgid "The selected action does not exist." msgstr "Выбранное дейÑтвие не ÑущеÑтвует." -#: aleksis/core/forms.py:773 +#: aleksis/core/forms.py:772 aleksis/core/forms.py:773 msgid "You do not have permission to run {} on all selected objects." msgstr "У Ð’Ð°Ñ Ð½ÐµÑ‚ Ñ€Ð°Ð·Ñ€ÐµÑˆÐµÐ½Ð¸Ñ Ð½Ð° запуÑк {} на вÑех выбранных объектах." -#: aleksis/core/forms.py:829 +#: aleksis/core/forms.py:828 aleksis/core/forms.py:829 msgid "No valid selection." msgstr "Ðеправильный выбор." @@ -297,658 +310,690 @@ msgstr "Результат резервного ÐºÐ¾Ð¿Ð¸Ñ€Ð¾Ð²Ð°Ð½Ð¸Ñ Ð½Ðµ на msgid "Linked school term" msgstr "СвÑзанный учебный год" -#: aleksis/core/models.py:83 +#: aleksis/core/models.py:82 aleksis/core/models.py:83 msgid "Boolean (Yes/No)" msgstr "Булево (Да/Ðет)" -#: aleksis/core/models.py:84 +#: aleksis/core/models.py:83 aleksis/core/models.py:84 msgid "Text (one line)" msgstr "ТекÑÑ‚ (одна Ñтрока)" -#: aleksis/core/models.py:86 +#: aleksis/core/models.py:85 aleksis/core/models.py:86 msgid "Date and time" msgstr "Дата и времÑ" -#: aleksis/core/models.py:87 +#: aleksis/core/models.py:86 aleksis/core/models.py:87 msgid "Decimal number" msgstr "ДеÑÑтичное чиÑло" +#: aleksis/core/models.py:87 aleksis/core/models.py:210 #: aleksis/core/models.py:88 aleksis/core/models.py:211 msgid "E-mail address" msgstr "ÐÐ´Ñ€ÐµÑ Ñл.почты" -#: aleksis/core/models.py:89 +#: aleksis/core/models.py:88 aleksis/core/models.py:89 msgid "Integer" msgstr "Целое" -#: aleksis/core/models.py:90 +#: aleksis/core/models.py:89 aleksis/core/models.py:90 msgid "IP address" msgstr "IP адреÑ" -#: aleksis/core/models.py:91 +#: aleksis/core/models.py:90 aleksis/core/models.py:91 msgid "Boolean or empty (Yes/No/Neither)" msgstr "Булево или пуÑтое (Да/Ðет/Ðичего)" -#: aleksis/core/models.py:92 +#: aleksis/core/models.py:91 aleksis/core/models.py:92 msgid "Text (multi-line)" msgstr "ТекÑÑ‚ (многоÑтрочный)" -#: aleksis/core/models.py:94 +#: aleksis/core/models.py:93 aleksis/core/models.py:94 msgid "URL / Link" msgstr "URL / СÑылка" +#: aleksis/core/models.py:105 aleksis/core/models.py:1077 #: aleksis/core/models.py:106 aleksis/core/models.py:1078 msgid "Name" -msgstr "ИмÑ" +msgstr "Полное имÑ" -#: aleksis/core/models.py:108 +#: aleksis/core/models.py:107 aleksis/core/models.py:108 msgid "Start date" msgstr "Дата начала" -#: aleksis/core/models.py:109 +#: aleksis/core/models.py:108 aleksis/core/models.py:109 msgid "End date" msgstr "Дата окончаниÑ" -#: aleksis/core/models.py:128 +#: aleksis/core/models.py:127 aleksis/core/models.py:128 msgid "The start date must be earlier than the end date." msgstr "Дата начала должна быть ранее даты окончаниÑ." -#: aleksis/core/models.py:135 +#: aleksis/core/models.py:134 aleksis/core/models.py:135 msgid "There is already a school term for this time or a part of this time." msgstr "Ðа Ñто Ð²Ñ€ÐµÐ¼Ñ Ð¸Ð»Ð¸ на его чаÑть уже запланирован учебный год." -#: aleksis/core/models.py:143 +#: aleksis/core/models.py:142 #: aleksis/core/templates/core/school_term/list.html:8 #: aleksis/core/templates/core/school_term/list.html:9 +#: aleksis/core/models.py:143 msgid "School terms" msgstr "Учебный год" +#: aleksis/core/models.py:163 aleksis/core/models.py:1026 #: aleksis/core/models.py:164 aleksis/core/models.py:1027 msgid "Person" msgstr "Физлицо" -#: aleksis/core/models.py:167 +#: aleksis/core/models.py:166 aleksis/core/models.py:167 msgid "Can view address" msgstr "Может видеть адреÑ" -#: aleksis/core/models.py:168 +#: aleksis/core/models.py:167 aleksis/core/models.py:168 msgid "Can view contact details" msgstr "Может видеть контактные данные" -#: aleksis/core/models.py:169 +#: aleksis/core/models.py:168 aleksis/core/models.py:169 msgid "Can view photo" msgstr "Может видеть фото" -#: aleksis/core/models.py:170 +#: aleksis/core/models.py:169 aleksis/core/models.py:170 msgid "Can view avatar image" msgstr "Может видеть аватар" -#: aleksis/core/models.py:171 +#: aleksis/core/models.py:170 aleksis/core/models.py:171 msgid "Can view persons groups" msgstr "Может видеть группы лиц" -#: aleksis/core/models.py:172 +#: aleksis/core/models.py:171 aleksis/core/models.py:172 msgid "Can view personal details" msgstr "Может видеть личные данные" -#: aleksis/core/models.py:182 +#: aleksis/core/models.py:181 aleksis/core/models.py:182 msgid "female" msgstr "жен" -#: aleksis/core/models.py:182 +#: aleksis/core/models.py:181 aleksis/core/models.py:182 msgid "male" msgstr "муж" -#: aleksis/core/models.py:182 +#: aleksis/core/models.py:181 aleksis/core/models.py:182 msgid "other" msgstr "другой" +#: aleksis/core/models.py:189 aleksis/core/models.py:1353 #: aleksis/core/models.py:190 aleksis/core/models.py:1382 msgid "Linked user" msgstr "СвÑзанный пользователь" -#: aleksis/core/models.py:196 +#: aleksis/core/models.py:195 aleksis/core/models.py:196 msgid "Additional name(s)" msgstr "Дополнительные имена" -#: aleksis/core/models.py:200 aleksis/core/models.py:512 -#: aleksis/core/models.py:1468 +#: aleksis/core/models.py:199 aleksis/core/models.py:511 +#: aleksis/core/models.py:1439 aleksis/core/models.py:200 +#: aleksis/core/models.py:512 aleksis/core/models.py:1468 msgid "Short name" msgstr "Короткое имÑ" -#: aleksis/core/models.py:203 +#: aleksis/core/models.py:202 aleksis/core/models.py:203 msgid "Street" msgstr "Улица" -#: aleksis/core/models.py:204 +#: aleksis/core/models.py:203 aleksis/core/models.py:204 msgid "Street number" msgstr "Ðомер дома" -#: aleksis/core/models.py:205 +#: aleksis/core/models.py:204 aleksis/core/models.py:205 msgid "Postal code" msgstr "Почтовый индекÑ" -#: aleksis/core/models.py:206 +#: aleksis/core/models.py:205 aleksis/core/models.py:206 msgid "Place" msgstr "Город/меÑто" -#: aleksis/core/models.py:208 +#: aleksis/core/models.py:207 aleksis/core/models.py:208 msgid "Home phone" msgstr "Домашний телефон" -#: aleksis/core/models.py:209 +#: aleksis/core/models.py:208 aleksis/core/models.py:209 msgid "Mobile phone" msgstr "Мобильный телефон" -#: aleksis/core/models.py:213 +#: aleksis/core/models.py:212 aleksis/core/models.py:213 msgid "Date of birth" msgstr "Дата рождениÑ" -#: aleksis/core/models.py:214 +#: aleksis/core/models.py:213 aleksis/core/models.py:214 msgid "Place of birth" msgstr "МеÑто рождениÑ" -#: aleksis/core/models.py:215 +#: aleksis/core/models.py:214 aleksis/core/models.py:215 msgid "Sex" msgstr "Пол" +#: aleksis/core/models.py:221 aleksis/core/models.py:550 #: aleksis/core/models.py:222 aleksis/core/models.py:551 -msgid "This is an official photo, used for official documents and for internal use cases." -msgstr "Ðто официальное фото, которое иÑпользуетÑÑ Ð´Ð»Ñ Ð´Ð¾ÐºÑƒÐ¼ÐµÐ½Ñ‚Ð¾Ð² и внутренних нужд." +msgid "" +"This is an official photo, used for official documents and for internal use " +"cases." +msgstr "" +"Ðто официальное фото, которое иÑпользуетÑÑ Ð´Ð»Ñ Ð´Ð¾ÐºÑƒÐ¼ÐµÐ½Ñ‚Ð¾Ð² и внутренних нужд." +#: aleksis/core/models.py:226 aleksis/core/models.py:554 #: aleksis/core/models.py:227 aleksis/core/models.py:555 msgid "Display picture / Avatar" msgstr "Отобразить фото/аватар" +#: aleksis/core/models.py:229 aleksis/core/models.py:557 #: aleksis/core/models.py:230 aleksis/core/models.py:558 msgid "This is a picture or an avatar for public display." msgstr "Ðто фото или аватар Ð´Ð»Ñ Ð¾Ð±Ñ‰ÐµÐ³Ð¾ отображениÑ." -#: aleksis/core/models.py:235 +#: aleksis/core/models.py:234 aleksis/core/models.py:235 msgid "Guardians / Parents" msgstr "Опекуны / Родители" -#: aleksis/core/models.py:242 +#: aleksis/core/models.py:241 aleksis/core/models.py:242 msgid "Primary group" msgstr "ОÑÐ½Ð¾Ð²Ð½Ð°Ñ Ð³Ñ€ÑƒÐ¿Ð¿Ð°" -#: aleksis/core/models.py:245 aleksis/core/models.py:725 -#: aleksis/core/models.py:749 aleksis/core/models.py:844 -#: aleksis/core/models.py:1113 +#: aleksis/core/models.py:244 aleksis/core/models.py:724 +#: aleksis/core/models.py:748 aleksis/core/models.py:843 +#: aleksis/core/models.py:1112 aleksis/core/models.py:245 +#: aleksis/core/models.py:725 aleksis/core/models.py:749 +#: aleksis/core/models.py:844 aleksis/core/models.py:1113 msgid "Description" msgstr "ОпиÑание" -#: aleksis/core/models.py:465 +#: aleksis/core/models.py:464 aleksis/core/models.py:465 msgid "Title of field" msgstr "Ðазвание полÑ" -#: aleksis/core/models.py:467 +#: aleksis/core/models.py:466 aleksis/core/models.py:467 msgid "Type of field" msgstr "Тип полÑ" -#: aleksis/core/models.py:469 +#: aleksis/core/models.py:468 aleksis/core/models.py:469 msgid "Required" msgstr "Ðеобходимое" -#: aleksis/core/models.py:470 +#: aleksis/core/models.py:469 aleksis/core/models.py:470 msgid "Help text / description" msgstr "Ð’Ñпомогательный текÑÑ‚ / опиÑание" -#: aleksis/core/models.py:476 +#: aleksis/core/models.py:475 aleksis/core/models.py:476 msgid "Addtitional field for groups" msgstr "Дополнительное поле Ð´Ð»Ñ Ð³Ñ€ÑƒÐ¿Ð¿" -#: aleksis/core/models.py:477 +#: aleksis/core/models.py:476 aleksis/core/models.py:477 msgid "Addtitional fields for groups" msgstr "Дополнительные Ð¿Ð¾Ð»Ñ Ð´Ð»Ñ Ð³Ñ€ÑƒÐ¿Ð¿" -#: aleksis/core/models.py:497 +#: aleksis/core/models.py:496 aleksis/core/models.py:497 msgid "Can assign child groups to groups" msgstr "Может определÑть дочерние группы в группы" -#: aleksis/core/models.py:498 +#: aleksis/core/models.py:497 aleksis/core/models.py:498 msgid "Can view statistics about group." msgstr "Может видеть ÑтатиÑтику группы." +#: aleksis/core/models.py:509 aleksis/core/models.py:1440 #: aleksis/core/models.py:510 aleksis/core/models.py:1469 msgid "Long name" msgstr "Длинное имÑ" -#: aleksis/core/models.py:520 aleksis/core/templates/core/group/full.html:105 +#: aleksis/core/models.py:519 aleksis/core/templates/core/group/full.html:105 +#: aleksis/core/models.py:520 msgid "Members" msgstr "УчаÑтники" -#: aleksis/core/models.py:523 aleksis/core/templates/core/group/full.html:102 +#: aleksis/core/models.py:522 aleksis/core/templates/core/group/full.html:102 +#: aleksis/core/models.py:523 msgid "Owners" msgstr "Владельцы" -#: aleksis/core/models.py:530 aleksis/core/templates/core/group/full.html:59 +#: aleksis/core/models.py:529 aleksis/core/templates/core/group/full.html:59 +#: aleksis/core/models.py:530 msgid "Parent groups" msgstr "РодительÑкие группы" -#: aleksis/core/models.py:538 +#: aleksis/core/models.py:537 aleksis/core/models.py:538 msgid "Type of group" msgstr "Тип группы" -#: aleksis/core/models.py:543 +#: aleksis/core/models.py:542 #: aleksis/core/templates/core/additional_field/list.html:8 #: aleksis/core/templates/core/additional_field/list.html:9 +#: aleksis/core/models.py:543 msgid "Additional fields" msgstr "Дополнительные полÑ" +#: aleksis/core/models.py:723 aleksis/core/models.py:747 +#: aleksis/core/models.py:842 aleksis/core/models.py:1270 +#: aleksis/core/templates/core/announcement/list.html:18 #: aleksis/core/models.py:724 aleksis/core/models.py:748 #: aleksis/core/models.py:843 aleksis/core/models.py:1299 -#: aleksis/core/templates/core/announcement/list.html:18 msgid "Title" msgstr "Ðазвание" -#: aleksis/core/models.py:727 +#: aleksis/core/models.py:726 aleksis/core/models.py:727 msgid "Application" msgstr "Приложение" -#: aleksis/core/models.py:733 +#: aleksis/core/models.py:732 aleksis/core/models.py:733 msgid "Activity" msgstr "ÐктивноÑть" -#: aleksis/core/models.py:734 +#: aleksis/core/models.py:733 aleksis/core/models.py:734 msgid "Activities" msgstr "ÐктивноÑти" -#: aleksis/core/models.py:740 +#: aleksis/core/models.py:739 aleksis/core/models.py:740 msgid "Sender" msgstr "Отправитель" -#: aleksis/core/models.py:745 +#: aleksis/core/models.py:744 aleksis/core/models.py:745 msgid "Recipient" msgstr "Получатель" +#: aleksis/core/models.py:749 aleksis/core/models.py:1078 #: aleksis/core/models.py:750 aleksis/core/models.py:1079 msgid "Link" msgstr "СÑылка" +#: aleksis/core/models.py:752 aleksis/core/models.py:1079 +#: aleksis/core/models.py:1400 +#: aleksis/core/templates/oauth2_provider/application/detail.html:26 #: aleksis/core/models.py:753 aleksis/core/models.py:1080 #: aleksis/core/models.py:1429 -#: aleksis/core/templates/oauth2_provider/application/detail.html:26 msgid "Icon" msgstr "Иконка" -#: aleksis/core/models.py:756 +#: aleksis/core/models.py:755 aleksis/core/models.py:756 msgid "Send notification at" msgstr "Отправить уведомление в" -#: aleksis/core/models.py:758 +#: aleksis/core/models.py:757 aleksis/core/models.py:758 msgid "Read" msgstr "Читать" -#: aleksis/core/models.py:759 +#: aleksis/core/models.py:758 aleksis/core/models.py:759 msgid "Sent" msgstr "Отправлено" -#: aleksis/core/models.py:776 +#: aleksis/core/models.py:775 aleksis/core/models.py:776 msgid "Notification" msgstr "Уведомление" -#: aleksis/core/models.py:777 aleksis/core/preferences.py:29 +#: aleksis/core/models.py:776 aleksis/core/preferences.py:29 +#: aleksis/core/models.py:777 msgid "Notifications" msgstr "УведомлениÑ" -#: aleksis/core/models.py:845 +#: aleksis/core/models.py:844 aleksis/core/models.py:845 msgid "Link to detailed view" msgstr "СÑылка на подробный обзор" -#: aleksis/core/models.py:848 +#: aleksis/core/models.py:847 aleksis/core/models.py:848 msgid "Date and time from when to show" msgstr "Дата и времÑ, Ñ ÐºÐ¾Ñ‚Ð¾Ñ€Ð¾Ð³Ð¾ показывать" -#: aleksis/core/models.py:851 +#: aleksis/core/models.py:850 aleksis/core/models.py:851 msgid "Date and time until when to show" msgstr "Дата и времÑ, по какое показывать" -#: aleksis/core/models.py:876 +#: aleksis/core/models.py:875 aleksis/core/models.py:876 msgid "Announcement" msgstr "ОбъÑвление" -#: aleksis/core/models.py:877 +#: aleksis/core/models.py:876 #: aleksis/core/templates/core/announcement/list.html:7 #: aleksis/core/templates/core/announcement/list.html:8 +#: aleksis/core/models.py:877 msgid "Announcements" msgstr "ОбъÑвление" -#: aleksis/core/models.py:914 +#: aleksis/core/models.py:913 aleksis/core/models.py:914 msgid "Announcement recipient" msgstr "Получатель объÑвлениÑ" -#: aleksis/core/models.py:915 +#: aleksis/core/models.py:914 aleksis/core/models.py:915 msgid "Announcement recipients" msgstr "Получатели объÑвлениÑ" -#: aleksis/core/models.py:935 +#: aleksis/core/models.py:934 aleksis/core/models.py:935 msgid "Widget Title" msgstr "Ðазвание виджета" -#: aleksis/core/models.py:936 +#: aleksis/core/models.py:935 aleksis/core/models.py:936 msgid "Activate Widget" msgstr "Ðктивировать виджет" -#: aleksis/core/models.py:937 +#: aleksis/core/models.py:936 aleksis/core/models.py:937 msgid "Widget is broken" msgstr "Виджет поломалÑÑ" -#: aleksis/core/models.py:940 +#: aleksis/core/models.py:939 aleksis/core/models.py:940 msgid "Size on mobile devices" msgstr "Размер на мобильных" -#: aleksis/core/models.py:941 +#: aleksis/core/models.py:940 aleksis/core/models.py:941 msgid "<= 600 px, 12 columns" msgstr "<= 600 пикÑ, 12 Ñтолбцов" -#: aleksis/core/models.py:946 +#: aleksis/core/models.py:945 aleksis/core/models.py:946 msgid "Size on tablet devices" msgstr "Размер на планшетах" -#: aleksis/core/models.py:947 +#: aleksis/core/models.py:946 aleksis/core/models.py:947 msgid "> 600 px, 12 columns" msgstr "> 600 пикÑ, 12 Ñтолбцов" -#: aleksis/core/models.py:952 +#: aleksis/core/models.py:951 aleksis/core/models.py:952 msgid "Size on desktop devices" msgstr "Размер на ПК" -#: aleksis/core/models.py:953 +#: aleksis/core/models.py:952 aleksis/core/models.py:953 msgid "> 992 px, 12 columns" msgstr "> 992 пикÑ, 12 Ñтолбцов" -#: aleksis/core/models.py:958 +#: aleksis/core/models.py:957 aleksis/core/models.py:958 msgid "Size on large desktop devices" msgstr "Размер Ð´Ð»Ñ Ð±Ð¾Ð»ÑŒÑˆÐ¸Ñ… Ñкранов" -#: aleksis/core/models.py:959 +#: aleksis/core/models.py:958 aleksis/core/models.py:959 msgid "> 1200 px>, 12 columns" msgstr "> 1200 пикÑ, 12 Ñтолбцов" -#: aleksis/core/models.py:990 +#: aleksis/core/models.py:989 aleksis/core/models.py:990 msgid "Can edit default dashboard" msgstr "Может редактировать типовую/Ñтандартную информпанель" -#: aleksis/core/models.py:991 +#: aleksis/core/models.py:990 aleksis/core/models.py:991 msgid "Dashboard Widget" msgstr "Виджет информпанели" -#: aleksis/core/models.py:992 +#: aleksis/core/models.py:991 aleksis/core/models.py:992 msgid "Dashboard Widgets" msgstr "Виджеты информпанели" -#: aleksis/core/models.py:998 +#: aleksis/core/models.py:997 aleksis/core/models.py:998 msgid "URL" msgstr "URL" -#: aleksis/core/models.py:999 +#: aleksis/core/models.py:998 aleksis/core/models.py:999 msgid "Icon URL" msgstr "Иконка URL" -#: aleksis/core/models.py:1005 +#: aleksis/core/models.py:1004 aleksis/core/models.py:1005 msgid "External link widget" msgstr "ВнешнÑÑ ÑÑылка на виджет" -#: aleksis/core/models.py:1006 +#: aleksis/core/models.py:1005 aleksis/core/models.py:1006 msgid "External link widgets" msgstr "Внешние ÑÑылки на виджеты" -#: aleksis/core/models.py:1012 +#: aleksis/core/models.py:1011 aleksis/core/models.py:1012 msgid "Content" msgstr "Содержимое" -#: aleksis/core/models.py:1018 +#: aleksis/core/models.py:1017 aleksis/core/models.py:1018 msgid "Static content widget" msgstr "Виджет Ñ Ð¿Ð¾ÑтоÑнным Ñодержимым" -#: aleksis/core/models.py:1019 +#: aleksis/core/models.py:1018 aleksis/core/models.py:1019 msgid "Static content widgets" msgstr "Виджеты Ñ Ð¿Ð¾ÑтоÑнным Ñодержимым" -#: aleksis/core/models.py:1024 +#: aleksis/core/models.py:1023 aleksis/core/models.py:1024 msgid "Dashboard widget" msgstr "Виджет информпанели" -#: aleksis/core/models.py:1029 +#: aleksis/core/models.py:1028 aleksis/core/models.py:1029 msgid "Order" msgstr "ПорÑдок" -#: aleksis/core/models.py:1030 +#: aleksis/core/models.py:1029 aleksis/core/models.py:1030 msgid "Part of the default dashboard" msgstr "ЧаÑть типовой информпанели" -#: aleksis/core/models.py:1045 +#: aleksis/core/models.py:1044 aleksis/core/models.py:1045 msgid "Dashboard widget order" msgstr "ПорÑдок виджета на информпанели" -#: aleksis/core/models.py:1046 +#: aleksis/core/models.py:1045 aleksis/core/models.py:1046 msgid "Dashboard widget orders" msgstr "ПорÑдок виджетов на информпанели" -#: aleksis/core/models.py:1052 +#: aleksis/core/models.py:1051 aleksis/core/models.py:1052 msgid "Menu ID" msgstr "Меню ID" -#: aleksis/core/models.py:1065 +#: aleksis/core/models.py:1064 aleksis/core/models.py:1065 msgid "Custom menu" msgstr "ПользовательÑкое меню" -#: aleksis/core/models.py:1066 +#: aleksis/core/models.py:1065 aleksis/core/models.py:1066 msgid "Custom menus" msgstr "ПользовательÑкие меню" -#: aleksis/core/models.py:1076 +#: aleksis/core/models.py:1075 aleksis/core/models.py:1076 msgid "Menu" msgstr "Меню" -#: aleksis/core/models.py:1086 +#: aleksis/core/models.py:1085 aleksis/core/models.py:1086 msgid "Custom menu item" msgstr "Пункт пользовательÑкого меню" -#: aleksis/core/models.py:1087 +#: aleksis/core/models.py:1086 aleksis/core/models.py:1087 msgid "Custom menu items" msgstr "Пункты пользовательÑкого меню" -#: aleksis/core/models.py:1112 +#: aleksis/core/models.py:1111 aleksis/core/models.py:1112 msgid "Title of type" msgstr "Ðазвание типа" -#: aleksis/core/models.py:1119 aleksis/core/templates/core/group/full.html:50 +#: aleksis/core/models.py:1118 aleksis/core/templates/core/group/full.html:50 +#: aleksis/core/models.py:1119 msgid "Group type" msgstr "Тип группы" -#: aleksis/core/models.py:1120 +#: aleksis/core/models.py:1119 #: aleksis/core/templates/core/group_type/list.html:8 #: aleksis/core/templates/core/group_type/list.html:9 +#: aleksis/core/models.py:1120 msgid "Group types" msgstr "Типы групп" -#: aleksis/core/models.py:1133 +#: aleksis/core/models.py:1132 aleksis/core/models.py:1133 msgid "Can view system status" msgstr "Может проÑматривать ÑоÑтоÑние ÑиÑтемы" -#: aleksis/core/models.py:1134 +#: aleksis/core/models.py:1133 aleksis/core/models.py:1134 msgid "Can manage data" msgstr "Может управлÑть данными" -#: aleksis/core/models.py:1135 +#: aleksis/core/models.py:1134 aleksis/core/models.py:1135 msgid "Can impersonate" msgstr "Может маÑкироватьÑÑ" -#: aleksis/core/models.py:1136 +#: aleksis/core/models.py:1135 aleksis/core/models.py:1136 msgid "Can use search" msgstr "Может иÑпользовать поиÑк" -#: aleksis/core/models.py:1137 +#: aleksis/core/models.py:1136 aleksis/core/models.py:1137 msgid "Can change site preferences" msgstr "Может менÑть ÑвойÑтва Ñайта" -#: aleksis/core/models.py:1138 +#: aleksis/core/models.py:1137 aleksis/core/models.py:1138 msgid "Can change person preferences" msgstr "Может менÑть перÑональные ÑвойÑтва" -#: aleksis/core/models.py:1139 +#: aleksis/core/models.py:1138 aleksis/core/models.py:1139 msgid "Can change group preferences" msgstr "Может менÑть ÑвойÑтва группы" -#: aleksis/core/models.py:1140 +#: aleksis/core/models.py:1139 aleksis/core/models.py:1140 msgid "Can test PDF generation" msgstr "Может генерировать теÑтовые PDF" -#: aleksis/core/models.py:1141 +#: aleksis/core/models.py:1140 aleksis/core/models.py:1141 msgid "Can invite persons" msgstr "Может приглашать других" -#: aleksis/core/models.py:1177 +#: aleksis/core/models.py:1176 aleksis/core/models.py:1177 msgid "Related data check task" msgstr "Задание проверки ÑвÑзанных данных" -#: aleksis/core/models.py:1185 +#: aleksis/core/models.py:1184 aleksis/core/models.py:1185 msgid "Issue solved" msgstr "Проблема решена" -#: aleksis/core/models.py:1186 +#: aleksis/core/models.py:1185 aleksis/core/models.py:1186 msgid "Notification sent" msgstr "Уведомление отправлено" -#: aleksis/core/models.py:1199 +#: aleksis/core/models.py:1198 aleksis/core/models.py:1199 msgid "Data check result" msgstr "Результат проверки данных" -#: aleksis/core/models.py:1200 +#: aleksis/core/models.py:1199 aleksis/core/models.py:1200 msgid "Data check results" msgstr "Результаты проверки данных" -#: aleksis/core/models.py:1202 +#: aleksis/core/models.py:1201 aleksis/core/models.py:1202 msgid "Can run data checks" msgstr "Может запуÑкать проверки данных" -#: aleksis/core/models.py:1203 +#: aleksis/core/models.py:1202 aleksis/core/models.py:1203 msgid "Can solve data check problems" msgstr "Может решать проблемы проверки данных" -#: aleksis/core/models.py:1210 +#: aleksis/core/models.py:1209 aleksis/core/models.py:1210 msgid "E-Mail address" msgstr "ÐÐ´Ñ€ÐµÑ Ñл.почты" -#: aleksis/core/models.py:1270 +#: aleksis/core/models.py:1241 aleksis/core/models.py:1270 msgid "Owner" msgstr "Владелец" -#: aleksis/core/models.py:1274 +#: aleksis/core/models.py:1245 aleksis/core/models.py:1274 msgid "File expires at" msgstr "Файл дейÑтвителен до" -#: aleksis/core/models.py:1277 +#: aleksis/core/models.py:1248 aleksis/core/models.py:1277 msgid "Generated HTML file" msgstr "Сгенерированный файл HTML" -#: aleksis/core/models.py:1280 +#: aleksis/core/models.py:1251 aleksis/core/models.py:1280 msgid "Generated PDF file" msgstr "Сгенерированный файл PDF" -#: aleksis/core/models.py:1287 +#: aleksis/core/models.py:1258 aleksis/core/models.py:1287 msgid "PDF file" msgstr "Файл PDF" -#: aleksis/core/models.py:1288 +#: aleksis/core/models.py:1259 aleksis/core/models.py:1288 msgid "PDF files" msgstr "Файлы PDF" -#: aleksis/core/models.py:1293 +#: aleksis/core/models.py:1264 aleksis/core/models.py:1293 msgid "Task result" msgstr "Результат заданиÑ" -#: aleksis/core/models.py:1296 +#: aleksis/core/models.py:1267 aleksis/core/models.py:1296 msgid "Task user" msgstr "Пользователь заданиÑ" -#: aleksis/core/models.py:1300 +#: aleksis/core/models.py:1271 aleksis/core/models.py:1300 msgid "Back URL" msgstr "URL Ð´Ð»Ñ Ð²Ð¾Ð·Ð²Ñ€Ð°Ñ‚Ð°" -#: aleksis/core/models.py:1301 +#: aleksis/core/models.py:1272 aleksis/core/models.py:1301 msgid "Progress title" msgstr "Ðазвание процеÑÑа" -#: aleksis/core/models.py:1302 +#: aleksis/core/models.py:1273 aleksis/core/models.py:1302 msgid "Error message" msgstr "Сообщение об ошибке" -#: aleksis/core/models.py:1303 +#: aleksis/core/models.py:1274 aleksis/core/models.py:1303 msgid "Success message" msgstr "Сообщение об уÑпехе" -#: aleksis/core/models.py:1304 +#: aleksis/core/models.py:1275 aleksis/core/models.py:1304 msgid "Redirect on success URL" msgstr "URL Ð´Ð»Ñ Ð¿ÐµÑ€ÐµÐ½Ð°Ð¿Ñ€Ð°Ð²Ð»ÐµÐ½Ð¸Ñ Ð² Ñлучае уÑпеха" -#: aleksis/core/models.py:1306 +#: aleksis/core/models.py:1277 aleksis/core/models.py:1306 msgid "Additional button title" msgstr "Ðазвание дополнительной кнопки" -#: aleksis/core/models.py:1308 +#: aleksis/core/models.py:1279 aleksis/core/models.py:1308 msgid "Additional button URL" msgstr "URL дополнительной кнопки" -#: aleksis/core/models.py:1310 +#: aleksis/core/models.py:1281 aleksis/core/models.py:1310 msgid "Additional button icon" msgstr "Иконка дополнительной кнопки" -#: aleksis/core/models.py:1312 +#: aleksis/core/models.py:1283 aleksis/core/models.py:1312 msgid "Result fetched" msgstr "Полученный результат" -#: aleksis/core/models.py:1337 +#: aleksis/core/models.py:1308 aleksis/core/models.py:1337 msgid "Background task completed successfully" msgstr "Фоновое задание уÑпешно завершено" -#: aleksis/core/models.py:1338 +#: aleksis/core/models.py:1309 aleksis/core/models.py:1338 msgid "The background task '{}' has been completed successfully." msgstr "Фоновое задание \"{}\" уÑпешно завершено." -#: aleksis/core/models.py:1344 +#: aleksis/core/models.py:1315 aleksis/core/models.py:1344 msgid "Background task failed" msgstr "Ошибка фонового заданиÑ" -#: aleksis/core/models.py:1345 +#: aleksis/core/models.py:1316 aleksis/core/models.py:1345 msgid "The background task '{}' has failed." msgstr "Ошибка фонового Ð·Ð°Ð´Ð°Ð½Ð¸Ñ \"{}\"." -#: aleksis/core/models.py:1354 +#: aleksis/core/models.py:1325 aleksis/core/models.py:1354 msgid "Background task" msgstr "Фоновое задание" -#: aleksis/core/models.py:1368 +#: aleksis/core/models.py:1339 aleksis/core/models.py:1368 msgid "Task user assignment" msgstr "Ðазначение Ð¿Ð¾Ð»ÑŒÐ·Ð¾Ð²Ð°Ñ‚ÐµÐ»Ñ Ð·Ð°Ð´Ð°Ð½Ð¸Ñ" -#: aleksis/core/models.py:1369 +#: aleksis/core/models.py:1340 aleksis/core/models.py:1369 msgid "Task user assignments" msgstr "ÐÐ°Ð·Ð½Ð°Ñ‡ÐµÐ½Ð¸Ñ Ð¿Ð¾Ð»ÑŒÐ·Ð¾Ð²Ð°Ñ‚ÐµÐ»ÐµÐ¹ заданиÑ" -#: aleksis/core/models.py:1385 +#: aleksis/core/models.py:1356 aleksis/core/models.py:1385 msgid "Additional attributes" msgstr "Дополнительные атрибуты" -#: aleksis/core/models.py:1423 +#: aleksis/core/models.py:1394 aleksis/core/models.py:1423 msgid "Allowed scopes that clients can request" msgstr "Разрешённые пределы дейÑтвиÑ, которые могут запрашивать клиенты" -#: aleksis/core/models.py:1433 -msgid "This image will be shown as icon in the authorization flow. It should be squared." -msgstr "Ðто изображение будет иÑпользоватьÑÑ Ð² качеÑтве значка во Ð²Ñ€ÐµÐ¼Ñ Ð°Ð²Ñ‚Ð¾Ñ€Ð¸Ð·Ð°Ñ†Ð¸Ð¸. Должно быть квадратным." +#: aleksis/core/models.py:1404 aleksis/core/models.py:1433 +msgid "" +"This image will be shown as icon in the authorization flow. It should be " +"squared." +msgstr "" +"Ðто изображение будет иÑпользоватьÑÑ Ð² качеÑтве значка во Ð²Ñ€ÐµÐ¼Ñ Ð°Ð²Ñ‚Ð¾Ñ€Ð¸Ð·Ð°Ñ†Ð¸Ð¸. " +"Должно быть квадратным." -#: aleksis/core/models.py:1478 +#: aleksis/core/models.py:1449 aleksis/core/models.py:1478 msgid "Can view room timetable" msgstr "Может проÑмативать раÑпиÑание комнаты" -#: aleksis/core/models.py:1480 +#: aleksis/core/models.py:1451 aleksis/core/models.py:1480 msgid "Room" msgstr "Комната" -#: aleksis/core/models.py:1481 +#: aleksis/core/models.py:1452 aleksis/core/models.py:1481 msgid "Rooms" msgstr "Комнаты" @@ -1061,8 +1106,11 @@ msgid "Automatically create new persons for new users" msgstr "Ðовые физлица Ð´Ð»Ñ Ð½Ð¾Ð²Ñ‹Ñ… пользователей Ñоздавать автоматичеÑки" #: aleksis/core/preferences.py:256 -msgid "Automatically link existing persons to new users by their e-mail address" -msgstr "СвÑзывать ÑущеÑтвующие физлица Ñ Ð½Ð¾Ð²Ñ‹Ð¼Ð¸ пользователÑми автоматичеÑки по Ñл.адреÑам" +msgid "" +"Automatically link existing persons to new users by their e-mail address" +msgstr "" +"СвÑзывать ÑущеÑтвующие физлица Ñ Ð½Ð¾Ð²Ñ‹Ð¼Ð¸ пользователÑми автоматичеÑки по Ñл." +"адреÑам" #: aleksis/core/preferences.py:267 msgid "Display name of the school" @@ -1070,7 +1118,9 @@ msgstr "Ðазвание школы / уч.Ð·Ð°Ð²ÐµÐ´ÐµÐ½Ð¸Ñ Ð´Ð»Ñ Ð¾Ñ‚Ð¾Ð±Ñ€ #: aleksis/core/preferences.py:278 msgid "Official name of the school, e.g. as given by supervisory authority" -msgstr "Официальное название школы / уч.заведениÑ, напр., как в региÑтрационных документах" +msgstr "" +"Официальное название школы / уч.заведениÑ, напр., как в региÑтрационных " +"документах" #: aleksis/core/preferences.py:286 msgid "Allow users to change their passwords" @@ -1129,8 +1179,11 @@ msgid "Fields on person model which are editable by themselves." msgstr "ÐŸÐ¾Ð»Ñ Ñ Ð¾Ð¿Ð¸Ñанием физлица, которые можно редактировать ÑамоÑтоÑтельно." #: aleksis/core/preferences.py:424 -msgid "Editable fields on person model which should trigger a notification on change" -msgstr "ИзменÑемые Ð¿Ð¾Ð»Ñ Ð¾Ð¿Ð¸ÑÐ°Ð½Ð¸Ñ Ñ„Ð¸Ð·Ð»Ð¸Ñ†Ð°, при изменении которых должен Ñрабатывать триггер Ð´Ð»Ñ ÑƒÐ²ÐµÐ´Ð¾Ð¼Ð»ÐµÐ½Ð¸Ñ" +msgid "" +"Editable fields on person model which should trigger a notification on change" +msgstr "" +"ИзменÑемые Ð¿Ð¾Ð»Ñ Ð¾Ð¿Ð¸ÑÐ°Ð½Ð¸Ñ Ñ„Ð¸Ð·Ð»Ð¸Ñ†Ð°, при изменении которых должен Ñрабатывать " +"триггер Ð´Ð»Ñ ÑƒÐ²ÐµÐ´Ð¾Ð¼Ð»ÐµÐ½Ð¸Ñ" #: aleksis/core/preferences.py:438 msgid "Contact for notification if a person changes their data" @@ -1160,15 +1213,15 @@ msgstr "ÐвтоматичеÑки обновлÑть информпанель msgid "Country for phone number parsing" msgstr "Страна Ð´Ð»Ñ Ð¿Ð°Ñ€Ñинга номера телефона" -#: aleksis/core/settings.py:551 +#: aleksis/core/settings.py:549 aleksis/core/settings.py:551 msgid "English" msgstr "ÐнглийÑкий" -#: aleksis/core/settings.py:552 +#: aleksis/core/settings.py:550 aleksis/core/settings.py:552 msgid "German" msgstr "Ðемецкий" -#: aleksis/core/settings.py:553 +#: aleksis/core/settings.py:551 aleksis/core/settings.py:553 msgid "Ukrainian" msgstr "УкраинÑкий" @@ -1180,18 +1233,20 @@ msgid "Edit" msgstr "Редактировать" #: aleksis/core/tables.py:27 aleksis/core/tables.py:148 -#: aleksis/core/tables.py:185 +#: aleksis/core/tables.py:192 #: aleksis/core/templates/core/announcement/list.html:22 +#: aleksis/core/tables.py:185 msgid "Actions" msgstr "ДейÑтвиÑ" #: aleksis/core/tables.py:115 aleksis/core/tables.py:116 #: aleksis/core/tables.py:130 aleksis/core/tables.py:146 -#: aleksis/core/tables.py:183 +#: aleksis/core/tables.py:190 #: aleksis/core/templates/core/announcement/list.html:42 #: aleksis/core/templates/core/group/full.html:33 #: aleksis/core/templates/core/pages/delete.html:22 #: aleksis/core/templates/oauth2_provider/application/detail.html:21 +#: aleksis/core/tables.py:183 msgid "Delete" msgstr "Удалить" @@ -1211,12 +1266,14 @@ msgstr "" #: aleksis/core/templates/403.html:19 aleksis/core/templates/404.html:16 msgid "" "\n" -" If you think this is an error in AlekSIS, please contact your site\n" +" If you think this is an error in AlekSIS, please contact your " +"site\n" " administrators:\n" " " msgstr "" "\n" -" ЕÑли Ð’Ñ‹ думаете, что Ñто ошибка AlekSIS, обратитеÑÑŒ, пожалуйÑта,\n" +" ЕÑли Ð’Ñ‹ думаете, что Ñто ошибка AlekSIS, обратитеÑÑŒ, " +"пожалуйÑта,\n" " к админиÑтраторам Ñайта:\n" " " @@ -1243,12 +1300,14 @@ msgstr "" #: aleksis/core/templates/500.html:13 msgid "" "\n" -" Your site administrators will automatically be notified about this\n" +" Your site administrators will automatically be notified about " +"this\n" " error. You can also contact them directly:\n" " " msgstr "" "\n" -" ÐдминиÑтраторы Ñайта будут уведомлены об Ñтой ошибке автоматичеÑки.\n" +" ÐдминиÑтраторы Ñайта будут уведомлены об Ñтой ошибке " +"автоматичеÑки.\n" " Ð’Ñ‹ также можете обратитьÑÑ Ðº ним непоÑредÑтвенно:\n" " " @@ -1294,13 +1353,21 @@ msgstr "Подтвердить" #: aleksis/core/templates/account/email_confirm.html:12 #, python-format -msgid "Please confirm that <a href=\"mailto:%(email)s\">%(email)s</a> is an e-mail address for user %(user_display)s." -msgstr "Подтвердите, пожалуйÑта, что <a href=\"mailto:%(email)s\">%(email)s</a> — Ñл.Ð°Ð´Ñ€ÐµÑ Ð¿Ð¾Ð»ÑŒÐ·Ð¾Ð²Ð°Ñ‚ÐµÐ»Ñ %(user_display)s." +msgid "" +"Please confirm that <a href=\"mailto:%(email)s\">%(email)s</a> is an e-mail " +"address for user %(user_display)s." +msgstr "" +"Подтвердите, пожалуйÑта, что <a href=\"mailto:%(email)s\">%(email)s</a> — Ñл." +"Ð°Ð´Ñ€ÐµÑ Ð¿Ð¾Ð»ÑŒÐ·Ð¾Ð²Ð°Ñ‚ÐµÐ»Ñ %(user_display)s." #: aleksis/core/templates/account/email_confirm.html:25 #, python-format -msgid "This e-mail confirmation link expired or is invalid. Please <a href=\"%(email_url)s\">issue a new e-mail confirmation request</a>." -msgstr "Ðта ÑÑылка Ð´Ð»Ñ Ð¿Ð¾Ð´Ñ‚Ð²ÐµÑ€Ð¶Ð´ÐµÐ½Ð¸Ñ Ñл.почты проÑрочена или недейÑтвительна. Сделайте, пожалуйÑта, <a href=\"%(email_url)s\">новый запроÑ</a>." +msgid "" +"This e-mail confirmation link expired or is invalid. Please <a href=" +"\"%(email_url)s\">issue a new e-mail confirmation request</a>." +msgstr "" +"Ðта ÑÑылка Ð´Ð»Ñ Ð¿Ð¾Ð´Ñ‚Ð²ÐµÑ€Ð¶Ð´ÐµÐ½Ð¸Ñ Ñл.почты проÑрочена или недейÑтвительна. " +"Сделайте, пожалуйÑта, <a href=\"%(email_url)s\">новый запроÑ</a>." #: aleksis/core/templates/account/password_change.html:5 #: aleksis/core/templates/account/password_change.html:6 @@ -1334,7 +1401,8 @@ msgstr "Изменение Ð¿Ð°Ñ€Ð¾Ð»Ñ Ð¾Ñ‚ÐºÐ»ÑŽÑ‡ÐµÐ½Ð¾." msgid "" "\n" " Users are not allowed to edit their own passwords. If you think\n" -" this is an error please contact one of your site administrators.\n" +" this is an error please contact one of your site " +"administrators.\n" " " msgstr "" "\n" @@ -1353,8 +1421,12 @@ msgid "Reset password" msgstr "СброÑить пароль" #: aleksis/core/templates/account/password_reset.html:17 -msgid "Forgotten your password? Enter your e-mail address below, and we'll send you an e-mail allowing you to reset it." -msgstr "Забыли пароль? Укажите ниже Ñвою Ñл.почту и мы отправим Вам пиÑьмо Ð´Ð»Ñ ÑброÑа паролÑ." +msgid "" +"Forgotten your password? Enter your e-mail address below, and we'll send you " +"an e-mail allowing you to reset it." +msgstr "" +"Забыли пароль? Укажите ниже Ñвою Ñл.почту и мы отправим Вам пиÑьмо Ð´Ð»Ñ " +"ÑброÑа паролÑ." #: aleksis/core/templates/account/password_reset.html:30 msgid "" @@ -1377,8 +1449,10 @@ msgid "" " " msgstr "" "\n" -" Мы отправили Вам Ñл.пиÑьмо. ЕÑли Ð’Ñ‹ не получите его на протÑжении\n" -" неÑкольких минут, обратитеÑÑŒ, пожалуйÑта, к админиÑтраторам Ñайта.\n" +" Мы отправили Вам Ñл.пиÑьмо. ЕÑли Ð’Ñ‹ не получите его на " +"протÑжении\n" +" неÑкольких минут, обратитеÑÑŒ, пожалуйÑта, к админиÑтраторам " +"Ñайта.\n" " " #: aleksis/core/templates/account/password_reset_from_key.html:15 @@ -1389,13 +1463,16 @@ msgstr "Ðеправильный токен" #, python-format msgid "" "\n" -" The password reset link was invalid, possibly because it has already been used. Please request a <a href=\"%(passwd_reset_url)s\"\n" +" The password reset link was invalid, possibly because it has " +"already been used. Please request a <a href=\"%(passwd_reset_url)s\"\n" " class=\"blue-text text-lighten-2\">new password reset</a>.\n" " " msgstr "" "\n" -" СÑылка на ÑÐ±Ñ€Ð¾Ñ Ð¿Ð°Ñ€Ð¾Ð»Ñ Ð½ÐµÐ´ÐµÐ¹Ñтвительна или, возможно, уже иÑпользована. Сделайте, пожалуйÑта, <a href=\"%(passwd_reset_url)s\"\n" -" class=\"blue-text text-lighten-2\">новый Ð·Ð°Ð¿Ñ€Ð¾Ñ Ð½Ð° ÑÐ±Ñ€Ð¾Ñ Ð¿Ð°Ñ€Ð¾Ð»Ñ</a>.\n" +" СÑылка на ÑÐ±Ñ€Ð¾Ñ Ð¿Ð°Ñ€Ð¾Ð»Ñ Ð½ÐµÐ´ÐµÐ¹Ñтвительна или, возможно, уже " +"иÑпользована. Сделайте, пожалуйÑта, <a href=\"%(passwd_reset_url)s\"\n" +" class=\"blue-text text-lighten-2\">новый Ð·Ð°Ð¿Ñ€Ð¾Ñ Ð½Ð° ÑÐ±Ñ€Ð¾Ñ " +"паролÑ</a>.\n" " " #: aleksis/core/templates/account/password_reset_from_key.html:25 @@ -1444,8 +1521,11 @@ msgstr "РегиÑтрациÑ" #: aleksis/core/templates/account/signup.html:12 #, python-format -msgid "Already have an account? Then please <a href=\"%(login_url)s\">sign in</a>." -msgstr "У Ð’Ð°Ñ ÑƒÐ¶Ðµ еÑть ÑƒÑ‡Ñ‘Ñ‚Ð½Ð°Ñ Ð·Ð°Ð¿Ð¸ÑÑŒ? Ð’ таком Ñлучае можете <a href=\"%(login_url)s\">войти</a>." +msgid "" +"Already have an account? Then please <a href=\"%(login_url)s\">sign in</a>." +msgstr "" +"У Ð’Ð°Ñ ÑƒÐ¶Ðµ еÑть ÑƒÑ‡Ñ‘Ñ‚Ð½Ð°Ñ Ð·Ð°Ð¿Ð¸ÑÑŒ? Ð’ таком Ñлучае можете <a href=\"%(login_url)s" +"\">войти</a>." #: aleksis/core/templates/account/signup.html:22 #: aleksis/core/templates/socialaccount/signup.html:23 @@ -1489,13 +1569,17 @@ msgstr "Подтвердите Ñвой Ð°Ð´Ñ€ÐµÑ Ñл.почты!" #: aleksis/core/templates/account/verification_sent.html:16 msgid "" "\n" -" This part of the site requires us to verify that you are who you claim to be.\n" -" For this purpose, we require that you verify ownership of your e-mail address.\n" +" This part of the site requires us to verify that you are who you " +"claim to be.\n" +" For this purpose, we require that you verify ownership of your e-" +"mail address.\n" " " msgstr "" "\n" -" Ð”Ð»Ñ Ñтой чаÑти Ñайта необходимо пройти проверку, что Ð’Ñ‹ - именно Ð’Ñ‹.\n" -" Ð”Ð»Ñ Ñтого нам нужно проверить как минимум, что Ð°Ð´Ñ€ÐµÑ Ñл.почты принадлежит именно Вам\n" +" Ð”Ð»Ñ Ñтой чаÑти Ñайта необходимо пройти проверку, что Ð’Ñ‹ - именно " +"Ð’Ñ‹.\n" +" Ð”Ð»Ñ Ñтого нам нужно проверить как минимум, что Ð°Ð´Ñ€ÐµÑ Ñл.почты " +"принадлежит именно Вам\n" " " #: aleksis/core/templates/account/verification_sent.html:22 @@ -1509,7 +1593,8 @@ msgstr "" "\n" " Мы отправили Вам Ð´Ð»Ñ Ð¿Ñ€Ð¾Ð²ÐµÑ€ÐºÐ¸ Ñл.пиÑьмо.\n" " ПожалуйÑта, перейдите по указанной ÑÑылке. ЕÑли Ð’Ñ‹ не получите\n" -" пиÑьмо в ближайшие неÑколько минут, обратитеÑÑŒ, пожалуйÑта, к нам.\n" +" пиÑьмо в ближайшие неÑколько минут, обратитеÑÑŒ, пожалуйÑта, к " +"нам.\n" " " #: aleksis/core/templates/core/additional_field/edit.html:6 @@ -1545,7 +1630,7 @@ msgstr "ДейÑтвительно Ñ" #: aleksis/core/templates/core/announcement/list.html:20 msgid "Valid until" -msgstr "ДейÑтвительно по" +msgstr "ДейÑтвует до" #: aleksis/core/templates/core/announcement/list.html:21 msgid "Recipients" @@ -1608,19 +1693,23 @@ msgstr "Создать %(name)s" msgid "Edit default dashboard" msgstr "Редактировать Ñтандартную информпанель" +#: aleksis/core/templates/core/data_check/list.html:9 #: aleksis/core/templates/core/data_check/list.html:10 #: aleksis/core/templates/core/data_check/list.html:11 msgid "Data checks" msgstr "Проверки данных" +#: aleksis/core/templates/core/data_check/list.html:15 #: aleksis/core/templates/core/data_check/list.html:16 msgid "Check data again" msgstr "Проверить данные ещё раз" +#: aleksis/core/templates/core/data_check/list.html:22 #: aleksis/core/templates/core/data_check/list.html:23 msgid "The system detected some problems with your data." msgstr "СиÑтема обнаружила некоторые проблемы Ñ Ð’Ð°ÑˆÐ¸Ð¼Ð¸ данными." +#: aleksis/core/templates/core/data_check/list.html:23 #: aleksis/core/templates/core/data_check/list.html:24 msgid "" "Please go through all data and check whether some extra action is\n" @@ -1629,42 +1718,52 @@ msgstr "" "ПожалуйÑта, переÑмотрите внимательно вÑе данные и проверьте не нужно ли\n" " что-то Ñделать." +#: aleksis/core/templates/core/data_check/list.html:31 #: aleksis/core/templates/core/data_check/list.html:32 msgid "Everything is fine." msgstr "Ð’ÑÑ‘ прекраÑно." +#: aleksis/core/templates/core/data_check/list.html:32 #: aleksis/core/templates/core/data_check/list.html:33 msgid "The system hasn't detected any problems with your data." msgstr "СиÑтема не обнаружила никаких проблем Ñ Ð’Ð°ÑˆÐ¸Ð¼Ð¸ данными." +#: aleksis/core/templates/core/data_check/list.html:40 #: aleksis/core/templates/core/data_check/list.html:41 msgid "Detected problems" msgstr "Обнаруженные проблемы" +#: aleksis/core/templates/core/data_check/list.html:45 #: aleksis/core/templates/core/data_check/list.html:46 msgid "Affected object" msgstr "ЗавиÑимые объекты" +#: aleksis/core/templates/core/data_check/list.html:46 #: aleksis/core/templates/core/data_check/list.html:47 msgid "Detected problem" msgstr "Обнаружена проблема" +#: aleksis/core/templates/core/data_check/list.html:47 #: aleksis/core/templates/core/data_check/list.html:48 msgid "Show details" msgstr "Подробнее" +#: aleksis/core/templates/core/data_check/list.html:48 #: aleksis/core/templates/core/data_check/list.html:49 msgid "Options to solve the problem" msgstr "Варианты Ñ€ÐµÑˆÐµÐ½Ð¸Ñ Ð¿Ñ€Ð¾Ð±Ð»ÐµÐ¼Ñ‹" +#: aleksis/core/templates/core/data_check/list.html:63 #: aleksis/core/templates/core/data_check/list.html:65 msgid "Show object" msgstr "ПоÑмотреть объект" +#: aleksis/core/templates/core/data_check/list.html:86 #: aleksis/core/templates/core/data_check/list.html:89 msgid "Registered checks" msgstr "ЗарегиÑтрированные проверки" +#: aleksis/core/templates/core/data_check/list.html:90 #: aleksis/core/templates/core/data_check/list.html:93 msgid "" "\n" @@ -1684,29 +1783,39 @@ msgstr "Редактировать информпанель" #: aleksis/core/templates/core/edit_dashboard.html:24 msgid "" "\n" -" On this page you can arrange your personal dashboard. You can drag any items from \"Available widgets\" to \"Your\n" -" Dashboard\" or change the order by moving the widgets. After you have finished, please don't forget to click on\n" +" On this page you can arrange your personal dashboard. You can drag " +"any items from \"Available widgets\" to \"Your\n" +" Dashboard\" or change the order by moving the widgets. After you " +"have finished, please don't forget to click on\n" " \"Save\".\n" " " msgstr "" "\n" -" Ðа Ñтой Ñтранице Ð’Ñ‹ можете упорÑдочить Ñвою информпанель. ПеретаÑкивайте любые Ñлементы из \"ДоÑтупных виджетов\"\n" -" в \"Свою информпанель\" или менÑйте порÑдок, перетÑÐ³Ð¸Ð²Ð°Ñ Ð²Ð¸Ð´Ð¶ÐµÑ‚Ñ‹. ПоÑле Ð·Ð°Ð²ÐµÑ€ÑˆÐµÐ½Ð¸Ñ Ð½Ðµ забудьте нажать\n" +" Ðа Ñтой Ñтранице Ð’Ñ‹ можете упорÑдочить Ñвою информпанель. " +"ПеретаÑкивайте любые Ñлементы из \"ДоÑтупных виджетов\"\n" +" в \"Свою информпанель\" или менÑйте порÑдок, перетÑÐ³Ð¸Ð²Ð°Ñ Ð²Ð¸Ð´Ð¶ÐµÑ‚Ñ‹. " +"ПоÑле Ð·Ð°Ð²ÐµÑ€ÑˆÐµÐ½Ð¸Ñ Ð½Ðµ забудьте нажать\n" " \"Сохранить\".\n" " " #: aleksis/core/templates/core/edit_dashboard.html:30 msgid "" "\n" -" On this page you can arrange the default dashboard which is shown when a user doesn't arrange his own\n" -" dashboard. You can drag any items from \"Available widgets\" to \"Default Dashboard\" or change the order\n" -" by moving the widgets. After you have finished, please don't forget to click on \"Save\".\n" +" On this page you can arrange the default dashboard which is shown " +"when a user doesn't arrange his own\n" +" dashboard. You can drag any items from \"Available widgets\" to " +"\"Default Dashboard\" or change the order\n" +" by moving the widgets. After you have finished, please don't " +"forget to click on \"Save\".\n" " " msgstr "" "\n" -" Ðа Ñтой Ñтранице Ð’Ñ‹ можете упорÑдочить типовую/Ñтандартную информпанель, ÐºÐ¾Ñ‚Ð¾Ñ€Ð°Ñ Ð¾Ñ‚Ð¾Ð±Ñ€Ð°Ð¶Ð°ÐµÑ‚ÑÑ, еÑли пользователь\n" -" не наÑтроил Ñвою. ПеретÑгивайте любые Ñлементы из \"ДоÑтупных виджетов\" в \"Типовую информпанель\" или менÑйте \n" -" порÑдок, перетÑÐ³Ð¸Ð²Ð°Ñ Ð²Ð¸Ð´Ð¶ÐµÑ‚Ñ‹. ПоÑле Ð·Ð°Ð²Ñ€ÐµÑˆÐµÐ½Ð¸Ñ Ð½Ðµ забудьте нажать \"Сохранить\".\n" +" Ðа Ñтой Ñтранице Ð’Ñ‹ можете упорÑдочить типовую/Ñтандартную " +"информпанель, ÐºÐ¾Ñ‚Ð¾Ñ€Ð°Ñ Ð¾Ñ‚Ð¾Ð±Ñ€Ð°Ð¶Ð°ÐµÑ‚ÑÑ, еÑли пользователь\n" +" не наÑтроил Ñвою. ПеретÑгивайте любые Ñлементы из \"ДоÑтупных " +"виджетов\" в \"Типовую информпанель\" или менÑйте \n" +" порÑдок, перетÑÐ³Ð¸Ð²Ð°Ñ Ð²Ð¸Ð´Ð¶ÐµÑ‚Ñ‹. ПоÑле Ð·Ð°Ð²Ñ€ÐµÑˆÐµÐ½Ð¸Ñ Ð½Ðµ забудьте нажать " +"\"Сохранить\".\n" " " #: aleksis/core/templates/core/edit_dashboard.html:48 @@ -1729,13 +1838,16 @@ msgstr "Определить дочерние группы к группе" #: aleksis/core/templates/core/group/child_groups.html:18 msgid "" "\n" -" You can use this to assign child groups to groups. Please use the filters below to select groups you want to\n" +" You can use this to assign child groups to groups. Please use the " +"filters below to select groups you want to\n" " change and click \"Next\".\n" " " msgstr "" "\n" -" Ð’Ñ‹ можете воÑпользоватьÑÑ Ñтим Ð´Ð»Ñ Ð¿Ñ€Ð¸Ð²Ñзки дочерних групп к оÑновным. Ð”Ð»Ñ Ð²Ñ‹Ð±Ð¾Ñ€Ð° групп,\n" -" которые хотите изменить, иÑпользуйте фильтры, раÑположенные ниже и нажмите \"Далее\".\n" +" Ð’Ñ‹ можете воÑпользоватьÑÑ Ñтим Ð´Ð»Ñ Ð¿Ñ€Ð¸Ð²Ñзки дочерних групп к " +"оÑновным. Ð”Ð»Ñ Ð²Ñ‹Ð±Ð¾Ñ€Ð° групп,\n" +" которые хотите изменить, иÑпользуйте фильтры, раÑположенные ниже и " +"нажмите \"Далее\".\n" " " #: aleksis/core/templates/core/group/child_groups.html:31 @@ -1761,7 +1873,8 @@ msgid "" " " msgstr "" "\n" -" Выберите, пожалуйÑта, неÑколько групп в порÑдке, по какому привÑзывать.\n" +" Выберите, пожалуйÑта, неÑколько групп в порÑдке, по какому " +"привÑзывать.\n" " " #: aleksis/core/templates/core/group/child_groups.html:72 @@ -1775,14 +1888,18 @@ msgstr "ПожалуйÑта, будьте аккуратны!" #: aleksis/core/templates/core/group/child_groups.html:79 msgid "" "\n" -" If you click \"Back\" or \"Next\" the current group assignments are not saved.\n" -" If you click \"Save\", you will overwrite all existing child group relations for this group with what you\n" +" If you click \"Back\" or \"Next\" the current group assignments " +"are not saved.\n" +" If you click \"Save\", you will overwrite all existing child " +"group relations for this group with what you\n" " selected on this page.\n" " " msgstr "" "\n" -" ЕÑли нажмёте \"Ðазад\" или \"Далее\" привÑзки Ñтой группы не ÑохранÑÑ‚ÑÑ.\n" -" ЕÑли нажмёте \"Сохранить\", вÑе ÑущеÑтвующие ÑвÑзи дочерней группы Ñ Ñтой группой будут заменены на\n" +" ЕÑли нажмёте \"Ðазад\" или \"Далее\" привÑзки Ñтой группы не " +"ÑохранÑÑ‚ÑÑ.\n" +" ЕÑли нажмёте \"Сохранить\", вÑе ÑущеÑтвующие ÑвÑзи дочерней " +"группы Ñ Ñтой группой будут заменены на\n" " выбранные на Ñтой Ñтранице.\n" " " @@ -1880,12 +1997,14 @@ msgstr "Домой" #: aleksis/core/templates/core/index.html:34 msgid "" "\n" -" You didn't customise your dashboard so that you see the system default. Please click on \"Edit dashboard\" to\n" +" You didn't customise your dashboard so that you see the system " +"default. Please click on \"Edit dashboard\" to\n" " customise your personal dashboard.\n" " " msgstr "" "\n" -" Ð’Ñ‹ ещё не наÑтроили Ñвою информпанель, так что пока наблюдаете типовую по-умолчанию. Ð”Ð»Ñ Ð½Ð°Ñтройки \n" +" Ð’Ñ‹ ещё не наÑтроили Ñвою информпанель, так что пока наблюдаете " +"типовую по-умолчанию. Ð”Ð»Ñ Ð½Ð°Ñтройки \n" " Ñвоей информпанели клацните \"Редактировать информпанель\".\n" " " @@ -1922,96 +2041,114 @@ msgstr "СоÑтоÑние ÑиÑтемы" msgid "System checks" msgstr "СиÑтемные проверки" +#: aleksis/core/templates/core/pages/system_status.html:22 #: aleksis/core/templates/core/pages/system_status.html:26 msgid "Maintenance mode enabled" msgstr "Включен режим обÑлуживаниÑ" -#: aleksis/core/templates/core/pages/system_status.html:28 -#, fuzzy -#| msgid "" -#| "\n" -#| " Only admin and visitors from internal IPs can access the site.\n" -#| " " +#: aleksis/core/templates/core/pages/system_status.html:24 msgid "" "\n" -" Only admin and visitors from internal IPs can access the site.\n" -" " +" Only admin and visitors from internal IPs can access the " +"site.\n" +" " msgstr "" "\n" -" ДоÑтуп к Ñайту имеют только админиÑтратор и пользователи Ñ Ð²Ð½ÑƒÑ‚Ñ€ÐµÐ½Ð½Ð¸Ð¼Ð¸ IP-адреÑами.\n" +" ДоÑтуп к Ñайту имеют только админиÑтратор и пользователи Ñ " +"внутренними IP-адреÑами.\n" " " +#: aleksis/core/templates/core/pages/system_status.html:36 #: aleksis/core/templates/core/pages/system_status.html:39 msgid "Maintenance mode disabled" msgstr "Режим обÑÐ»ÑƒÐ¶Ð¸Ð²Ð°Ð½Ð¸Ñ Ð²Ñ‹ÐºÐ»ÑŽÑ‡ÐµÐ½" +#: aleksis/core/templates/core/pages/system_status.html:37 #: aleksis/core/templates/core/pages/system_status.html:40 msgid "Everyone can access the site." msgstr "ДоÑтуп к Ñайту еÑть у вÑех." +#: aleksis/core/templates/core/pages/system_status.html:47 #: aleksis/core/templates/core/pages/system_status.html:51 msgid "Debug mode enabled" msgstr "Режим отладки включен" +#: aleksis/core/templates/core/pages/system_status.html:49 #: aleksis/core/templates/core/pages/system_status.html:53 msgid "" "\n" -" The web server throws back debug information on errors. Do not use in production!\n" +" The web server throws back debug information on errors. Do " +"not use in production!\n" " " msgstr "" "\n" -" Веб-Ñервер во Ð²Ñ€ÐµÐ¼Ñ Ð¾ÑˆÐ¸Ð±Ð¾Ðº пишет отладочную информацию. Ðе иÑпользуйте в продакшене!\n" +" Веб-Ñервер во Ð²Ñ€ÐµÐ¼Ñ Ð¾ÑˆÐ¸Ð±Ð¾Ðº пишет отладочную информацию. Ðе " +"иÑпользуйте в продакшене!\n" " " +#: aleksis/core/templates/core/pages/system_status.html:56 #: aleksis/core/templates/core/pages/system_status.html:60 msgid "Debug mode disabled" msgstr "Режим отладки отключен" +#: aleksis/core/templates/core/pages/system_status.html:58 #: aleksis/core/templates/core/pages/system_status.html:62 msgid "" "\n" -" Debug mode is disabled. Default error pages are displayed on errors.\n" +" Debug mode is disabled. Default error pages are displayed on " +"errors.\n" " " msgstr "" "\n" -" Режим отладки отключен. Ð’ Ñлучае ошибок будут отображатьÑÑ Ñтандартные Ñтраницы об ошибках.\n" +" Режим отладки отключен. Ð’ Ñлучае ошибок будут отображатьÑÑ " +"Ñтандартные Ñтраницы об ошибках.\n" " " +#: aleksis/core/templates/core/pages/system_status.html:71 #: aleksis/core/templates/core/pages/system_status.html:75 msgid "System health checks" msgstr "Проверки работы ÑиÑтемы" +#: aleksis/core/templates/core/pages/system_status.html:77 #: aleksis/core/templates/core/pages/system_status.html:81 msgid "Service" msgstr "Служба" +#: aleksis/core/templates/core/pages/system_status.html:78 +#: aleksis/core/templates/core/pages/system_status.html:119 #: aleksis/core/templates/core/pages/system_status.html:82 #: aleksis/core/templates/core/pages/system_status.html:123 msgid "Status" msgstr "СоÑтоÑние" +#: aleksis/core/templates/core/pages/system_status.html:79 #: aleksis/core/templates/core/pages/system_status.html:83 msgid "Time taken" msgstr "ПродолжительноÑть" +#: aleksis/core/templates/core/pages/system_status.html:100 #: aleksis/core/templates/core/pages/system_status.html:104 msgid "seconds" msgstr "Ñек" +#: aleksis/core/templates/core/pages/system_status.html:111 #: aleksis/core/templates/core/pages/system_status.html:115 msgid "Celery task results" msgstr "Результаты Ð²Ñ‹Ð¿Ð¾Ð»Ð½ÐµÐ½Ð¸Ñ Celery" -#: aleksis/core/templates/core/pages/system_status.html:120 +#: aleksis/core/templates/core/pages/system_status.html:116 #: aleksis/core/templates/templated_email/celery_failure.email:9 #: aleksis/core/templates/templated_email/celery_failure.email:28 +#: aleksis/core/templates/core/pages/system_status.html:120 msgid "Task" msgstr "ЗаданиÑ" +#: aleksis/core/templates/core/pages/system_status.html:117 #: aleksis/core/templates/core/pages/system_status.html:121 msgid "ID" msgstr "ID" +#: aleksis/core/templates/core/pages/system_status.html:118 #: aleksis/core/templates/core/pages/system_status.html:122 msgid "Date done" msgstr "Ð’Ñ€ÐµÐ¼Ñ Ð·Ð°Ð²ÐµÑ€ÑˆÐµÐ½Ð¸Ñ" @@ -2024,11 +2161,13 @@ msgstr "Пробное генерирование PDF" #: aleksis/core/templates/core/pages/test_pdf.html:14 msgid "" "\n" -" This simple view can be used to ensure the correct function of the built-in PDF generation system.\n" +" This simple view can be used to ensure the correct function of the " +"built-in PDF generation system.\n" " " msgstr "" "\n" -" Ðта проÑÑ‚Ð°Ñ Ñтраница может помочь проверить корректноÑть наÑтроек вÑтроенной ÑиÑтемы Ð³ÐµÐ½ÐµÑ€Ð¸Ñ€Ð¾Ð²Ð°Ð½Ð¸Ñ PDF.\n" +" Ðта проÑÑ‚Ð°Ñ Ñтраница может помочь проверить корректноÑть наÑтроек " +"вÑтроенной ÑиÑтемы Ð³ÐµÐ½ÐµÑ€Ð¸Ñ€Ð¾Ð²Ð°Ð½Ð¸Ñ PDF.\n" " " #: aleksis/core/templates/core/partials/announcements.html:8 @@ -2086,25 +2225,25 @@ msgstr "ÐеизвеÑтно" #: aleksis/core/templates/core/partials/splash_screen.html:11 msgid "" "\n" -" This webbrowser doesn't support JavaScript, or its execution is blocked. Please use another browser to continue.\n" +" This webbrowser doesn't support JavaScript, or its execution is " +"blocked. Please use another browser to continue.\n" " " msgstr "" "\n" -" Ðтот веб-браузер не поддерживает JavaScript или его обработка заблокирована. Ð”Ð»Ñ Ð¿Ñ€Ð¾Ð´Ð¾Ð»Ð¶ÐµÐ½Ð¸Ñ Ð²Ð¾ÑпользуйтеÑÑŒ, пожалуйÑта, другим браузером.\n" +" Ðтот веб-браузер не поддерживает JavaScript или его обработка " +"заблокирована. Ð”Ð»Ñ Ð¿Ñ€Ð¾Ð´Ð¾Ð»Ð¶ÐµÐ½Ð¸Ñ Ð²Ð¾ÑпользуйтеÑÑŒ, пожалуйÑта, другим " +"браузером.\n" " " #: aleksis/core/templates/core/partials/splash_screen.html:17 -#, fuzzy -#| msgid "" -#| "The maintenance mode is currently enabled. Please try again\n" -#| " later." msgid "" "\n" " The maintenance mode is currently enabled. Please try again later.\n" " " msgstr "" -"Сайт находитÑÑ Ð½Ð° обÑлуживании. Попробуйте зайти\n" -" позже." +"\n" +" Сайт находитÑÑ Ð½Ð° обÑлуживании. Попробуйте зайти позже.\n" +" " #: aleksis/core/templates/core/perms/assign.html:12 #: aleksis/core/templates/core/perms/assign.html:13 @@ -2216,8 +2355,11 @@ msgid "The invite feature is disabled." msgstr "Ð¤ÑƒÐºÑ†Ð¸Ñ Ð¿Ñ€Ð¸Ð³Ð»Ð°ÑˆÐµÐ½Ð¸Ñ Ð²Ñ‹ÐºÐ»ÑŽÑ‡ÐµÐ½Ð°." #: aleksis/core/templates/invitations/disabled.html:15 -msgid "To enable it, switch on the corresponding checkbox in the authentication section of the " -msgstr "Ð”Ð»Ñ Ð°ÐºÑ‚Ð¸Ð²Ð°Ñ†Ð¸Ð¸ включите ÑоответÑтвующий Ñ‡ÐµÐºÐ±Ð¾ÐºÑ Ð² разделе авторизации на " +msgid "" +"To enable it, switch on the corresponding checkbox in the authentication " +"section of the " +msgstr "" +"Ð”Ð»Ñ Ð°ÐºÑ‚Ð¸Ð²Ð°Ñ†Ð¸Ð¸ включите ÑоответÑтвующий Ñ‡ÐµÐºÐ±Ð¾ÐºÑ Ð² разделе авторизации на " #: aleksis/core/templates/invitations/disabled.html:16 msgid "site preferences page" @@ -2261,10 +2403,12 @@ msgstr "Приглашение по Ñл.почте" msgid "Generate invitation code" msgstr "Создать код приглашениÑ" +#: aleksis/core/templates/invitations/forms/_invite.html:29 #: aleksis/core/templates/invitations/forms/_invite.html:30 msgid "Generate code" msgstr "Генерирование кода" +#: aleksis/core/templates/invitations/forms/_invite.html:33 #: aleksis/core/templates/invitations/forms/_invite.html:34 msgid "Invitations" msgstr "ПриглашениÑ" @@ -2281,6 +2425,7 @@ msgstr "РегиÑÑ‚Ñ€Ð°Ñ†Ð¸Ñ Ð¿Ñ€Ð¸Ð»Ð¾Ð¶ÐµÐ½Ð¸Ñ OAuth2" #: aleksis/core/templates/oauth2_provider/application/create.html:14 #: aleksis/core/templates/oauth2_provider/application/edit.html:14 +#: aleksis/core/templates/oauth2_provider/authorized-token-delete.html:24 #: aleksis/core/templates/two_factor/_wizard_actions.html:6 msgid "Cancel" msgstr "Отменить" @@ -2354,31 +2499,52 @@ msgstr "Разрешить" msgid "Disallow" msgstr "Запретить" +#: aleksis/core/templates/oauth2_provider/authorized-token-delete.html:5 +#: aleksis/core/templates/oauth2_provider/authorized-token-delete.html:6 +#: aleksis/core/templates/oauth2_provider/authorized-tokens.html:23 +msgid "Revoke access" +msgstr "Отозвать доÑтуп" + +#: aleksis/core/templates/oauth2_provider/authorized-token-delete.html:12 +msgid "Are you sure to revoke the access for this application?" +msgstr "Ви дейÑтвительно хотите отозвать доÑтуп Ð´Ð»Ñ Ñтого приложениÑ?" + +#: aleksis/core/templates/oauth2_provider/authorized-token-delete.html:20 +msgid "Revoke" +msgstr "Отозвать" + +#: aleksis/core/templates/oauth2_provider/authorized-tokens.html:5 +#: aleksis/core/templates/oauth2_provider/authorized-tokens.html:6 +msgid "Authorized applications" +msgstr "Ðвторизованные приложениÑ" + +#: aleksis/core/templates/oauth2_provider/authorized-tokens.html:33 +msgid "No authorized applications." +msgstr "Ðвторизованных приложений нет." + #: aleksis/core/templates/offline.html:5 msgid "Network error" msgstr "Ошибка Ñети" #: aleksis/core/templates/offline.html:10 -msgid "No connection to server." -msgstr "" +msgid "Page not available offline." +msgstr "Ð’ автономном режиме Ñтраница не доÑтупна." #: aleksis/core/templates/offline.html:14 -#, fuzzy -#| msgid "" -#| "\n" -#| " This page is not available offline. Since you probably don't have an internet connection, check to see if your WiFi\n" -#| " or mobile data is turned on and try again. If you think you are connected, please contact the system\n" -#| " administrators:\n" -#| " " msgid "" "\n" -" This page is not available without a connection to the server. Please check your internet connection and try again.\n" -" If you are connected and the error persists, please contact the system administrators:\n" +" This page is not available offline. Since you probably don't have an " +"internet connection, check to see if your WiFi\n" +" or mobile data is turned on and try again. If you think you are " +"connected, please contact the system\n" +" administrators:\n" " " msgstr "" "\n" -" Ðта Ñтраница в автономном режимен не доÑтупна. Возможно, у Ð’Ð°Ñ Ð¿Ñ€Ð¾Ð±Ð»ÐµÐ¼Ð° Ñ Ð¸Ð½Ñ‚ÐµÑ€Ð½ÐµÑ‚Ð¾Ð¼. УбедитеÑÑŒ, что Ваш WiFi\n" -" или мобильный интернет включены и попробуйте ещё раз. ЕÑли Ñчитаете, что Ñ Ð¿Ð¾Ð´ÐºÐ»ÑŽÑ‡ÐµÐ½Ð¸ÐµÐ¼ вÑÑ‘ хорошо, обратитеÑÑŒ,\n" +" Ðта Ñтраница в автономном режимен не доÑтупна. Возможно, у Ð’Ð°Ñ " +"проблема Ñ Ð¸Ð½Ñ‚ÐµÑ€Ð½ÐµÑ‚Ð¾Ð¼. УбедитеÑÑŒ, что Ваш WiFi\n" +" или мобильный интернет включены и попробуйте ещё раз. ЕÑли Ñчитаете, " +"что Ñ Ð¿Ð¾Ð´ÐºÐ»ÑŽÑ‡ÐµÐ½Ð¸ÐµÐ¼ вÑÑ‘ хорошо, обратитеÑÑŒ,\n" " пожалуйÑта, к ÑиÑтемным админиÑтраторам:\n" " " @@ -2414,12 +2580,14 @@ msgstr "Ошибка входа учётной запиÑи третьей ÑÑ‚ #: aleksis/core/templates/socialaccount/authentication_error.html:15 msgid "" "\n" -" An error occurred while attempting to login via your third-party account.\n" +" An error occurred while attempting to login via your third-party " +"account.\n" " Please contact one of your site administrators.\n" " " msgstr "" "\n" -" Во Ð²Ñ€ÐµÐ¼Ñ Ð¿Ð¾Ð¿Ñ‹Ñ‚ÐºÐ¸ входа Ñ Ð’Ð°ÑˆÐµÐ¹ Ñторонней учётной запиÑью возникла ошибка входа.\n" +" Во Ð²Ñ€ÐµÐ¼Ñ Ð¿Ð¾Ð¿Ñ‹Ñ‚ÐºÐ¸ входа Ñ Ð’Ð°ÑˆÐµÐ¹ Ñторонней учётной запиÑью " +"возникла ошибка входа.\n" " ОбратитеÑÑŒ, пожалуйÑта, к админиÑтратору Ñайта.\n" " " @@ -2434,7 +2602,9 @@ msgstr "Удалить" #: aleksis/core/templates/socialaccount/connections.html:34 msgid "You currently have no third-party accounts connected to this account." -msgstr "Ð¡ÐµÐ¹Ñ‡Ð°Ñ Ñƒ Ð’Ð°Ñ Ð½ÐµÑ‚ учётных запиÑей третих Ñторон, Ñоединённых Ñ Ñтой учётной запиÑью." +msgstr "" +"Ð¡ÐµÐ¹Ñ‡Ð°Ñ Ñƒ Ð’Ð°Ñ Ð½ÐµÑ‚ учётных запиÑей третих Ñторон, Ñоединённых Ñ Ñтой учётной " +"запиÑью." #: aleksis/core/templates/socialaccount/connections.html:37 msgid "Add a Third-party Account" @@ -2464,11 +2634,15 @@ msgstr "Вход отменён" #, python-format msgid "" "\n" -" You decided to cancel logging in to our site using one of your existing accounts. If this was a mistake, please proceed to <a href=\"%(login_url)s\">sign in</a>.\n" +" You decided to cancel logging in to our site using one of your " +"existing accounts. If this was a mistake, please proceed to <a href=" +"\"%(login_url)s\">sign in</a>.\n" " " msgstr "" "\n" -" Похоже, Ð’Ñ‹ отменили вход на наш Ñайт Ñ Ð¾Ð´Ð½Ð¾Ð¹ из Ваших учётных запиÑей. ЕÑли Ñто произошло Ñлучайно, Ð’Ñ‹ можете <a href=\"%(login_url)s\">продолжить вход здеÑÑŒ</a>.\n" +" Похоже, Ð’Ñ‹ отменили вход на наш Ñайт Ñ Ð¾Ð´Ð½Ð¾Ð¹ из Ваших учётных " +"запиÑей. ЕÑли Ñто произошло Ñлучайно, Ð’Ñ‹ можете <a href=\"%(login_url)s" +"\">продолжить вход здеÑÑŒ</a>.\n" " " #: aleksis/core/templates/socialaccount/signup.html:12 @@ -2478,7 +2652,8 @@ msgid "" " %(site_name)s. As a final step, please complete the following form:" msgstr "" "Ð’Ñ‹ на пути к иÑпользованию Ñвоей учётной запиÑи %(provider_name)s\n" -" Ð´Ð»Ñ Ð²Ñ…Ð¾Ð´Ð° в %(site_name)s. Заполните, пожалуйÑта, Ð´Ð»Ñ Ð·Ð°Ð²ÐµÑ€ÑˆÐµÐ½Ð¸Ñ Ñту форму:" +" Ð´Ð»Ñ Ð²Ñ…Ð¾Ð´Ð° в %(site_name)s. Заполните, пожалуйÑта, Ð´Ð»Ñ Ð·Ð°Ð²ÐµÑ€ÑˆÐµÐ½Ð¸Ñ Ñту " +"форму:" #: aleksis/core/templates/socialaccount/snippets/provider_list.html:12 #, python-format @@ -2567,21 +2742,25 @@ msgstr "СиÑтема обнаружила новые проблемы Ñ Ð’Ð° #: aleksis/core/templates/templated_email/data_checks.email:6 msgid "" "the system detected some new problems with your data.\n" -"Please take some time to inspect them and solve the issues or mark them as ignored." +"Please take some time to inspect them and solve the issues or mark them as " +"ignored." msgstr "" "ÑиÑтема обнаружила новые проблемы Ñ Ð’Ð°ÑˆÐ¸Ð¼Ð¸ данными.\n" -"Уделите, пожалуйÑта, немного времени Ð´Ð»Ñ Ð¸Ñ… проверки и Ñ€ÐµÑˆÐµÐ½Ð¸Ñ Ð¿Ñ€Ð¾Ð±Ð»ÐµÐ¼, или же отметьте их Ð´Ð»Ñ Ð¸Ð³Ð½Ð¾Ñ€Ð¸Ñ€Ð¾Ð²Ð°Ð½Ð¸Ñ." +"Уделите, пожалуйÑта, немного времени Ð´Ð»Ñ Ð¸Ñ… проверки и Ñ€ÐµÑˆÐµÐ½Ð¸Ñ Ð¿Ñ€Ð¾Ð±Ð»ÐµÐ¼, или " +"же отметьте их Ð´Ð»Ñ Ð¸Ð³Ð½Ð¾Ñ€Ð¸Ñ€Ð¾Ð²Ð°Ð½Ð¸Ñ." #: aleksis/core/templates/templated_email/data_checks.email:15 msgid "" "\n" " the system detected some new problems with your data.\n" -" Please take some time to inspect them and solve the issues or mark them as ignored.\n" +" Please take some time to inspect them and solve the issues or mark them " +"as ignored.\n" " " msgstr "" "\n" " ÑиÑтема обнаружила новые проблемы Ñ Ð’Ð°ÑˆÐ¸Ð¼Ð¸ данными.\n" -" Уделите, пожалуйÑта, немного времени Ð´Ð»Ñ Ð¸Ñ… проверки и Ñ€ÐµÑˆÐµÐ½Ð¸Ñ Ð¿Ñ€Ð¾Ð±Ð»ÐµÐ¼, или же отметьте их Ð´Ð»Ñ Ð¸Ð³Ð½Ð¾Ñ€Ð¸Ñ€Ð¾Ð²Ð°Ð½Ð¸Ñ.\n" +" Уделите, пожалуйÑта, немного времени Ð´Ð»Ñ Ð¸Ñ… проверки и Ñ€ÐµÑˆÐµÐ½Ð¸Ñ Ð¿Ñ€Ð¾Ð±Ð»ÐµÐ¼, " +"или же отметьте их Ð´Ð»Ñ Ð¸Ð³Ð½Ð¾Ñ€Ð¸Ñ€Ð¾Ð²Ð°Ð½Ð¸Ñ.\n" " " #: aleksis/core/templates/templated_email/data_checks.email:23 @@ -2592,24 +2771,6 @@ msgstr "ОпиÑание проблемы" msgid "Count of objects with new problems" msgstr "КоличеÑтво объектов Ñ Ð½Ð¾Ð²Ñ‹Ð¼Ð¸ проблемами" -#: aleksis/core/templates/templated_email/invitation.email:4 -#, python-format -msgid "Invitation to register on %(site)s" -msgstr "" - -#: aleksis/core/templates/templated_email/invitation.email:6 -#: aleksis/core/templates/templated_email/invitation.email:14 -#, fuzzy, python-format -#| msgid "Selected persons" -msgid "Hello %(person)s" -msgstr "Выбранные физлица" - -#: aleksis/core/templates/templated_email/invitation.email:9 -#: aleksis/core/templates/templated_email/invitation.email:18 -#, python-format -msgid "you have been invited to register on %(site)s. If you would like to accept this invitation, please click on the following link:" -msgstr "" - #: aleksis/core/templates/templated_email/notification.email:4 msgid "New notification for" msgstr "Ðовое уведомление длÑ" @@ -2682,16 +2843,22 @@ msgid "" "\n" " Backup tokens can be used when your primary and backup\n" " phone numbers aren't available. The backup tokens below can be used\n" -" for login verification. If you've used up all your backup tokens, you\n" -" can generate a new set of backup tokens. Only the backup tokens shown\n" +" for login verification. If you've used up all your backup tokens, " +"you\n" +" can generate a new set of backup tokens. Only the backup tokens " +"shown\n" " below will be valid.\n" " " msgstr "" "\n" -" Резервные токены могут быть иÑпользованы, когда Ваши оÑновной и резервный\n" -" телефонные номера недоÑтупны. Резервные токены, указанные ниже, могут быть\n" -" иÑпользованы Ð´Ð»Ñ Ð²ÐµÑ€Ð¸Ñ„Ð¸ÐºÐ°Ñ†Ð¸Ð¸ входа. Когда Ð’Ñ‹ иÑпользуете вÑе резервные токены,\n" -" Ð’Ñ‹ Ñможете Ñгенерировать новый набор резервных токенов. ДейÑтвительными будут только\n" +" Резервные токены могут быть иÑпользованы, когда Ваши оÑновной и " +"резервный\n" +" телефонные номера недоÑтупны. Резервные токены, указанные ниже, " +"могут быть\n" +" иÑпользованы Ð´Ð»Ñ Ð²ÐµÑ€Ð¸Ñ„Ð¸ÐºÐ°Ñ†Ð¸Ð¸ входа. Когда Ð’Ñ‹ иÑпользуете вÑе " +"резервные токены,\n" +" Ð’Ñ‹ Ñможете Ñгенерировать новый набор резервных токенов. " +"ДейÑтвительными будут только\n" " токены, указанные ниже.\n" " " @@ -2742,7 +2909,9 @@ msgstr "" #: aleksis/core/templates/two_factor/core/login.html:51 msgid "Please login with your account to use the external application." -msgstr "Ð”Ð»Ñ Ð¸ÑÐ¿Ð¾Ð»ÑŒÐ·Ð¾Ð²Ð°Ð½Ð¸Ñ Ð²Ð½ÐµÑˆÐ½ÐµÐ³Ð¾ Ð¿Ñ€Ð¸Ð»Ð¾Ð¶ÐµÐ½Ð¸Ñ Ð²Ð¾Ð¹Ð´Ð¸Ñ‚Ðµ, пожалуйÑта, в Ñвою учётную запиÑÑŒ." +msgstr "" +"Ð”Ð»Ñ Ð¸ÑÐ¿Ð¾Ð»ÑŒÐ·Ð¾Ð²Ð°Ð½Ð¸Ñ Ð²Ð½ÐµÑˆÐ½ÐµÐ³Ð¾ Ð¿Ñ€Ð¸Ð»Ð¾Ð¶ÐµÐ½Ð¸Ñ Ð²Ð¾Ð¹Ð´Ð¸Ñ‚Ðµ, пожалуйÑта, в Ñвою учётную " +"запиÑÑŒ." #: aleksis/core/templates/two_factor/core/login.html:58 msgid "Please login to see this page." @@ -2751,93 +2920,83 @@ msgstr "Ð”Ð»Ñ Ð¿Ñ€Ð¾Ñмотра Ñтой Ñтраницы, пожалуйÑÑ‚ #: aleksis/core/templates/two_factor/core/login.html:69 msgid "" "\n" -" We are calling your phone right now, please enter the\n" +" We are calling your phone right now, please enter " +"the\n" " digits you hear.\n" " " msgstr "" "\n" -" Мы ÑÐµÐ¹Ñ‡Ð°Ñ Ð¿Ð¾Ð·Ð²Ð¾Ð½Ð¸Ð¼ на Ваш номер. Ðапишите, пожалуйÑта, цифры,\n" +" Мы ÑÐµÐ¹Ñ‡Ð°Ñ Ð¿Ð¾Ð·Ð²Ð¾Ð½Ð¸Ð¼ на Ваш номер. Ðапишите, " +"пожалуйÑта, цифры,\n" " которые Ð’Ñ‹ уÑлышите.\n" " " #: aleksis/core/templates/two_factor/core/login.html:74 -#, fuzzy -#| msgid "" -#| "\n" -#| " We sent you a text message, please enter the tokens we\n" -#| " sent.\n" -#| " " msgid "" "\n" -" We sent you a text message, please enter the code we sent.\n" +" We sent you a text message, please enter the code we " +"sent.\n" " " msgstr "" "\n" -" Мы отправили Вам текÑтовое Ñообщение. Ðапишите, пожалуйÑта, полученный\n" -" токен.\n" +" Мы отправили Вам текÑтовое Ñообщение. Ðапишите, " +"пожалуйÑта, полученный код.\n" " " #: aleksis/core/templates/two_factor/core/login.html:78 -#, fuzzy -#| msgid "" -#| "\n" -#| " We sent you a text message, please enter the tokens we\n" -#| " sent.\n" -#| " " msgid "" "\n" -" We sent you an email, please enter the code we sent.\n" +" We sent you an email, please enter the code we " +"sent.\n" " " msgstr "" "\n" -" Мы отправили Вам текÑтовое Ñообщение. Ðапишите, пожалуйÑта, полученный\n" -" токен.\n" +" Мы отправили Вам на Ñл.почту код. Введите, " +"пожалуйÑта, его.\n" " " #: aleksis/core/templates/two_factor/core/login.html:82 -#, fuzzy -#| msgid "" -#| "\n" -#| " This app is licenced under %(licence)s.\n" -#| " " msgid "" "\n" -" Please use your Webauthn-compatible device to authenticate.\n" +" Please use your Webauthn-compatible device to " +"authenticate.\n" " " msgstr "" "\n" -" Ðто приложение под лицензией %(licence)s.\n" -" " +" ПожалуйÑта, Ð´Ð»Ñ Ð°ÑƒÑ‚ÐµÐ½Ñ‚Ð¸Ñ„Ð¸ÐºÐ°Ñ†Ð¸Ð¸ пользуйтеÑÑŒ " +"уÑтройÑтвом, ÑовмеÑтимым Ñ Webauthn.\n" +" " #: aleksis/core/templates/two_factor/core/login.html:86 -#, fuzzy -#| msgid "" -#| "\n" -#| " Please enter the tokens generated by your token\n" -#| " generator.\n" -#| " " msgid "" "\n" -" Please enter the code generated by your code generator.\n" +" Please enter the code generated by your code " +"generator.\n" " " msgstr "" "\n" -" Ðапишите, пожалуйÑта, токен, Ñ Ð’Ð°ÑˆÐµÐ³Ð¾\n" -" генератора токенов.\n" +" Введите, пожалуйÑта, токен Ñ Ð’Ð°ÑˆÐµÐ³Ð¾ генератора " +"токенов.\n" " " #: aleksis/core/templates/two_factor/core/login.html:91 msgid "" "\n" -" Use this form for entering backup tokens for logging in.\n" -" These tokens have been generated for you to print and keep safe. Please\n" -" enter one of these backup tokens to login to your account.\n" +" Use this form for entering backup tokens for logging " +"in.\n" +" These tokens have been generated for you to print and " +"keep safe. Please\n" +" enter one of these backup tokens to login to your " +"account.\n" " " msgstr "" "\n" -" Ð”Ð»Ñ Ð²Ñ…Ð¾Ð´Ð° воÑпользуйтеÑÑŒ формой Ð´Ð»Ñ Ð²Ð²Ð¾Ð´Ð° резервных токенов.\n" -" Ðти токены были Ñгенерированы, чтобы Ð’Ñ‹ раÑпечатали их и Ñохранили в надёжном меÑте.\n" -" Ð”Ð»Ñ Ð²Ñ…Ð¾Ð´Ð° укажите, пожалуйÑта, один из резервных токенов.\n" +" Ð”Ð»Ñ Ð²Ñ…Ð¾Ð´Ð° воÑпользуйтеÑÑŒ формой Ð´Ð»Ñ Ð²Ð²Ð¾Ð´Ð° резервных " +"токенов.\n" +" Ðти токены были Ñгенерированы, чтобы Ð’Ñ‹ раÑпечатали их " +"и Ñохранили в надёжном меÑте.\n" +" Ð”Ð»Ñ Ð²Ñ…Ð¾Ð´Ð° укажите, пожалуйÑта, один из резервных " +"токенов.\n" " " #: aleksis/core/templates/two_factor/core/login.html:119 @@ -2845,14 +3004,13 @@ msgid "Device currently not available?" msgstr "Ð¡ÐµÐ¹Ñ‡Ð°Ñ ÑƒÑтройÑтво недоÑтупно?" #: aleksis/core/templates/two_factor/core/login.html:121 -#, fuzzy -#| msgid "Or, alternatively, use one of your backup phones:" msgid "Alternatively, use one of your other authentication methods:" -msgstr "Или можете воÑпользоватьÑÑ Ð¾Ð´Ð½Ð¸Ð¼ из резервных телефонов:" +msgstr "Или можете воÑпользоватьÑÑ Ð°Ð»ÑŒÑ‚ÐµÑ€Ð½Ð°Ñ‚Ð¸Ð²Ð½Ñ‹Ð¼ методом аутентификации:" #: aleksis/core/templates/two_factor/core/login.html:133 msgid "As a last resort, you can use a backup token:" -msgstr "Ð’ качеÑтве поÑледней возможноÑти можете воÑпользоватьÑÑ Ñ€ÐµÐ·ÐµÑ€Ð²Ð½Ñ‹Ð¼ токеном:" +msgstr "" +"Ð’ качеÑтве поÑледней возможноÑти можете воÑпользоватьÑÑ Ñ€ÐµÐ·ÐµÑ€Ð²Ð½Ñ‹Ð¼ токеном:" #: aleksis/core/templates/two_factor/core/login.html:136 msgid "Use Backup Token" @@ -2867,19 +3025,18 @@ msgid "Permission Denied" msgstr "ДоÑтуп отÑутÑтвует" #: aleksis/core/templates/two_factor/core/otp_required.html:10 -#, fuzzy -#| msgid "" -#| "The page you requested, enforces users to verify using\n" -#| " two-factor authentication for security reasons. You need to enable these\n" -#| " security features in order to access this page." msgid "" "The page you requested enforces users to verify using\n" -" two-factor authentication for security reasons. You need to enable this\n" +" two-factor authentication for security reasons. You need to enable " +"this\n" " security feature in order to access this page." msgstr "" -"Ð”Ð»Ñ Ð¿Ñ€Ð¾Ñмотра запрошенной Ñтраницы, Ñ Ð¾Ð³Ð»Ñдкой на безопаÑноÑть, необходима дополнительнаÑ\n" -" проверка Ð¿Ð¾Ð»ÑŒÐ·Ð¾Ð²Ð°Ñ‚ÐµÐ»Ñ Ñ Ð¸Ñпользованием двухфакторной аутентификации.\n" -" Ð”Ð»Ñ Ð´Ð¾Ñтупа к данной Ñтранице Ð’Ñ‹ должны включить Ñти функции безопаÑноÑти." +"Ð”Ð»Ñ Ð¿Ñ€Ð¾Ñмотра запрошенной Ñтраницы, Ñ Ð¾Ð³Ð»Ñдкой на безопаÑноÑть, необходима " +"дополнительнаÑ\n" +" проверка Ð¿Ð¾Ð»ÑŒÐ·Ð¾Ð²Ð°Ñ‚ÐµÐ»Ñ Ñ Ð¸Ñпользованием двухфакторной " +"аутентификации.\n" +" Ð”Ð»Ñ Ð´Ð¾Ñтупа к данной Ñтранице Ð’Ñ‹ должны включить Ñти функции " +"безопаÑноÑти." #: aleksis/core/templates/two_factor/core/otp_required.html:16 msgid "Go back" @@ -2909,19 +3066,10 @@ msgstr "" " пожалуйÑта, полученный токен." #: aleksis/core/templates/two_factor/core/setup.html:9 -#, fuzzy -#| msgid "Enable Two-Factor Authentication" msgid "Add Two-Factor Authentication Method" -msgstr "Включить двух-факторную аутентификацию" +msgstr "Добавить двухфакторную аутентификацию" #: aleksis/core/templates/two_factor/core/setup.html:13 -#, fuzzy -#| msgid "" -#| "\n" -#| " You are about to take your account security to the\n" -#| " next level. Follow the steps in this wizard to enable two-factor\n" -#| " authentication.\n" -#| " " msgid "" "\n" " You are about to take your account security to the\n" @@ -2930,8 +3078,10 @@ msgid "" " " msgstr "" "\n" -" Ð’Ñ‹ уÑтанавливаете Ð´Ð»Ñ Ñвоей учётной запиÑи новый уровень безопаÑноÑти.\n" -" Ð”Ð»Ñ Ð²ÐºÐ»ÑŽÑ‡ÐµÐ½Ð¸Ñ Ð´Ð²ÑƒÑ…Ñ„Ð°ÐºÑ‚Ð¾Ñ€Ð½Ð¾Ð¹ аутентификации пройдите неÑколько шагов\n" +" Ð’Ñ‹ уÑтанавливаете Ð´Ð»Ñ Ñвоей учётной запиÑи новый уровень " +"безопаÑноÑти.\n" +" Ð”Ð»Ñ Ð´Ð¾Ð±Ð°Ð²Ð»ÐµÐ½Ð¸Ñ Ð´Ð²ÑƒÑ…Ñ„Ð°ÐºÑ‚Ð¾Ñ€Ð½Ð¾Ð¹ аутентификации пройдите неÑколько " +"шагов\n" " маÑтера наÑтройки.\n" " " @@ -2942,28 +3092,25 @@ msgid "" " " msgstr "" "\n" -" Выберите, пожалуйÑта, метод аутентификации, который Ð’Ñ‹ хотите иÑпользовать:\n" +" Выберите, пожалуйÑта, метод аутентификации, который Ð’Ñ‹ хотите " +"иÑпользовать:\n" " " #: aleksis/core/templates/two_factor/core/setup.html:27 -#, fuzzy -#| msgid "" -#| "\n" -#| " To start using a token generator, please use your\n" -#| " favourite two factor authentication (TOTP) app to scan the QR code below.\n" -#| " Then, enter the token generated by the app.\n" -#| " " msgid "" "\n" " To start using a code generator, please use your\n" -" favourite two-factor authentication (TOTP) app to scan the QR code below.\n" +" favourite two-factor authentication (TOTP) app to scan the QR code " +"below.\n" " Then enter the token generated by the app.\n" " " msgstr "" "\n" -" Ð”Ð»Ñ Ñ‚Ð¾Ð³Ð¾, чтобы начать иÑпользование генератора токенов, воÑпользуйтеÑÑŒ, пожалуйÑта, Ñвоим\n" -" любимым приложением Ð´Ð»Ñ Ð´Ð²ÑƒÑ…Ñ„Ð°ÐºÑ‚Ð¾Ñ€Ð½Ð¾Ð¹ аутентификации (TOTP) и отÑканируйте QR-код, который видите ниже.\n" -" ПоÑле Ñтого напишите полученный генератором токен.\n" +" Ð”Ð»Ñ Ñ‚Ð¾Ð³Ð¾, чтобы начать пользоватьÑÑ Ð³ÐµÐ½ÐµÑ€Ð°Ñ‚Ð¾Ñ€Ð¾Ð¼ кодов, " +"воÑпользуйтеÑÑŒ, пожалуйÑта, Ñвоим\n" +" любимым приложением Ð´Ð»Ñ Ð´Ð²ÑƒÑ…Ñ„Ð°ÐºÑ‚Ð¾Ñ€Ð½Ð¾Ð¹ аутентификации (TOTP) и " +"отÑканируйте QR-код, который видите ниже.\n" +" ПоÑле Ñтого напишите Ñгенерированный программой токен.\n" " " #: aleksis/core/templates/two_factor/core/setup.html:38 @@ -2991,58 +3138,57 @@ msgstr "" " " #: aleksis/core/templates/two_factor/core/setup.html:52 -#, fuzzy -#| msgid "" -#| "\n" -#| " We sent you a text message, please enter the tokens we sent.\n" -#| " " msgid "" "\n" " We sent you an email, please enter the token we sent.\n" " " msgstr "" "\n" -" Мы отправили Вам текÑтовое Ñообщение. Ðапишите, пожалуйÑта, полученные токены.\n" -" " +" Мы отправили Вам Ñл.пиÑьмо. Ðапишите, пожалуйÑта, полученный токен.\n" +" " #: aleksis/core/templates/two_factor/core/setup.html:60 msgid "" "\n" -" We are calling your phone right now, please enter the digits you hear.\n" +" We are calling your phone right now, please enter the digits you " +"hear.\n" " " msgstr "" "\n" -" Мы ÑÐµÐ¹Ñ‡Ð°Ñ Ð·Ð²Ð¾Ð½Ð¸Ð¼ на Ваш номер, – напишите, пожалуйÑта, цифры, которые уÑлышите.\n" +" Мы ÑÐµÐ¹Ñ‡Ð°Ñ Ð·Ð²Ð¾Ð½Ð¸Ð¼ на Ваш номер, – напишите, пожалуйÑта, цифры, " +"которые уÑлышите.\n" " " #: aleksis/core/templates/two_factor/core/setup.html:66 -#, fuzzy -#| msgid "" -#| "\n" -#| " We sent you a text message, please enter the tokens we sent.\n" -#| " " msgid "" "\n" " We sent you a text message, please enter the code we sent.\n" " " msgstr "" "\n" -" Мы отправили Вам текÑтовое Ñообщение. Ðапишите, пожалуйÑта, полученные токены.\n" +" Мы отправили Вам текÑтовое Ñообщение. Ðапишите, пожалуйÑта, " +"полученный код.\n" " " #: aleksis/core/templates/two_factor/core/setup.html:73 msgid "" "\n" -" We've encountered an issue with the selected authentication method. Please\n" -" go back and verify that you entered your information correctly, try\n" -" again, or use a different authentication method instead. If the issue\n" +" We've encountered an issue with the selected authentication " +"method. Please\n" +" go back and verify that you entered your information correctly, " +"try\n" +" again, or use a different authentication method instead. If the " +"issue\n" " persists, contact the site administrator.\n" " " msgstr "" "\n" -" Мы заметили, что ÑущеÑтвует проблема Ñ Ð¸Ñпользованием выбранного метода авторизации. ВернитеÑÑŒ,\n" -" пожалуйÑта, назад, и убедитеÑÑŒ, что необходимые данные указаны правильно,\n" -" поÑле чего попробуйте зайти ешё раз, или же воÑпользуйтеÑÑŒ другим вариантом входа. ЕÑли же\n" +" Мы заметили, что ÑущеÑтвует проблема Ñ Ð¸Ñпользованием выбранного " +"метода авторизации. ВернитеÑÑŒ,\n" +" пожалуйÑта, назад, и убедитеÑÑŒ, что необходимые данные указаны " +"правильно,\n" +" поÑле чего попробуйте зайти ешё раз, или же воÑпользуйтеÑÑŒ другим " +"вариантом входа. ЕÑли же\n" " проблема оÑтаётÑÑ, обратитеÑÑŒ к админиÑтратору Ñайта.\n" " " @@ -3068,7 +3214,8 @@ msgstr "Ð”Ð²ÑƒÑ…Ñ„Ð°ÐºÑ‚Ð¾Ñ€Ð½Ð°Ñ Ð°ÑƒÑ‚ÐµÐ½Ñ‚Ð¸Ñ„Ð¸ÐºÐ°Ñ†Ð¸Ñ ÑƒÑпешно #: aleksis/core/templates/two_factor/core/setup_complete.html:13 msgid "" "\n" -" Congratulations, you've successfully enabled two-factor authentication.\n" +" Congratulations, you've successfully enabled two-factor " +"authentication.\n" " " msgstr "" "\n" @@ -3086,29 +3233,25 @@ msgid "Generate backup codes" msgstr "Создать резервные коды" #: aleksis/core/templates/two_factor/core/setup_complete.html:31 -#, fuzzy -#| msgid "" -#| "\n" -#| " However, it might happen that you don't have access to\n" -#| " your primary token device. To enable account recovery, generate backup codes\n" -#| " or add a phone number.\n" -#| " " msgid "" "\n" " However, it might happen that you don't have access to\n" -" your primary device. To enable account recovery, generate backup codes\n" +" your primary device. To enable account recovery, generate backup " +"codes\n" " or add other authentication methods.\n" " " msgstr "" "\n" -" Между прочим, может так ÑлучитьÑÑ, что у Ð’Ð°Ñ Ð½Ðµ будет доÑтупа к Ñвоему оÑновному\n" -" уÑтройÑтву Ñ Ñ‚Ð¾ÐºÐµÐ½Ð°Ð¼Ð¸. Ð”Ð»Ñ Ð²ÐºÐ»ÑŽÑ‡ÐµÐ½Ð¸Ñ Ð²Ð¾ÑÑÑ‚Ð°Ð½Ð¾Ð²Ð»ÐµÐ½Ð¸Ñ ÑƒÑ‡Ñ‘Ñ‚ÐºÐ¸ Ñоздайте резервные коды\n" -" или добавьте номер телефона.\n" +" Между прочим, может так ÑлучитьÑÑ, что у Ð’Ð°Ñ Ð½Ðµ будет доÑтупа к " +"Ñвоему оÑновному\n" +" уÑтройÑтву. Ð”Ð»Ñ Ð²ÐºÐ»ÑŽÑ‡ÐµÐ½Ð¸Ñ Ð²Ð¾ÑÑÑ‚Ð°Ð½Ð¾Ð²Ð»ÐµÐ½Ð¸Ñ ÑƒÑ‡Ñ‘Ñ‚ÐºÐ¸ Ñоздайте резервные " +"коды\n" +" или добавьте другие методы аутентификации.\n" " " #: aleksis/core/templates/two_factor/core/setup_complete.html:48 msgid "Add Another Authentication Method" -msgstr "" +msgstr "Добавить метод аутентификации" #: aleksis/core/templates/two_factor/profile/disable.html:5 #: aleksis/core/templates/two_factor/profile/disable.html:9 @@ -3116,8 +3259,12 @@ msgid "Disable Two-Factor Authentication" msgstr "Отключить двухфакторную аутентификацию" #: aleksis/core/templates/two_factor/profile/disable.html:12 -msgid "You are about to disable two-factor authentication. This weakens your account security, are you sure?" -msgstr "Ð’Ñ‹ отключаете двухфакторную аутентификацию. Ðто Ñнизит защиту Вашей учётной запиÑи. Ð’Ñ‹ уверены?" +msgid "" +"You are about to disable two-factor authentication. This weakens your " +"account security, are you sure?" +msgstr "" +"Ð’Ñ‹ отключаете двухфакторную аутентификацию. Ðто Ñнизит защиту Вашей учётной " +"запиÑи. Ð’Ñ‹ уверены?" #: aleksis/core/templates/two_factor/profile/disable.html:26 msgid "Disable" @@ -3151,187 +3298,238 @@ msgstr "Во Ð²Ñ€ÐµÐ¼Ñ ÑÐ¾Ð·Ð´Ð°Ð½Ð¸Ñ Ñ„Ð°Ð¹Ð»Ð° PDF возникла про msgid "Download PDF" msgstr "Скачать PDF" -#: aleksis/core/views.py:285 +#: aleksis/core/views.py:280 aleksis/core/views.py:285 msgid "The school term has been created." msgstr "Учебный год Ñоздан." -#: aleksis/core/views.py:297 +#: aleksis/core/views.py:292 aleksis/core/views.py:297 msgid "The school term has been saved." msgstr "Учебный год Ñохранён." -#: aleksis/core/views.py:401 +#: aleksis/core/views.py:396 aleksis/core/views.py:401 msgid "The child groups were successfully saved." msgstr "Дочерние группы Ñохранены." +#: aleksis/core/views.py:415 aleksis/core/views.py:425 #: aleksis/core/views.py:420 aleksis/core/views.py:430 msgid "The person has been saved." msgstr "Физлицо Ñохранено." -#: aleksis/core/views.py:480 +#: aleksis/core/views.py:475 aleksis/core/views.py:480 msgid "The group has been saved." msgstr "Группа Ñохранена." -#: aleksis/core/views.py:528 -#, fuzzy -#| msgid "The data checks were run successfully." -msgid "Maintenance mode was turned on successfully." -msgstr "Проверка данных уÑпешно запущена." - -#: aleksis/core/views.py:530 -msgid "Maintenance mode was turned off successfully." -msgstr "" - -#: aleksis/core/views.py:588 +#: aleksis/core/views.py:558 aleksis/core/views.py:588 msgid "The announcement has been saved." msgstr "ОбъÑвление Ñохранено." -#: aleksis/core/views.py:604 +#: aleksis/core/views.py:574 aleksis/core/views.py:604 msgid "The announcement has been deleted." msgstr "ОбъÑвление удалено." -#: aleksis/core/views.py:673 +#: aleksis/core/views.py:643 aleksis/core/views.py:673 msgid "The requested preference registry does not exist" msgstr "Журнал Ñ Ð·Ð°Ð¿Ñ€Ð¾ÑˆÐµÐ½Ð½Ñ‹Ð¼Ð¸ ÑвойÑтвами не ÑущеÑтвует" -#: aleksis/core/views.py:692 +#: aleksis/core/views.py:662 aleksis/core/views.py:692 msgid "The preferences have been saved successfully." msgstr "СвойÑтва Ñохранены." -#: aleksis/core/views.py:716 +#: aleksis/core/views.py:686 aleksis/core/views.py:716 msgid "The person has been deleted." msgstr "Физлицо удалено." -#: aleksis/core/views.py:730 +#: aleksis/core/views.py:700 aleksis/core/views.py:730 msgid "The group has been deleted." msgstr "Группа удалена." -#: aleksis/core/views.py:762 +#: aleksis/core/views.py:732 aleksis/core/views.py:762 msgid "The additional field has been saved." msgstr "Дополнительное поле Ñохранено." -#: aleksis/core/views.py:797 +#: aleksis/core/views.py:767 aleksis/core/views.py:797 msgid "The additional field has been deleted." msgstr "Дополнительное поле удалено." -#: aleksis/core/views.py:822 +#: aleksis/core/views.py:792 aleksis/core/views.py:822 msgid "The group type has been saved." msgstr "Тип группы Ñохранён." -#: aleksis/core/views.py:853 +#: aleksis/core/views.py:823 aleksis/core/views.py:853 msgid "The group type has been deleted." msgstr "Тип группы удалён." -#: aleksis/core/views.py:888 +#: aleksis/core/views.py:858 aleksis/core/views.py:888 msgid "Progress: Run data checks" msgstr "Ð’ процеÑÑе: ЗапуÑк проверки данных" -#: aleksis/core/views.py:889 +#: aleksis/core/views.py:859 aleksis/core/views.py:889 msgid "Run data checks …" msgstr "ЗапуÑкаетÑÑ Ð¿Ñ€Ð¾Ð²ÐµÑ€ÐºÐ° данных …" -#: aleksis/core/views.py:890 +#: aleksis/core/views.py:860 aleksis/core/views.py:890 msgid "The data checks were run successfully." msgstr "Проверка данных уÑпешно запущена." -#: aleksis/core/views.py:891 +#: aleksis/core/views.py:861 aleksis/core/views.py:891 msgid "There was a problem while running data checks." msgstr "Во Ð²Ñ€ÐµÐ¼Ñ Ð·Ð°Ð¿ÑƒÑка проверки данных возникла проблема." -#: aleksis/core/views.py:908 +#: aleksis/core/views.py:878 aleksis/core/views.py:908 #, python-brace-format msgid "The solve option '{solve_option_obj.verbose_name}' " msgstr "Вариант Ñ€ÐµÑˆÐµÐ½Ð¸Ñ \"{solve_option_obj.verbose_name}\" " -#: aleksis/core/views.py:918 +#: aleksis/core/views.py:888 aleksis/core/views.py:918 msgid "The requested solve option does not exist" msgstr "Запрошенный вариант Ñ€ÐµÑˆÐµÐ½Ð¸Ñ Ð½Ðµ ÑущеÑтвует" -#: aleksis/core/views.py:951 +#: aleksis/core/views.py:921 aleksis/core/views.py:951 msgid "The dashboard widget has been saved." msgstr "Виджет информпанели Ñохранён." -#: aleksis/core/views.py:981 +#: aleksis/core/views.py:951 aleksis/core/views.py:981 msgid "The dashboard widget has been created." msgstr "Виджет информпанели Ñоздан." -#: aleksis/core/views.py:991 +#: aleksis/core/views.py:961 aleksis/core/views.py:991 msgid "The dashboard widget has been deleted." msgstr "Виджет информпанели удалён." -#: aleksis/core/views.py:1063 +#: aleksis/core/views.py:1033 aleksis/core/views.py:1063 msgid "Your dashboard configuration has been saved successfully." msgstr "Ваша ÐºÐ¾Ð½Ñ„Ð¸Ð³ÑƒÑ€Ð°Ñ†Ð¸Ñ Ð¸Ð½Ñ„Ð¾Ñ€Ð¼Ð¿Ð°Ð½ÐµÐ»Ð¸ Ñохранена." -#: aleksis/core/views.py:1065 +#: aleksis/core/views.py:1035 aleksis/core/views.py:1065 msgid "The configuration of the default dashboard has been saved successfully." msgstr "ÐšÐ¾Ð½Ñ„Ð¸Ð³ÑƒÑ€Ð°Ñ†Ð¸Ñ Ñ‚Ð¸Ð¿Ð¾Ð²Ð¾Ð¹/Ñтандартной информпанели." -#: aleksis/core/views.py:1136 +#: aleksis/core/views.py:1106 aleksis/core/views.py:1136 #, python-brace-format msgid "The invitation was successfully created. The invitation code is {code}" msgstr "Приглашение уÑпешно Ñоздано. Код приглашениÑ: {code}" -#: aleksis/core/views.py:1233 +#: aleksis/core/views.py:1203 aleksis/core/views.py:1233 msgid "We have successfully assigned the permissions." msgstr "Мы уÑпешно назначили доÑтупы." -#: aleksis/core/views.py:1243 +#: aleksis/core/views.py:1213 aleksis/core/views.py:1243 msgid "The global user permission has been deleted." msgstr "Глобальный пользовательÑкий доÑтуп удалён." -#: aleksis/core/views.py:1253 +#: aleksis/core/views.py:1223 aleksis/core/views.py:1253 msgid "The global group permission has been deleted." msgstr "Глобальный групповой доÑтуп удалён." -#: aleksis/core/views.py:1263 +#: aleksis/core/views.py:1233 aleksis/core/views.py:1263 msgid "The object user permission has been deleted." msgstr "Объектный пользовательÑкий доÑтуп удалён." -#: aleksis/core/views.py:1273 +#: aleksis/core/views.py:1243 aleksis/core/views.py:1273 msgid "The object group permission has been deleted." msgstr "Объектный групповой доÑтуп удалён." -#: aleksis/core/views.py:1382 -msgid "The third-party account could not be disconnected because it is the only login method available." -msgstr "Учётную запиÑÑŒ третьей Ñтороны Ð½ÐµÐ»ÑŒÐ·Ñ Ð¾Ñ‚ÐºÐ»ÑŽÑ‡Ð¸Ñ‚ÑŒ, Ñ‚.к. Ñто единÑтвенный ÑпоÑоб входа." +#: aleksis/core/views.py:1352 aleksis/core/views.py:1382 +msgid "" +"The third-party account could not be disconnected because it is the only " +"login method available." +msgstr "" +"Учётную запиÑÑŒ третьей Ñтороны Ð½ÐµÐ»ÑŒÐ·Ñ Ð¾Ñ‚ÐºÐ»ÑŽÑ‡Ð¸Ñ‚ÑŒ, Ñ‚.к. Ñто единÑтвенный " +"ÑпоÑоб входа." -#: aleksis/core/views.py:1389 +#: aleksis/core/views.py:1359 aleksis/core/views.py:1389 msgid "The third-party account has been successfully disconnected." msgstr "Ð£Ñ‡Ñ‘Ñ‚Ð½Ð°Ñ Ð·Ð°Ð¿Ð¸ÑÑŒ третьей Ñтороны уÑпешно отключена." -#: aleksis/core/views.py:1465 -msgid "Person was invited successfully and an email with further instructions has been send to them." -msgstr "Владелец указанного Ñл.адреÑа уÑпешно приглашён. ИнÑтрукции о дальнейших дейÑтвиÑÑ… отправлены на Ñл.почту." +#: aleksis/core/views.py:1435 aleksis/core/views.py:1465 +msgid "" +"Person was invited successfully and an email with further instructions has " +"been send to them." +msgstr "" +"Владелец указанного Ñл.адреÑа уÑпешно приглашён. ИнÑтрукции о дальнейших " +"дейÑтвиÑÑ… отправлены на Ñл.почту." -#: aleksis/core/views.py:1476 +#: aleksis/core/views.py:1446 aleksis/core/views.py:1476 msgid "Person was already invited." msgstr "Кто-то уже приглаÑил его/её." -#~ msgid "Revoke access" -#~ msgstr "Отозвать доÑтуп" +#: aleksis/core/apps.py:151 +#, fuzzy +msgid "You have been logged out successfully." +msgstr "СвойÑтва Ñохранены." -#~ msgid "Are you sure to revoke the access for this application?" -#~ msgstr "Ви дейÑтвительно хотите отозвать доÑтуп Ð´Ð»Ñ Ñтого приложениÑ?" +#: aleksis/core/templates/core/pages/system_status.html:28 +#, fuzzy +msgid "" +"\n" +" Only admin and visitors from internal IPs can access the " +"site.\n" +" " +msgstr "" +"\n" +" ДоÑтуп к Ñайту имеют только админиÑтратор и пользователи Ñ " +"внутренними IP-адреÑами.\n" +" " + +#: aleksis/core/templates/offline.html:10 +msgid "No connection to server." +msgstr "" + +#: aleksis/core/templates/offline.html:14 +#, fuzzy +msgid "" +"\n" +" This page is not available without a connection to the server. Please " +"check your internet connection and try again.\n" +" If you are connected and the error persists, please contact the system " +"administrators:\n" +" " +msgstr "" +"\n" +" Ðта Ñтраница в автономном режимен не доÑтупна. Возможно, у Ð’Ð°Ñ " +"проблема Ñ Ð¸Ð½Ñ‚ÐµÑ€Ð½ÐµÑ‚Ð¾Ð¼. УбедитеÑÑŒ, что Ваш WiFi\n" +" или мобильный интернет включены и попробуйте ещё раз. ЕÑли Ñчитаете, " +"что Ñ Ð¿Ð¾Ð´ÐºÐ»ÑŽÑ‡ÐµÐ½Ð¸ÐµÐ¼ вÑÑ‘ хорошо, обратитеÑÑŒ,\n" +" пожалуйÑта, к ÑиÑтемным админиÑтраторам:\n" +" " + +#: aleksis/core/templates/templated_email/invitation.email:4 +#, python-format +msgid "Invitation to register on %(site)s" +msgstr "" -#~ msgid "Revoke" -#~ msgstr "Отозвать" +#: aleksis/core/templates/templated_email/invitation.email:6 +#: aleksis/core/templates/templated_email/invitation.email:14 +#, fuzzy, python-format +msgid "Hello %(person)s" +msgstr "Выбранные физлица" -#~ msgid "Authorized applications" -#~ msgstr "Ðвторизованные приложениÑ" +#: aleksis/core/templates/templated_email/invitation.email:9 +#: aleksis/core/templates/templated_email/invitation.email:18 +#, python-format +msgid "" +"you have been invited to register on %(site)s. If you would like to accept " +"this invitation, please click on the following link:" +msgstr "" -#~ msgid "No authorized applications." -#~ msgstr "Ðвторизованных приложений нет." +#: aleksis/core/views.py:528 +#, fuzzy +msgid "Maintenance mode was turned on successfully." +msgstr "Проверка данных уÑпешно запущена." -#~ msgid "Page not available offline." -#~ msgstr "Ð’ автономном режиме Ñтраница не доÑтупна." +#: aleksis/core/views.py:530 +msgid "Maintenance mode was turned off successfully." +msgstr "" #~ msgid "" #~ "\n" -#~ " This page is currently unavailable. If this error persists, contact your site administrators:\n" +#~ " This page is currently unavailable. If this error persists, " +#~ "contact your site administrators:\n" #~ " " #~ msgstr "" #~ "\n" -#~ " Ðта Ñтраница ÑÐµÐ¹Ñ‡Ð°Ñ Ð½ÐµÐ´Ð¾Ñтупна. ЕÑли ошибка проÑвитÑÑ ÐµÑ‰Ñ‘ раз, обратитеÑÑŒ к админиÑтраторам Ñайта:\n" +#~ " Ðта Ñтраница ÑÐµÐ¹Ñ‡Ð°Ñ Ð½ÐµÐ´Ð¾Ñтупна. ЕÑли ошибка проÑвитÑÑ ÐµÑ‰Ñ‘ " +#~ "раз, обратитеÑÑŒ к админиÑтраторам Ñайта:\n" #~ " " #~ msgid "" @@ -3415,8 +3613,10 @@ msgstr "Кто-то уже приглаÑил его/её." #~ " " #~ msgstr "" #~ "\n" -#~ " Ðе ÑÐ¼Ð¾Ñ‚Ñ€Ñ Ð½Ð° то, что мы Вам рекомендуем Ñтого не делать, Ð’Ñ‹ можете \n" -#~ " также отключить двухфакторную аутентификацию Ð´Ð»Ñ Ñвоей учётной запиÑи.\n" +#~ " Ðе ÑÐ¼Ð¾Ñ‚Ñ€Ñ Ð½Ð° то, что мы Вам рекомендуем Ñтого не делать, Ð’Ñ‹ " +#~ "можете \n" +#~ " также отключить двухфакторную аутентификацию Ð´Ð»Ñ Ñвоей учётной " +#~ "запиÑи.\n" #~ " " #~ msgid "" @@ -3428,7 +3628,8 @@ msgstr "Кто-то уже приглаÑил его/её." #~ msgstr "" #~ "\n" #~ " Ð”Ð²ÑƒÑ…Ñ„Ð°ÐºÑ‚Ð¾Ñ€Ð½Ð°Ñ Ð°ÑƒÑ‚ÐµÐ½Ñ‚Ð¸Ñ„Ð¸ÐºÐ°Ñ†Ð¸Ñ Ð´Ð»Ñ Ð’Ð°ÑˆÐµÐ¹ учётной запиÑи\n" -#~ " не активирована. Ð”Ð»Ñ Ð¿Ð¾Ð²Ñ‹ÑˆÐµÐ½Ð¸Ñ Ð±ÐµÐ·Ð¾Ð¿Ð°ÑноÑти учётной запиÑи включите\n" +#~ " не активирована. Ð”Ð»Ñ Ð¿Ð¾Ð²Ñ‹ÑˆÐµÐ½Ð¸Ñ Ð±ÐµÐ·Ð¾Ð¿Ð°ÑноÑти учётной запиÑи " +#~ "включите\n" #~ " двухфакторную аутентификацию.\n" #~ " " @@ -3535,8 +3736,14 @@ msgstr "Кто-то уже приглаÑил его/её." #~ msgid "ICal Feeds" #~ msgstr "iCal-ленты" -#~ msgid "These are URLs for different Calendar Feeds in the iCal (.ics) format. You can create as many as you want and import them in your calendar software." -#~ msgstr "ЗдеÑÑŒ находÑÑ‚ÑÑ ÑÑылки на разные ленты календарей в формате iCal (.ics). Ð’Ñ‹ можете Ñоздать их Ñтолько, Ñколько будет необходимо и импортировать их в ПО Ð´Ð»Ñ Ñ€Ð°Ð±Ð¾Ñ‚Ñ‹ Ñ ÐºÐ°Ð»ÐµÐ½Ð´Ð°Ñ€Ñми." +#~ msgid "" +#~ "These are URLs for different Calendar Feeds in the iCal (.ics) format. " +#~ "You can create as many as you want and import them in your calendar " +#~ "software." +#~ msgstr "" +#~ "ЗдеÑÑŒ находÑÑ‚ÑÑ ÑÑылки на разные ленты календарей в формате iCal (.ics). " +#~ "Ð’Ñ‹ можете Ñоздать их Ñтолько, Ñколько будет необходимо и импортировать их " +#~ "в ПО Ð´Ð»Ñ Ñ€Ð°Ð±Ð¾Ñ‚Ñ‹ Ñ ÐºÐ°Ð»ÐµÐ½Ð´Ð°Ñ€Ñми." #~ msgid "Your iCal URLs" #~ msgstr "Ваши ÑÑылки iCal" @@ -3561,24 +3768,30 @@ msgstr "Кто-то уже приглаÑил его/её." #~ msgid "" #~ "\n" -#~ " This platform is powered by AlekSIS®, a web-based school information system (SIS) which can be used\n" -#~ " to manage and/or publish organisational artifacts of educational institutions. AlekSIS is free software and\n" +#~ " This platform is powered by AlekSIS®, a web-based school " +#~ "information system (SIS) which can be used\n" +#~ " to manage and/or publish organisational artifacts of " +#~ "educational institutions. AlekSIS is free software and\n" #~ " can be used by anyone.\n" #~ " " #~ msgstr "" #~ "\n" -#~ " Ðта платформа иÑпользует AlekSIS®, веб-инÑтрумент информационной ÑиÑтемы Ð´Ð»Ñ Ð¾Ð±ÑƒÑ‡ÐµÐ½Ð¸Ñ (SIS) при помощи которой\n" -#~ " можно управлÑть и/или публиковать оргинформацию учебных заведений. AlekSIS - беÑплатное ПО\n" +#~ " Ðта платформа иÑпользует AlekSIS®, веб-инÑтрумент " +#~ "информационной ÑиÑтемы Ð´Ð»Ñ Ð¾Ð±ÑƒÑ‡ÐµÐ½Ð¸Ñ (SIS) при помощи которой\n" +#~ " можно управлÑть и/или публиковать оргинформацию учебных " +#~ "заведений. AlekSIS - беÑплатное ПО\n" #~ " и его может иÑпользовать любой желающий.\n" #~ " " #~ msgid "" #~ "\n" -#~ " AlekSIS® is a registered trademark of the AlekSIS open source project, represented by Teckids e.V.\n" +#~ " AlekSIS® is a registered trademark of the AlekSIS open " +#~ "source project, represented by Teckids e.V.\n" #~ " " #~ msgstr "" #~ "\n" -#~ " AlekSIS® – зарегиÑÑ‚Ñ€Ð¸Ñ€Ð¾Ð²Ð°Ð½Ð½Ð°Ñ Ñ‚Ð¾Ñ€Ð³Ð¾Ð²Ð°Ñ Ð¼Ð°Ñ€ÐºÐ° проекта Ñ Ð¾Ñ‚ÐºÑ€Ñ‹Ñ‚Ñ‹Ð¼ иÑходным кодом AlekSIS, ÐºÐ¾Ñ‚Ð¾Ñ€Ð°Ñ Ð¿Ñ€ÐµÐ´Ñтавлена Teckids e.V.\n" +#~ " AlekSIS® – зарегиÑÑ‚Ñ€Ð¸Ñ€Ð¾Ð²Ð°Ð½Ð½Ð°Ñ Ñ‚Ð¾Ñ€Ð³Ð¾Ð²Ð°Ñ Ð¼Ð°Ñ€ÐºÐ° проекта Ñ " +#~ "открытым иÑходным кодом AlekSIS, ÐºÐ¾Ñ‚Ð¾Ñ€Ð°Ñ Ð¿Ñ€ÐµÐ´Ñтавлена Teckids e.V.\n" #~ " " #~ msgid "Website of AlekSIS" @@ -3592,14 +3805,18 @@ msgstr "Кто-то уже приглаÑил его/её." #~ msgid "" #~ "\n" -#~ " The core and the official apps of AlekSIS are licenced under the EUPL, version 1.2 or later. For licence\n" -#~ " information from third-party apps, if installed, refer to the respective components below. The\n" +#~ " The core and the official apps of AlekSIS are licenced " +#~ "under the EUPL, version 1.2 or later. For licence\n" +#~ " information from third-party apps, if installed, refer to " +#~ "the respective components below. The\n" #~ " licences are marked like this:\n" #~ " " #~ msgstr "" #~ "\n" -#~ " Ядро и официальные Ð´Ð¾Ð¿Ð¾Ð»Ð½ÐµÐ½Ð¸Ñ AlekSIS лицензированы под лицензией EUPL, верÑии 1.2 или новее. Ð”Ð»Ñ Ð¿Ð¾Ð»ÑƒÑ‡ÐµÐ½Ð¸Ñ Ð¸Ð½Ñ„Ð¾Ñ€Ð¼Ð°Ñ†Ð¸Ð¸\n" -#~ " о компонентах третих Ñторон, еÑли таковы уÑтановлены, перейдите к ÑоответÑтвующим компонентам ниже.\n" +#~ " Ядро и официальные Ð´Ð¾Ð¿Ð¾Ð»Ð½ÐµÐ½Ð¸Ñ AlekSIS лицензированы под " +#~ "лицензией EUPL, верÑии 1.2 или новее. Ð”Ð»Ñ Ð¿Ð¾Ð»ÑƒÑ‡ÐµÐ½Ð¸Ñ Ð¸Ð½Ñ„Ð¾Ñ€Ð¼Ð°Ñ†Ð¸Ð¸\n" +#~ " о компонентах третих Ñторон, еÑли таковы уÑтановлены, " +#~ "перейдите к ÑоответÑтвующим компонентам ниже.\n" #~ " Ðти лицензии обозначены такой отметкой:\n" #~ " " @@ -3617,11 +3834,13 @@ msgstr "Кто-то уже приглаÑил его/её." #~ msgid "" #~ "\n" -#~ " Without activated JavaScript the progress status can't be updated.\n" +#~ " Without activated JavaScript the progress status can't be " +#~ "updated.\n" #~ " " #~ msgstr "" #~ "\n" -#~ " Без активного JavaScript ÑÑ‚Ð°Ñ‚ÑƒÑ Ð²Ñ‹Ð¿Ð¾Ð»Ð½ÐµÐ½Ð¸Ñ Ð¾Ð±Ð½Ð¾Ð²Ð»ÑтьÑÑ Ð½Ðµ Ñможет.\n" +#~ " Без активного JavaScript ÑÑ‚Ð°Ñ‚ÑƒÑ Ð²Ñ‹Ð¿Ð¾Ð»Ð½ÐµÐ½Ð¸Ñ Ð¾Ð±Ð½Ð¾Ð²Ð»ÑтьÑÑ Ð½Ðµ " +#~ "Ñможет.\n" #~ " " #~ msgid "Language" @@ -3632,12 +3851,14 @@ msgstr "Кто-то уже приглаÑил его/её." #~ msgid "" #~ "\n" -#~ " Your administrator account is not linked to any person. Therefore,\n" +#~ " Your administrator account is not linked to any person. " +#~ "Therefore,\n" #~ " a dummy person has been linked to your account.\n" #~ " " #~ msgstr "" #~ "\n" -#~ " Ваша админиÑÑ‚Ñ€Ð°Ñ‚Ð¸Ð²Ð½Ð°Ñ ÑƒÑ‡Ñ‘Ñ‚Ð½Ð°Ñ Ð·Ð°Ð¿Ð¸ÑÑŒ не Ñоединена на Ñ Ð¾Ð´Ð½Ð¸Ð¼ физлицом. ПоÑтому\n" +#~ " Ваша админиÑÑ‚Ñ€Ð°Ñ‚Ð¸Ð²Ð½Ð°Ñ ÑƒÑ‡Ñ‘Ñ‚Ð½Ð°Ñ Ð·Ð°Ð¿Ð¸ÑÑŒ не Ñоединена на Ñ Ð¾Ð´Ð½Ð¸Ð¼ " +#~ "физлицом. ПоÑтому\n" #~ " к Вашей учётной запиÑи привÑзано фейковое физлицо.\n" #~ " " @@ -3650,8 +3871,10 @@ msgstr "Кто-то уже приглаÑил его/её." #~ msgstr "" #~ "\n" #~ " Ваша ÑƒÑ‡Ñ‘Ñ‚Ð½Ð°Ñ Ð·Ð°Ð¿Ð¸ÑÑŒ не ÑвÑзана Ñ Ñ„Ð¸Ð·Ð»Ð¸Ñ†Ð¾Ð¼. Ðто значит,\n" -#~ " что у Ð’Ð°Ñ Ð½ÐµÑ‚ доÑтупа на к какой учебной информации. ОбратитеÑÑŒ,\n" -#~ " пожалуйÑта, к админиÑтраторам AlekSIS в Вашем учебном заведении.\n" +#~ " что у Ð’Ð°Ñ Ð½ÐµÑ‚ доÑтупа на к какой учебной информации. " +#~ "ОбратитеÑÑŒ,\n" +#~ " пожалуйÑта, к админиÑтраторам AlekSIS в Вашем учебном " +#~ "заведении.\n" #~ " " #~ msgid "Impersonate" diff --git a/aleksis/core/management/commands/vite.py b/aleksis/core/management/commands/vite.py index 57370441b37a9db241a8a8a0bebbd8f8f00f5753..747f328ea82060b2b0148d9c928a498e018cefc9 100644 --- a/aleksis/core/management/commands/vite.py +++ b/aleksis/core/management/commands/vite.py @@ -1,6 +1,7 @@ import os from django.conf import settings +from django.core.management.base import CommandError from django_yarnpkg.management.base import BaseYarnCommand from django_yarnpkg.yarn import yarn_adapter @@ -26,4 +27,6 @@ class Command(BaseYarnCommand): yarn_adapter.install(settings.YARN_INSTALLED_APPS) # Run Vite build - run_vite([options["command"]]) + ret = run_vite([options["command"]]) + if ret != 0: + raise CommandError("yarn command failed", returncode=ret) diff --git a/aleksis/core/managers.py b/aleksis/core/managers.py index 66cac5f59cb9a328aa36be23f36f5dc481b8e1f1..07237fdac02af7750a2000f89167cb9bbc217a5c 100644 --- a/aleksis/core/managers.py +++ b/aleksis/core/managers.py @@ -11,7 +11,23 @@ from django_cte import CTEManager, CTEQuerySet from polymorphic.managers import PolymorphicManager, PolymorphicQuerySet -class CurrentSiteManagerWithoutMigrations(_CurrentSiteManager): +class AlekSISBaseManager(_CurrentSiteManager): + """Base manager for AlekSIS model customisation.""" + + def unmanaged(self) -> QuerySet: + """Get instances that are not managed by any particular app.""" + return super().get_queryset().filter(managed_by_app_label="") + + def managed_by_app(self, app_label: str) -> QuerySet: + """Get instances managed by a particular app.""" + return super().get_queryset().filter(managed_by_app_label=app_label) + + def get_queryset(self) -> QuerySet: + return self.unmanaged() + + +# FIXME rename this and other classes after removing sites framework +class CurrentSiteManagerWithoutMigrations(AlekSISBaseManager): """CurrentSiteManager for auto-generating managers just by query sets.""" use_in_migrations = False @@ -123,7 +139,7 @@ class InstalledWidgetsDashboardWidgetOrderManager(Manager): return super().get_queryset().filter(widget_id__in=dashboard_widget_pks) -class PolymorphicCurrentSiteManager(CurrentSiteManagerWithoutMigrations, PolymorphicManager): +class PolymorphicCurrentSiteManager(AlekSISBaseManager, PolymorphicManager): """Default manager for extensible, polymorphic models.""" diff --git a/aleksis/core/migrations/0001_initial.py b/aleksis/core/migrations/0001_initial.py index aa7398e14a68e208ce75400c04177b752b1be8d4..d36293fa5ac4e11b8c2ad9080e9f90e9c9b1bd7d 100644 --- a/aleksis/core/migrations/0001_initial.py +++ b/aleksis/core/migrations/0001_initial.py @@ -5,7 +5,7 @@ import aleksis.core.util.core_helpers import datetime from django.conf import settings import django.contrib.postgres.fields.jsonb -import django.contrib.sites.managers +import aleksis.core.managers from django.db import migrations, models import django.db.models.deletion import phonenumber_field.modelfields @@ -47,7 +47,7 @@ class Migration(migrations.Migration): 'verbose_name_plural': 'Addtitional fields for groups', }, managers=[ - ('objects', django.contrib.sites.managers.CurrentSiteManager()), + ('objects', aleksis.core.managers.AlekSISBaseManager()), ], ), migrations.CreateModel( @@ -80,7 +80,7 @@ class Migration(migrations.Migration): 'verbose_name_plural': 'Custom menus', }, managers=[ - ('objects', django.contrib.sites.managers.CurrentSiteManager()), + ('objects', aleksis.core.managers.AlekSISBaseManager()), ], ), migrations.CreateModel( @@ -99,7 +99,7 @@ class Migration(migrations.Migration): 'permissions': (('assign_child_groups_to_groups', 'Can assign child groups to groups'),), }, managers=[ - ('objects', django.contrib.sites.managers.CurrentSiteManager()), + ('objects', aleksis.core.managers.AlekSISBaseManager()), ], ), migrations.CreateModel( @@ -136,7 +136,7 @@ class Migration(migrations.Migration): 'permissions': (('view_address', 'Can view address'), ('view_contact_details', 'Can view contact details'), ('view_photo', 'Can view photo'), ('view_person_groups', 'Can view persons groups'), ('view_personal_details', 'Can view personal details')), }, managers=[ - ('objects', django.contrib.sites.managers.CurrentSiteManager()), + ('objects', aleksis.core.managers.AlekSISBaseManager()), ], ), migrations.CreateModel( @@ -149,7 +149,7 @@ class Migration(migrations.Migration): }, bases=('core.person',), managers=[ - ('objects', django.contrib.sites.managers.CurrentSiteManager()), + ('objects', aleksis.core.managers.AlekSISBaseManager()), ], ), migrations.CreateModel( @@ -187,7 +187,7 @@ class Migration(migrations.Migration): 'abstract': False, }, managers=[ - ('objects', django.contrib.sites.managers.CurrentSiteManager()), + ('objects', aleksis.core.managers.AlekSISBaseManager()), ], ), migrations.CreateModel( @@ -209,7 +209,7 @@ class Migration(migrations.Migration): 'verbose_name_plural': 'Notifications', }, managers=[ - ('objects', django.contrib.sites.managers.CurrentSiteManager()), + ('objects', aleksis.core.managers.AlekSISBaseManager()), ], ), migrations.CreateModel( @@ -226,7 +226,7 @@ class Migration(migrations.Migration): 'verbose_name_plural': 'Group types', }, managers=[ - ('objects', django.contrib.sites.managers.CurrentSiteManager()), + ('objects', aleksis.core.managers.AlekSISBaseManager()), ], ), migrations.CreateModel( @@ -295,7 +295,7 @@ class Migration(migrations.Migration): 'verbose_name_plural': 'Custom menu items', }, managers=[ - ('objects', django.contrib.sites.managers.CurrentSiteManager()), + ('objects', aleksis.core.managers.AlekSISBaseManager()), ], ), migrations.CreateModel( @@ -313,7 +313,7 @@ class Migration(migrations.Migration): 'verbose_name_plural': 'Announcement recipients', }, managers=[ - ('objects', django.contrib.sites.managers.CurrentSiteManager()), + ('objects', aleksis.core.managers.AlekSISBaseManager()), ], ), migrations.CreateModel( @@ -332,7 +332,7 @@ class Migration(migrations.Migration): 'verbose_name_plural': 'Activities', }, managers=[ - ('objects', django.contrib.sites.managers.CurrentSiteManager()), + ('objects', aleksis.core.managers.AlekSISBaseManager()), ], ), ] diff --git a/aleksis/core/migrations/0007_dashboard_widget_order.py b/aleksis/core/migrations/0007_dashboard_widget_order.py index 78c1c8bcb372a8fd9abfa6bd578e88c70e61d01f..1089112baad1430d0a12ae7c61fdf6f95f8d0164 100644 --- a/aleksis/core/migrations/0007_dashboard_widget_order.py +++ b/aleksis/core/migrations/0007_dashboard_widget_order.py @@ -1,6 +1,6 @@ # Generated by Django 3.1.4 on 2020-12-21 13:38 -import django.contrib.sites.managers +import aleksis.core.managers from django.db import migrations, models import django.db.models.deletion import django.utils.timezone diff --git a/aleksis/core/migrations/0008_data_check_result.py b/aleksis/core/migrations/0008_data_check_result.py index df2d51119513f8033da981c7cd12c6f9a93425b9..79fda72f47d6115cd609ea3a66e819ba3aedf3bd 100644 --- a/aleksis/core/migrations/0008_data_check_result.py +++ b/aleksis/core/migrations/0008_data_check_result.py @@ -1,6 +1,6 @@ # Generated by Django 3.1.3 on 2020-11-14 16:11 -import django.contrib.sites.managers +import aleksis.core.managers from django.db import migrations, models import django.db.models.deletion import django.utils.timezone @@ -58,6 +58,6 @@ class Migration(migrations.Migration): "verbose_name": "Data check result", "verbose_name_plural": "Data check results", }, - managers=[("objects", django.contrib.sites.managers.CurrentSiteManager()),], + managers=[("objects", aleksis.core.managers.AlekSISBaseManager()),], ), ] diff --git a/aleksis/core/migrations/0013_pdf_file.py b/aleksis/core/migrations/0013_pdf_file.py index 4ae06cffe58f3d90798c05e67b6ffd8843f14fef..2a2853eba761df25862b879d45f42416b586d847 100644 --- a/aleksis/core/migrations/0013_pdf_file.py +++ b/aleksis/core/migrations/0013_pdf_file.py @@ -1,7 +1,7 @@ # Generated by Django 3.2 on 2021-04-10 18:58 import aleksis.core.models -import django.contrib.sites.managers +import aleksis.core.managers from django.db import migrations, models import django.db.models.deletion import django.utils.timezone @@ -40,7 +40,7 @@ class Migration(migrations.Migration): 'verbose_name_plural': 'PDF files', }, managers=[ - ('objects', django.contrib.sites.managers.CurrentSiteManager()), + ('objects', aleksis.core.managers.AlekSISBaseManager()), ], ), ] diff --git a/aleksis/core/migrations/0016_taskuserassignment.py b/aleksis/core/migrations/0016_taskuserassignment.py index 59328cf6b702ddba0e37d121f5f5ce264c04d657..e413e3f0e6d965d2e56bb8f071786be56f0342e2 100644 --- a/aleksis/core/migrations/0016_taskuserassignment.py +++ b/aleksis/core/migrations/0016_taskuserassignment.py @@ -1,7 +1,7 @@ # Generated by Django 3.2 on 2021-05-09 10:55 from django.conf import settings -import django.contrib.sites.managers +import aleksis.core.managers from django.db import migrations, models import django.db.models.deletion @@ -30,7 +30,7 @@ class Migration(migrations.Migration): 'verbose_name_plural': 'Task user assignments', }, managers=[ - ('objects', django.contrib.sites.managers.CurrentSiteManager()), + ('objects', aleksis.core.managers.AlekSISBaseManager()), ], ), ] diff --git a/aleksis/core/migrations/0047_add_room_model.py b/aleksis/core/migrations/0047_add_room_model.py index 36464dd97e3757ca97d22c7c7957fbfa141f75b8..d0f5482601245e4fa04032a3ed948b180968b64f 100644 --- a/aleksis/core/migrations/0047_add_room_model.py +++ b/aleksis/core/migrations/0047_add_room_model.py @@ -1,7 +1,7 @@ # Generated by Django 3.2.15 on 2022-11-20 14:20 from django.apps import apps -import django.contrib.sites.managers +import aleksis.core.managers from django.db import migrations, models from django.db.utils import ProgrammingError import django.db.models.deletion @@ -48,7 +48,7 @@ class Migration(migrations.Migration): 'permissions': (('view_room_timetable', 'Can view room timetable'),), }, managers=[ - ('objects', django.contrib.sites.managers.CurrentSiteManager()), + ('objects', aleksis.core.managers.AlekSISBaseManager()), ], ), migrations.AddConstraint( diff --git a/aleksis/core/migrations/0049_oauthapplication_post_logout_redirect_uris.py b/aleksis/core/migrations/0049_oauthapplication_post_logout_redirect_uris.py new file mode 100644 index 0000000000000000000000000000000000000000..a14526b2f0c32077e873c9b3bcd0cb21ee9e0cd6 --- /dev/null +++ b/aleksis/core/migrations/0049_oauthapplication_post_logout_redirect_uris.py @@ -0,0 +1,21 @@ +# Generated by Django 4.1.9 on 2023-06-17 10:59 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("sites", "0002_alter_domain_unique"), + ("core", "0048_delete_personalicalurl"), + ] + + operations = [ + migrations.AddField( + model_name="oauthapplication", + name="post_logout_redirect_uris", + field=models.TextField( + blank=True, help_text="Allowed Post Logout URIs list, space separated" + ), + ), + ] diff --git a/aleksis/core/migrations/0050_managed_by_app_label.py b/aleksis/core/migrations/0050_managed_by_app_label.py new file mode 100644 index 0000000000000000000000000000000000000000..0de24ef5fe734d99c9c606b8b7b76a4167348190 --- /dev/null +++ b/aleksis/core/migrations/0050_managed_by_app_label.py @@ -0,0 +1,184 @@ +# Generated by Django 4.1.9 on 2023-07-06 21:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("sites", "0002_alter_domain_unique"), + ("core", "0049_oauthapplication_post_logout_redirect_uris"), + ] + + operations = [ + migrations.AddField( + model_name="activity", + name="managed_by_app_label", + field=models.CharField( + blank=True, + editable=False, + max_length=255, + verbose_name="App label of app responsible for managing this instance", + ), + ), + migrations.AddField( + model_name="additionalfield", + name="managed_by_app_label", + field=models.CharField( + blank=True, + editable=False, + max_length=255, + verbose_name="App label of app responsible for managing this instance", + ), + ), + migrations.AddField( + model_name="announcement", + name="managed_by_app_label", + field=models.CharField( + blank=True, + editable=False, + max_length=255, + verbose_name="App label of app responsible for managing this instance", + ), + ), + migrations.AddField( + model_name="announcementrecipient", + name="managed_by_app_label", + field=models.CharField( + blank=True, + editable=False, + max_length=255, + verbose_name="App label of app responsible for managing this instance", + ), + ), + migrations.AddField( + model_name="custommenu", + name="managed_by_app_label", + field=models.CharField( + blank=True, + editable=False, + max_length=255, + verbose_name="App label of app responsible for managing this instance", + ), + ), + migrations.AddField( + model_name="custommenuitem", + name="managed_by_app_label", + field=models.CharField( + blank=True, + editable=False, + max_length=255, + verbose_name="App label of app responsible for managing this instance", + ), + ), + migrations.AddField( + model_name="dashboardwidgetorder", + name="managed_by_app_label", + field=models.CharField( + blank=True, + editable=False, + max_length=255, + verbose_name="App label of app responsible for managing this instance", + ), + ), + migrations.AddField( + model_name="datacheckresult", + name="managed_by_app_label", + field=models.CharField( + blank=True, + editable=False, + max_length=255, + verbose_name="App label of app responsible for managing this instance", + ), + ), + migrations.AddField( + model_name="group", + name="managed_by_app_label", + field=models.CharField( + blank=True, + editable=False, + max_length=255, + verbose_name="App label of app responsible for managing this instance", + ), + ), + migrations.AddField( + model_name="grouptype", + name="managed_by_app_label", + field=models.CharField( + blank=True, + editable=False, + max_length=255, + verbose_name="App label of app responsible for managing this instance", + ), + ), + migrations.AddField( + model_name="notification", + name="managed_by_app_label", + field=models.CharField( + blank=True, + editable=False, + max_length=255, + verbose_name="App label of app responsible for managing this instance", + ), + ), + migrations.AddField( + model_name="pdffile", + name="managed_by_app_label", + field=models.CharField( + blank=True, + editable=False, + max_length=255, + verbose_name="App label of app responsible for managing this instance", + ), + ), + migrations.AddField( + model_name="person", + name="managed_by_app_label", + field=models.CharField( + blank=True, + editable=False, + max_length=255, + verbose_name="App label of app responsible for managing this instance", + ), + ), + migrations.AddField( + model_name="persongroupthrough", + name="managed_by_app_label", + field=models.CharField( + blank=True, + editable=False, + max_length=255, + verbose_name="App label of app responsible for managing this instance", + ), + ), + migrations.AddField( + model_name="room", + name="managed_by_app_label", + field=models.CharField( + blank=True, + editable=False, + max_length=255, + verbose_name="App label of app responsible for managing this instance", + ), + ), + migrations.AddField( + model_name="schoolterm", + name="managed_by_app_label", + field=models.CharField( + blank=True, + editable=False, + max_length=255, + verbose_name="App label of app responsible for managing this instance", + ), + ), + migrations.AddField( + model_name="taskuserassignment", + name="managed_by_app_label", + field=models.CharField( + blank=True, + editable=False, + max_length=255, + verbose_name="App label of app responsible for managing this instance", + ), + ), + ] diff --git a/aleksis/core/mixins.py b/aleksis/core/mixins.py index d1599b6b33b553dabcb668e41e00896ba352523c..dca6ba89ef488f3e32d9345badc6be238752e80d 100644 --- a/aleksis/core/mixins.py +++ b/aleksis/core/mixins.py @@ -30,13 +30,14 @@ from guardian.admin import GuardedModelAdmin from guardian.core import ObjectPermissionChecker from icalendar import Calendar from jsonstore.fields import IntegerField, JSONFieldMixin -from material.base import Layout, LayoutNode +from material.base import Fieldset, Layout, LayoutNode from polymorphic.base import PolymorphicModelBase from polymorphic.managers import PolymorphicManager from polymorphic.models import PolymorphicModel from rules.contrib.admin import ObjectPermissionsModelAdmin from aleksis.core.managers import ( + AlekSISBaseManager, CurrentSiteManagerWithoutMigrations, PolymorphicCurrentSiteManager, SchoolTermRelatedQuerySet, @@ -137,8 +138,16 @@ class ExtensibleModel(models.Model, metaclass=_ExtensibleModelBase): site = models.ForeignKey( Site, on_delete=models.CASCADE, default=settings.SITE_ID, editable=False, related_name="+" ) - objects = CurrentSiteManager() - objects_all_sites = models.Manager() + objects = AlekSISBaseManager() + # FIXME this is now broken, remove sites framework + objects_all = models.Manager() + + managed_by_app_label = models.CharField( + max_length=255, + verbose_name="App label of app responsible for managing this instance", + editable=False, + blank=True, + ) extra_permissions = [] @@ -386,7 +395,7 @@ class ExtensiblePolymorphicModel( """Model class for extensible, polymorphic models.""" objects = PolymorphicCurrentSiteManager() - objects_all_sites = PolymorphicManager() + objects_all = PolymorphicManager() class Meta: abstract = True @@ -454,6 +463,20 @@ class ExtensibleForm(ModelForm, metaclass=_ExtensibleFormMetaclass): cls.base_layout.append(node) cls.layout = Layout(*cls.base_layout) + visit_nodes = [node] + while visit_nodes: + current_node = visit_nodes.pop() + if isinstance(current_node, Fieldset): + visit_nodes += node.elements + else: + field_name = ( + current_node if isinstance(current_node, str) else current_node.field_name + ) + field = fields_for_model(cls._meta.model, [field_name])[field_name] + cls._meta.fields.append(field_name) + cls.base_fields[field_name] = field + setattr(cls, field_name, field) + class BaseModelAdmin(GuardedModelAdmin, ObjectPermissionsModelAdmin): """A base class for ModelAdmin combining django-guardian and rules.""" diff --git a/aleksis/core/schema/__init__.py b/aleksis/core/schema/__init__.py index 249f5fd7e5382923d9ec6915c3ca666b74208eef..604e0d6cbb4b1f5c08c244c42ddc42b3e4084bbb 100644 --- a/aleksis/core/schema/__init__.py +++ b/aleksis/core/schema/__init__.py @@ -12,26 +12,42 @@ from haystack.utils.loading import UnifiedIndex from ..models import ( CustomMenu, DynamicRoute, + Group, Notification, OAuthAccessToken, PDFFile, Person, + Room, TaskUserAssignment, ) from ..util.apps import AppConfig from ..util.core_helpers import get_allowed_object_ids, get_app_module, get_app_packages, has_person +from .base import FilterOrderList from .calendar import CalendarBaseType, SetCalendarStatusMutation from .celery_progress import CeleryProgressFetchedMutation, CeleryProgressType from .custom_menu import CustomMenuType from .dynamic_routes import DynamicRouteType -from .group import GroupType # noqa +from .group import GroupType from .installed_apps import AppType from .message import MessageType from .notification import MarkNotificationReadMutation, NotificationType from .oauth import OAuthAccessTokenType, OAuthRevokeTokenMutation from .pdf import PDFFileType from .person import PersonMutation, PersonType -from .school_term import SchoolTermType # noqa +from .room import ( + RoomBatchDeleteMutation, + RoomBatchPatchMutation, + RoomCreateMutation, + RoomDeleteMutation, + RoomType, +) +from .school_term import ( + SchoolTermBatchDeleteMutation, + SchoolTermBatchPatchMutation, + SchoolTermCreateMutation, + SchoolTermDeleteMutation, + SchoolTermType, +) from .search import SearchResultType from .system_properties import SystemPropertiesType from .two_factor import TwoFactorType @@ -47,6 +63,8 @@ class Query(graphene.ObjectType): person_by_id = graphene.Field(PersonType, id=graphene.ID()) person_by_id_or_me = graphene.Field(PersonType, id=graphene.ID()) + groups = graphene.List(GroupType) + who_am_i = graphene.Field(UserType) system_properties = graphene.Field(SystemPropertiesType) @@ -71,6 +89,11 @@ class Query(graphene.ObjectType): oauth_access_tokens = graphene.List(OAuthAccessTokenType) + rooms = FilterOrderList(RoomType) + room_by_id = graphene.Field(RoomType, id=graphene.ID()) + + school_terms = FilterOrderList(SchoolTermType) + calendar = graphene.Field(CalendarBaseType) def resolve_ping(root, info, payload) -> str: @@ -105,6 +128,10 @@ class Query(graphene.ObjectType): raise PermissionDenied() return person + @staticmethod + def resolve_groups(root, info, **kwargs): + return get_objects_for_user(info.context.user, "core.view_group", Group) + def resolve_who_am_i(root, info, **kwargs): return info.context.user @@ -175,7 +202,18 @@ class Query(graphene.ObjectType): def resolve_oauth_access_tokens(root, info, **kwargs): return OAuthAccessToken.objects.filter(user=info.context.user) - def resolve_calendar(self, info, **kwargs): + @staticmethod + def resolve_room_by_id(root, info, **kwargs): + pk = kwargs.get("id") + room_object = Room.objects.get(pk=pk) + + if not info.context.user.has_perm("core.view_room_rule", room_object): + raise PermissionDenied + + return room_object + + @staticmethod + def resolve_calendar(root, info, **kwargs): return True @@ -188,6 +226,16 @@ class Mutation(graphene.ObjectType): revoke_oauth_token = OAuthRevokeTokenMutation.Field() + create_room = RoomCreateMutation.Field() + delete_room = RoomDeleteMutation.Field() + delete_rooms = RoomBatchDeleteMutation.Field() + update_rooms = RoomBatchPatchMutation.Field() + + create_school_term = SchoolTermCreateMutation.Field() + delete_school_term = SchoolTermDeleteMutation.Field() + delete_school_terms = SchoolTermBatchDeleteMutation.Field() + update_school_terms = SchoolTermBatchPatchMutation.Field() + set_calendar_status = SetCalendarStatusMutation.Field() diff --git a/aleksis/core/schema/base.py b/aleksis/core/schema/base.py index 36d4f0647bd25d56b58e14fc4b737a3893e20372..198217379e257e427aa1332ae7bce7734d139c18 100644 --- a/aleksis/core/schema/base.py +++ b/aleksis/core/schema/base.py @@ -1,8 +1,12 @@ +import json + +from django.contrib.contenttypes.models import ContentType from django.core.exceptions import PermissionDenied from django.db.models import Model import graphene -from graphene_django import DjangoObjectType +from django_filters.filterset import FilterSet, filterset_factory +from graphene_django import DjangoListField, DjangoObjectType from ..util.core_helpers import queryset_rules_filter @@ -47,3 +51,151 @@ class DeleteMutation(graphene.Mutation): return cls(ok=True) else: raise PermissionDenied() + + +class PermissionsTypeMixin: + """Mixin for adding permissions to a Graphene type. + + To configure the names for the permissions or to do + different permission checking, override the respective + methods `resolve_can_edit` and `resolve_can_delete` + """ + + can_edit = graphene.Boolean() + can_delete = graphene.Boolean() + + @staticmethod + def resolve_can_edit(root: Model, info, **kwargs): + content_type = ContentType.objects.get_for_model(root) + perm = f"{content_type.app_label}.edit_{content_type.model}_rule" + return info.context.user.has_perm(perm, root) + + @staticmethod + def resolve_can_delete(root: Model, info, **kwargs): + content_type = ContentType.objects.get_for_model(root) + perm = f"{content_type.app_label}.delete_{content_type.model}_rule" + return info.context.user.has_perm(perm, root) + + +class PermissionBatchPatchMixin: + class Meta: + login_required = True + + @classmethod + def check_permissions(cls, root, info, input): # noqa + if info.context.user.has_perms(cls._meta.permissions, root): + return + + raise PermissionDenied() + + +class PermissionBatchDeleteMixin: + class Meta: + login_required = True + + @classmethod + def check_permissions(cls, root, info, input): # noqa + if info.context.user.has_perms(cls._meta.permissions, root): + return + + raise PermissionDenied() + + +class PermissionPatchMixin: + class Meta: + login_required = True + + @classmethod + def check_permissions(cls, root, info, input, id, obj): # noqa + if info.context.user.has_perms(cls._meta.permissions, root): + return + + raise PermissionDenied() + + +class DjangoFilterMixin: + """Filters a queryset with django filter.""" + + @classmethod + def get_filterset(cls): + meta = getattr(cls, "_meta", None) + + if not meta: + raise NotImplementedError(f"{cls.__name__} must implement class Meta for filtering.") + + if hasattr(meta, "filterset_class"): + filterset = getattr(meta, "filterset_class") + if filterset is not None: + return filterset + + model: Model = getattr(meta, "model") + fields = getattr(meta, "filter_fields", None) + + if not model: + raise NotImplementedError(f"{cls.__name__} must supply a model via the Meta class") + + if not fields: + # Django filter doesn't allow to filter without explicit fields + raise NotImplementedError( + f"{cls.__name__}.Meta must contain filter_fields or a filterset_class" + ) + + fs = filterset_factory(model=model, fields=fields) + + return fs + + @classmethod + def filter(cls, filters, queryset): # noqa + filterset_class = cls.get_filterset() + filterset: FilterSet = filterset_class(filters, queryset) + return filterset.qs + + +class FilterOrderList(DjangoListField): + """Generic filterable Field for lists of django models. + + After the models are filtered, they can be filtered again (e.g. + for permissions using the get_queryset method inside the + DjangoObjectType subclass. + """ + + def __init__(self, _type, *args, **kwargs): + kwargs.update(order_by=graphene.List(graphene.String)) + kwargs.update(filters=graphene.JSONString()) + super().__init__(_type, *args, **kwargs) + + @staticmethod + def list_resolver( + django_object_type, + resolver, + default_manager, + root, + info, + order_by=None, + filters=None, + **args, + ): + qs = DjangoListField.list_resolver( + django_object_type, resolver, default_manager, root, info, **args + ) + + if filters is not None: + if isinstance(filters, str): + filters = json.loads(filters) + + if isinstance(filters, dict) and len(filters.keys()) > 0: + for f_key, f_value in filters.items(): + if isinstance(f_value, list): + filters[f_key] = ",".join(map(str, f_value)) + + qs = django_object_type.filter(filters, qs) + + if order_by is not None: + if isinstance(order_by, str): + order_by = [order_by] + + qs = qs.order_by(*order_by) + + print(f"{filters=}") + + return qs diff --git a/aleksis/core/schema/celery_progress.py b/aleksis/core/schema/celery_progress.py index 5502b3317c47841c7ef02e9e5165db9ef37fe338..d03a8081d9560d0b5a4da6707c80f963fde6358d 100644 --- a/aleksis/core/schema/celery_progress.py +++ b/aleksis/core/schema/celery_progress.py @@ -88,7 +88,7 @@ class CeleryProgressFetchedMutation(graphene.Mutation): celery_progress = graphene.Field(CeleryProgressType) def mutate(root, info, task_id, **kwargs): - task = TaskUserAssignment.objects.filter(task_result__task_id=task_id) + task = TaskUserAssignment.objects.get(task_result__task_id=task_id) if not info.context.user.has_perm("core.view_progress_rule", task): return None diff --git a/aleksis/core/schema/person.py b/aleksis/core/schema/person.py index 6eed201487348034b2d9b1cba616046b1794dbf2..90165cceb0fb33676e17aad5caf978ce7c124be5 100644 --- a/aleksis/core/schema/person.py +++ b/aleksis/core/schema/person.py @@ -55,14 +55,26 @@ class PersonType(DjangoObjectType): full_name = graphene.String() username = graphene.String() userid = graphene.ID() - photo = graphene.Field(FieldFileType) - avatar = graphene.Field(FieldFileType) + photo = graphene.Field(FieldFileType, required=False) + avatar = graphene.Field(FieldFileType, required=False) avatar_url = graphene.String() avatar_content_url = graphene.String() - secondary_image_url = graphene.String() + secondary_image_url = graphene.String(required=False) + + street = graphene.String(required=False) + housenumber = graphene.String(required=False) + postal_code = graphene.String(required=False) + place = graphene.String(required=False) + + phone_number = graphene.String(required=False) + mobile_number = graphene.String(required=False) + email = graphene.String(required=False) + + date_of_birth = graphene.String(required=False) + place_of_birth = graphene.String(required=False) notifications = graphene.List(NotificationType) - unread_notifications_count = graphene.Int() + unread_notifications_count = graphene.Int(required=False) is_dummy = graphene.Boolean() preferences = graphene.Field(PersonPreferencesType) @@ -150,7 +162,11 @@ class PersonType(DjangoObjectType): return root.user.id if root.user else None def resolve_unread_notifications_count(root, info, **kwargs): # noqa - return root.unread_notifications_count + if root.pk and has_person(info.context) and root == info.context.user.person: + return root.unread_notifications_count + elif root.pk: + return 0 + return None def resolve_photo(root, info, **kwargs): if info.context.user.has_perm("core.view_photo_rule", root): @@ -199,11 +215,11 @@ class PersonType(DjangoObjectType): return root.is_dummy if hasattr(root, "is_dummy") else False def resolve_notifications(root: Person, info, **kwargs): - if has_person(info.context.user) and info.context.user.person == root: + if root.pk and has_person(info.context) and root == info.context.user.person: return root.notifications.filter(send_at__lte=timezone.now()).order_by( "read", "-created" ) - raise PermissionDenied() + return [] def resolve_can_edit_person(root, info, **kwargs): # noqa return info.context.user.has_perm("core.edit_person_rule", root) diff --git a/aleksis/core/schema/room.py b/aleksis/core/schema/room.py index d4f76700793d2f7eededaac1b0361ebeff1a6a5a..575130971671cfa79743b985918b3ad1621da53f 100644 --- a/aleksis/core/schema/room.py +++ b/aleksis/core/schema/room.py @@ -1,9 +1,53 @@ from graphene_django import DjangoObjectType +from graphene_django_cud.mutations import ( + DjangoBatchDeleteMutation, + DjangoBatchPatchMutation, + DjangoCreateMutation, +) from ..models import Room +from .base import ( + DeleteMutation, + DjangoFilterMixin, + PermissionBatchDeleteMixin, + PermissionBatchPatchMixin, + PermissionsTypeMixin, +) -class RoomType(DjangoObjectType): +class RoomType(PermissionsTypeMixin, DjangoFilterMixin, DjangoObjectType): class Meta: model = Room fields = ("id", "name", "short_name") + filter_fields = { + "id": ["exact", "lte", "gte"], + "name": ["icontains"], + "short_name": ["icontains"], + } + + @classmethod + def get_queryset(cls, queryset, info): + return queryset # FIXME filter this queryset based on permissions + + +class RoomCreateMutation(DjangoCreateMutation): + class Meta: + model = Room + permissions = ("core.create_room",) + + +class RoomDeleteMutation(DeleteMutation): + klass = Room + permission_required = "core.delete_room" + + +class RoomBatchDeleteMutation(PermissionBatchDeleteMixin, DjangoBatchDeleteMutation): + class Meta: + model = Room + permissions = ("core.delete_room",) + + +class RoomBatchPatchMutation(PermissionBatchPatchMixin, DjangoBatchPatchMutation): + class Meta: + model = Room + permissions = ("core.change_room",) diff --git a/aleksis/core/schema/school_term.py b/aleksis/core/schema/school_term.py index 1f6e504bc280689c9011a63da7d0af3b5ec53a06..798d1c62eb960d2c850a918345cbc03252a88820 100644 --- a/aleksis/core/schema/school_term.py +++ b/aleksis/core/schema/school_term.py @@ -1,8 +1,71 @@ +from django.core.exceptions import PermissionDenied, ValidationError +from django.utils.translation import gettext as _ + from graphene_django import DjangoObjectType +from graphene_django_cud.mutations import ( + DjangoBatchDeleteMutation, + DjangoBatchPatchMutation, + DjangoCreateMutation, +) from ..models import SchoolTerm +from .base import ( + DeleteMutation, + DjangoFilterMixin, + PermissionBatchDeleteMixin, + PermissionBatchPatchMixin, + PermissionsTypeMixin, +) + + +class SchoolTermType(PermissionsTypeMixin, DjangoFilterMixin, DjangoObjectType): + class Meta: + model = SchoolTerm + filter_fields = { + "name": ["icontains", "exact"], + "date_start": ["exact", "lt", "lte", "gt", "gte"], + "date_end": ["exact", "lt", "lte", "gt", "gte"], + } + + @classmethod + def get_queryset(cls, queryset, info, **kwargs): + if not info.context.user.has_perm("view_schoolterm_rule"): + raise PermissionDenied + + return queryset # FIXME filter this queryset based on permissions + + +class SchoolTermCreateMutation(DjangoCreateMutation): + class Meta: + model = SchoolTerm + permissions = ("core.create_school_term",) # FIXME + + @classmethod + def validate(cls, root, info, input): # noqa + date_start = input.get("date_start") + date_end = input.get("date_end") + if date_end < date_start: + raise ValidationError(_("The start date must be earlier than the end date.")) + + qs = SchoolTerm.objects.within_dates(date_start, date_end) + if qs.exists(): + raise ValidationError( + _("There is already a school term for this time or a part of this time.") + ) + + +class SchoolTermDeleteMutation(DeleteMutation): + klass = SchoolTerm + permission_required = "core.delete_school_term" # FIXME + + +class SchoolTermBatchDeleteMutation(PermissionBatchDeleteMixin, DjangoBatchDeleteMutation): + class Meta: + model = SchoolTerm + permissions = ("core.delete_school_term",) -class SchoolTermType(DjangoObjectType): +class SchoolTermBatchPatchMutation(PermissionBatchPatchMixin, DjangoBatchPatchMutation): class Meta: model = SchoolTerm + permissions = ("core.change_school_term",) # FIXME diff --git a/aleksis/core/settings.py b/aleksis/core/settings.py index e8bf327490dde0917b69851f796defdf60ded7c1..59c5c30efef72708a5081b7d354a1b7a6ce9330b 100644 --- a/aleksis/core/settings.py +++ b/aleksis/core/settings.py @@ -936,12 +936,19 @@ LOGGING["root"] = { "handlers": ["console"], "level": _settings.get("logging.level", "WARNING"), } +# Configure global log Format +LOGGING["formatters"]["verbose"] = { + "format": "{asctime} {levelname} {name}[{process}]: {msg}", + "style": "{", +} # Add null handler for selective silencing LOGGING["handlers"]["null"] = {"class": "logging.NullHandler"} # Make console logging independent of DEBUG LOGGING["handlers"]["console"]["filters"].remove("require_debug_true") # Use root log level for console del LOGGING["handlers"]["console"]["level"] +# Use verbose log format for console +LOGGING["handlers"]["console"]["formatter"] = "verbose" # Disable exception mails if not desired if not _settings.get("logging.mail_admins", True): LOGGING["loggers"]["django"]["handlers"].remove("mail_admins") @@ -957,6 +964,9 @@ LOGGING["loggers"]["celery"] = { "level": _settings.get("logging.level", "WARNING"), "propagate": False, } +# Set Django log levels +LOGGING["loggers"]["django"]["level"] = _settings.get("logging.level", "WARNING") +LOGGING["loggers"]["django.server"]["level"] = _settings.get("logging.level", "WARNING") # Rules and permissions @@ -1041,6 +1051,9 @@ else: DEFAULT_FILE_STORAGE = "titofisto.TitofistoStorage" TITOFISTO_TIMEOUT = 10 * 60 +TITOFISTO_ENABLE_UPLOAD = True +TITOFISTO_UPLOAD_NAMESPACE = "__titofisto__/upload/" + SASS_PROCESSOR_STORAGE = DEFAULT_FILE_STORAGE SENTRY_ENABLED = _settings.get("health.sentry.enabled", False) diff --git a/aleksis/core/tables.py b/aleksis/core/tables.py index 5411cad4747bc8c5de011fcdd2abcde432842236..8963981bb22412b51abca69120b90dc3b0e18d72 100644 --- a/aleksis/core/tables.py +++ b/aleksis/core/tables.py @@ -10,24 +10,6 @@ from .models import Person from .util.core_helpers import get_site_preferences -class SchoolTermTable(tables.Table): - """Table to list persons.""" - - class Meta: - attrs = {"class": "highlight"} - - name = tables.LinkColumn("edit_school_term", args=[A("id")]) - date_start = tables.Column() - date_end = tables.Column() - edit = tables.LinkColumn( - "edit_school_term", - args=[A("id")], - text=_("Edit"), - attrs={"a": {"class": "btn-flat waves-effect waves-orange orange-text"}}, - verbose_name=_("Actions"), - ) - - class PersonsTable(tables.Table): """Table to list persons.""" diff --git a/aleksis/core/templates/core/base_simple_print.html b/aleksis/core/templates/core/base_simple_print.html index 6e66e28983679acebdd2df0993ae7b5fdf64e621..abb01712a5f52d4ec9a44e34a31b11b8a29f9ef1 100644 --- a/aleksis/core/templates/core/base_simple_print.html +++ b/aleksis/core/templates/core/base_simple_print.html @@ -4,6 +4,8 @@ <!DOCTYPE html> <html lang="{{ LANGUAGE_CODE }}"> <head> + <base href="{{ BASE_URL }}" /> + {% include "core/partials/meta.html" %} <title> diff --git a/aleksis/core/templates/core/school_term/create.html b/aleksis/core/templates/core/school_term/create.html deleted file mode 100644 index a3e049112caeaf84095dde68c7fd8d7a32f75602..0000000000000000000000000000000000000000 --- a/aleksis/core/templates/core/school_term/create.html +++ /dev/null @@ -1,17 +0,0 @@ -{# -*- engine:django -*- #} - -{% extends "core/base.html" %} -{% load material_form i18n %} - -{% block browser_title %}{% blocktrans %}Create school term{% endblocktrans %}{% endblock %} -{% block page_title %}{% blocktrans %}Create school term{% endblocktrans %}{% endblock %} - -{% block content %} - - <form method="post"> - {% csrf_token %} - {% form form=form %}{% endform %} - {% include "core/partials/save_button.html" %} - </form> - -{% endblock %} diff --git a/aleksis/core/templates/core/school_term/edit.html b/aleksis/core/templates/core/school_term/edit.html deleted file mode 100644 index aa1b1dcf5015e876d0b9aa316d25da31673f0a3f..0000000000000000000000000000000000000000 --- a/aleksis/core/templates/core/school_term/edit.html +++ /dev/null @@ -1,17 +0,0 @@ -{# -*- engine:django -*- #} - -{% extends "core/base.html" %} -{% load material_form i18n %} - -{% block browser_title %}{% blocktrans %}Edit school term{% endblocktrans %}{% endblock %} -{% block page_title %}{% blocktrans %}Edit school term{% endblocktrans %}{% endblock %} - -{% block content %} - - <form method="post"> - {% csrf_token %} - {% form form=form %}{% endform %} - {% include "core/partials/save_button.html" %} - </form> - -{% endblock %} diff --git a/aleksis/core/templates/core/school_term/list.html b/aleksis/core/templates/core/school_term/list.html deleted file mode 100644 index 9df6af9727b868e13d43a75c42730b375ff6aa47..0000000000000000000000000000000000000000 --- a/aleksis/core/templates/core/school_term/list.html +++ /dev/null @@ -1,18 +0,0 @@ -{# -*- engine:django -*- #} - -{% extends "core/base.html" %} - -{% load i18n %} -{% load render_table from django_tables2 %} - -{% block browser_title %}{% blocktrans %}School terms{% endblocktrans %}{% endblock %} -{% block page_title %}{% blocktrans %}School terms{% endblocktrans %}{% endblock %} - -{% block content %} - <a class="btn green waves-effect waves-light" href="{% url 'create_school_term' %}"> - <i class="material-icons left iconify" data-icon="mdi:add"></i> - {% trans "Create school term" %} - </a> - - {% render_table table %} -{% endblock %} diff --git a/aleksis/core/urls.py b/aleksis/core/urls.py index d959ad27a46ab662d9c63fddfa5bfd11fc4884b1..774e17ce5d0f221bed8fbc85b1c06a7d78511e8a 100644 --- a/aleksis/core/urls.py +++ b/aleksis/core/urls.py @@ -96,17 +96,6 @@ urlpatterns = [ views.TwoFactorSetupView.as_view(), name="setup_two_factor_auth", ), - path("school_terms/", views.SchoolTermListView.as_view(), name="school_terms"), - path( - "school_terms/create/", - views.SchoolTermCreateView.as_view(), - name="create_school_term", - ), - path( - "school_terms/<int:pk>/", - views.SchoolTermEditView.as_view(), - name="edit_school_term", - ), path("persons/", views.persons, name="persons"), path( "person/", TemplateView.as_view(template_name="core/empty.html"), name="person" diff --git a/aleksis/core/util/apps.py b/aleksis/core/util/apps.py index 0889280858a64a61cd8857a0c6eef4f872012cfc..26f0752dc352cf0793ad7a9c7d70f95e015dd457 100644 --- a/aleksis/core/util/apps.py +++ b/aleksis/core/util/apps.py @@ -1,3 +1,4 @@ +import logging from importlib import metadata from typing import TYPE_CHECKING, Any, Optional, Sequence @@ -26,8 +27,11 @@ class AppConfig(django.apps.AppConfig): def __init_subclass__(cls): super().__init_subclass__() cls.default = True + cls._logger = logging.getLogger(f"{cls.__module__}.{cls.__name__}") def ready(self): + self._logger.debug("Running app.ready") + super().ready() # Register default listeners @@ -36,9 +40,12 @@ class AppConfig(django.apps.AppConfig): preference_updated.connect(self.preference_updated) user_logged_in.connect(self.user_logged_in) user_logged_out.connect(self.user_logged_out) + self._logger.debug("Default signal handlers connected") # Getting an app ready means it should look at its config once + self._logger.debug("Force-loading preferences") self.preference_updated(self) + self._logger.debug("Preferences loaded") def get_distribution_name(self): """Get distribution name of application package.""" @@ -87,6 +94,10 @@ class AppConfig(django.apps.AppConfig): # Get string representation of licence in SPDX format licence = getattr(cls, "licence", None) + default_flags = { + "isFsfLibre": False, + "isOsiApproved": False, + } default_dict = { "isDeprecatedLicenseId": False, "isFsfLibre": False, @@ -131,7 +142,7 @@ class AppConfig(django.apps.AppConfig): return (readable, flags, licence_dicts) else: # We could not find a valid licence - return ("Unknown", [default_dict]) + return ("Unknown", default_flags, [default_dict]) @classmethod def get_licence_dict(cls): @@ -278,6 +289,8 @@ class AppConfig(django.apps.AppConfig): return {} def _maintain_default_data(self): + self._logger.debug("Maintaining default data for %s", self.get_name()) + from django.contrib.auth.models import Permission from django.contrib.contenttypes.models import ContentType @@ -288,10 +301,19 @@ class AppConfig(django.apps.AppConfig): for model in self.get_models(): if hasattr(model, "maintain_default_data"): # Method implemented by each model object; can be left out + self._logger.info( + "Maintaining default data of %s in %s", model._meta.model_name, self.get_name() + ) model.maintain_default_data() if hasattr(model, "extra_permissions"): + self._logger.info( + "Maintaining extra permissions for %s in %s", + model._meta.model_name, + self.get_name(), + ) ct = ContentType.objects.get_for_model(model) for perm, verbose_name in model.extra_permissions: + self._logger.debug("Creating %s (%s)", perm, verbose_name) Permission.objects.get_or_create( codename=perm, content_type=ct, diff --git a/aleksis/core/util/frontend_helpers.py b/aleksis/core/util/frontend_helpers.py index 5d343acc0533a2509077c0f0d15ca0bc8d1823f9..885ac39fd51833c55ce072cc8a425991cfea7db2 100644 --- a/aleksis/core/util/frontend_helpers.py +++ b/aleksis/core/util/frontend_helpers.py @@ -49,7 +49,7 @@ def write_vite_values(out_path: str) -> dict[str, Any]: json.dump(vite_values, out) -def run_vite(args: Optional[Sequence[str]] = None) -> None: +def run_vite(args: Optional[Sequence[str]] = None) -> int: args = list(args) if args else [] config_path = os.path.join(settings.BASE_DIR, "aleksis", "core", "vite.config.js") @@ -64,7 +64,7 @@ def run_vite(args: Optional[Sequence[str]] = None) -> None: log_level = {"INFO": "info", "WARNING": "warn", "ERROR": "error"}.get(log_level, "silent") args += ["-l", log_level] - yarn_adapter.call_yarn(["run", "vite"] + args) + return yarn_adapter.call_yarn(["run", "vite"] + args) def get_language_cookie(code: str) -> str: diff --git a/aleksis/core/views.py b/aleksis/core/views.py index 9d71ff52378b605b45be8bf686e378986d29b382..fa2404922d8a287c863151a6290718210a1c9bf6 100644 --- a/aleksis/core/views.py +++ b/aleksis/core/views.py @@ -93,7 +93,6 @@ from .forms import ( OAuthApplicationForm, PersonForm, PersonPreferenceForm, - SchoolTermForm, SelectPermissionForm, SitePreferenceForm, ) @@ -115,7 +114,6 @@ from .models import ( OAuthApplication, Person, PersonInvitation, - SchoolTerm, ) from .registries import ( group_preferences_registry, @@ -132,7 +130,6 @@ from .tables import ( GroupTypesTable, InvitationsTable, PersonsTable, - SchoolTermTable, UserGlobalPermissionTable, UserObjectPermissionTable, ) @@ -269,40 +266,6 @@ def index(request: HttpRequest) -> HttpResponse: return render(request, "core/index.html", context) -@method_decorator(pwa_cache, name="dispatch") -class SchoolTermListView(PermissionRequiredMixin, SingleTableView): - """Table of all school terms.""" - - model = SchoolTerm - table_class = SchoolTermTable - permission_required = "core.view_schoolterm_rule" - template_name = "core/school_term/list.html" - - -@method_decorator(never_cache, name="dispatch") -class SchoolTermCreateView(PermissionRequiredMixin, AdvancedCreateView): - """Create view for school terms.""" - - model = SchoolTerm - form_class = SchoolTermForm - permission_required = "core.add_schoolterm_rule" - template_name = "core/school_term/create.html" - success_url = reverse_lazy("school_terms") - success_message = _("The school term has been created.") - - -@method_decorator(never_cache, name="dispatch") -class SchoolTermEditView(PermissionRequiredMixin, AdvancedEditView): - """Edit view for school terms.""" - - model = SchoolTerm - form_class = SchoolTermForm - permission_required = "core.edit_schoolterm" - template_name = "core/school_term/edit.html" - success_url = reverse_lazy("school_terms") - success_message = _("The school term has been saved.") - - @pwa_cache @permission_required("core.view_persons_rule") def persons(request: HttpRequest) -> HttpResponse: diff --git a/aleksis/core/vite.config.js b/aleksis/core/vite.config.js index bc408ab344bd9d1d22da7b562a97c02a07fdf91e..0d341f37bba0b64b50d9281e7d3fe2f93540256c 100644 --- a/aleksis/core/vite.config.js +++ b/aleksis/core/vite.config.js @@ -258,7 +258,9 @@ export default defineConfig({ navigateFallback: "/", directoryIndex: null, navigateFallbackAllowlist: [ - new RegExp("^/(?!(django|admin|graphql|__icons__))[^.]*$"), + new RegExp( + "^/(?!(django|admin|graphql|__icons__|oauth/authorize))[^.]*$" + ), ], additionalManifestEntries: [ { url: "/", revision: crypto.randomUUID() }, @@ -272,7 +274,7 @@ export default defineConfig({ runtimeCaching: [ { urlPattern: new RegExp( - "^/(?!(django|admin|graphql|__icons__))[^.]*$" + "^/(?!(django|admin|graphql|__icons__|oauth/authorize))[^.]*$" ), handler: "CacheFirst", }, diff --git a/docs/admin/10_install.rst b/docs/admin/10_install.rst index 03ffc97b1e73b28561f2283338773672e4c41a73..b829cd2c92710efa6050f068319ef052e9b4138e 100644 --- a/docs/admin/10_install.rst +++ b/docs/admin/10_install.rst @@ -147,7 +147,7 @@ After that, you can install the aleksis meta-package, or only `aleksis-core`: .. code-block:: shell pip3 install --break-system-packages aleksis aleksis-admin vite build - aleksis-admin collectstatic + aleksis-admin collectstatic --clear aleksis-admin migrate aleksis-admin createinitialrevisions diff --git a/docs/conf.py b/docs/conf.py index 3287243f133810dce0b9a34d65d4c9fb2913a89f..4f14591b931a77ed87e0e2cc7af56b48805e820b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -25,13 +25,13 @@ django.setup() # -- Project information ----------------------------------------------------- project = "AlekSIS-Core" -copyright = "2019-2022 The AlekSIS team" +copyright = "2019-2023 The AlekSIS team" author = "The AlekSIS Team" # The short X.Y version -version = "3.0" +version = "4.0" # The full version, including alpha/beta/rc tags -release = "3.0.1.dev0" +release = "4.0.0.dev0" # -- General configuration --------------------------------------------------- diff --git a/docs/dev/01_setup.rst b/docs/dev/01_setup.rst index f7f56f811bcd7b0bfca48fbf9793a026ff2a44bf..c98eb81925a370ad1e40ee8f000a70a8330892cc 100644 --- a/docs/dev/01_setup.rst +++ b/docs/dev/01_setup.rst @@ -7,7 +7,8 @@ by reading its documentation. Poetry makes a lot of stuff very easy, especially managing a virtual environment that contains AlekSIS and everything you need to run the -framework and selected apps. +framework and selected apps. The minimum supported version of Poetry +is 1.2.0. Also, `Yarn`_ is needed to resolve JavaScript dependencies. @@ -91,7 +92,7 @@ All three steps can be done with the ``poetry shell`` command and ALEKSIS_maintenance__debug=true ALEKSIS_database__password=aleksis poetry shell poetry run aleksis-admin vite build - poetry run aleksis-admin collectstatic + poetry run aleksis-admin collectstatic --clear poetry run aleksis-admin compilemessages poetry run aleksis-admin migrate poetry run aleksis-admin createinitialrevisions diff --git a/pyproject.toml b/pyproject.toml index ffc7814b10c1d681a80e8df46a7e2b5ddaf6a144..9a66194ff91686a879f19c6eb2352032ccf704a7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "AlekSIS-Core" -version = "3.0.1.dev0" +version = "4.0.0.dev0" packages = [ { include = "aleksis" } ] @@ -28,12 +28,9 @@ authors = [ "Benedict Suska <benedict.suska@teckids.de>", "Lukas Weichelt <lukas.weichelt@teckids.de>" ] -maintainers = [ - "Jonathan Weth <dev@jonathanweth.de>", - "Dominik George <dominik.george@teckids.org>" -] +maintainers = ["Jonathan Weth <dev@jonathanweth.de>", "Dominik George <dominik.george@teckids.org>"] license = "EUPL-1.2-or-later" -homepage = "https://aleksis.org/" +homepage = "https://aleksis.org" repository = "https://edugit.org/AlekSIS/official/AlekSIS-Core" documentation = "https://aleksis.org/AlekSIS-Core/docs/html/" keywords = ["SIS", "education", "school", "digitisation", "school apps"] @@ -49,11 +46,14 @@ classifiers = [ "Typing :: Typed", ] +[[tool.poetry.source]] +name = "PyPI" +priority = "primary" + [[tool.poetry.source]] name = "gitlab" url = "https://edugit.org/api/v4/projects/461/packages/pypi/simple" -secondary = true - +priority = "supplemental" [tool.poetry.dependencies] python = "^3.9" Django = "^4.1" @@ -86,7 +86,7 @@ django-celery-beat = "^2.2.0" django-celery-email = "^3.0.0" django-jsonstore = "^0.5.0" django-polymorphic = "^3.0.0" -django-colorfield = "^0.8.0" +django-colorfield = "^0.9.0" django-bleach = "^3.0.0" django-guardian = "^2.2.0" rules = "^3.0" @@ -113,10 +113,10 @@ ipython = "^8.0.0" django-oauth-toolkit = "^2.0.0" django-storages = {version = "^1.13.2", optional = true} boto3 = {version = "^1.26.142", optional = true} -django-cleanup = "^7.0.0" +django-cleanup = "^8.0.0" djangorestframework = "^3.12.4" Whoosh = "^2.7.4" -django-titofisto = "^0.2.0" +django-titofisto = "^1.0.0" haystack-redis = "^0.0.1" python-gnupg = "^0.5.0" sentry-sdk = {version = "^1.4.3", optional = true} @@ -124,9 +124,11 @@ django-cte = "^1.1.5" pycountry = "^22.0.0" django-iconify = "^0.3" customidenticon = "^0.1.5" -graphene-django = "^3.0.0" +graphene-django = ">=3.0.0, <=3.1.2" selenium = "^4.4.3" django-vite = "^2.0.2" +graphene-django-cud = "^0.10.0" +uwsgi = "^2.0.21" django-ical = "^1.8.3" django-recurrence = "^1.11.1" recurring-ical-events = "^2.0.2" @@ -137,17 +139,54 @@ ldap = ["django-auth-ldap"] s3 = ["boto3", "django-storages"] sentry = ["sentry-sdk"] -[tool.poetry.dev-dependencies] -aleksis-builddeps = {version=">=2023.1.dev0", allow-prereleases=true} -uwsgi = "^2.0" - [tool.poetry.scripts] aleksis-admin = 'aleksis.core.__main__:aleksis_cmd' +[tool.poetry.group.dev.dependencies] +django-stubs = "^4.2" + +safety = "^2.3.5" + +flake8 = "^6.0.0" +flake8-django = "^1.0.0" +flake8-fixme = "^1.1.1" +flake8-mypy = "^17.8.0" +flake8-bandit = "^4.1.1" +flake8-builtins = "^2.0.0" +flake8-docstrings = "^1.5.0" +flake8-rst-docstrings = "^0.3.0" + +black = ">=21.0" +flake8-black = "^0.3.0" + +isort = "^5.0.0" +flake8-isort = "^6.0.0" + +curlylint = "^0.13.0" + +[tool.poetry.group.test.dependencies] +pytest = "^7.2" +pytest-django = "^4.1" +pytest-django-testing-postgresql = "^0.2" +pytest-cov = "^4.0.0" +pytest-sugar = "^0.9.2" +selenium = "<4.10.0" +freezegun = "^1.1.0" + +[tool.poetry.group.docs] +optional = true + +[tool.poetry.group.docs.dependencies] +sphinx = "^7.0" +sphinxcontrib-django = "^2.3.0" +sphinxcontrib-svg2pdfconverter = "^1.1.1" +sphinx-autodoc-typehints = "^1.7" +sphinx_material = "^0.0.35" + [tool.black] line-length = 100 exclude = "/migrations/" [build-system] -requires = ["poetry-core"] +requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api"