From 30a9414180df87de89f54e7271a43e9c7edba43f Mon Sep 17 00:00:00 2001 From: Antoine Nguyen Date: Fri, 7 Feb 2025 17:27:57 +0100 Subject: [PATCH] Added calendars plugin and improved used part --- dev-requirements.txt | 4 + docker-compose.yml | 13 + docker/Dockerfile.dev | 2 +- docker/Dockerfile.radicale.dev | 14 + docker/radicale_config/config | 159 +++++ docker/radicale_config/rights | 135 ++++ frontend/package.json | 5 + frontend/src/api/calendars.js | 58 ++ frontend/src/api/contacts.js | 9 + frontend/src/api/mailboxes.js | 9 + frontend/src/api/parameters.js | 29 +- .../admin/domains/DomainCreationForm.vue | 2 +- .../admin/domains/DomainEditForm.vue | 2 +- .../admin/identities/AccountEditForm.vue | 2 +- .../calendars/CalendarAccessRulesForm.vue | 150 +++++ .../components/calendars/CalendarDetail.vue | 50 ++ .../src/components/calendars/CalendarForm.vue | 111 ++++ .../src/components/calendars/EventForm.vue | 186 ++++++ .../components/calendars/ImportEventsForm.vue | 94 +++ .../components/contacts/AddressBookInfo.vue | 40 ++ .../src/components/contacts/CategoryForm.vue | 14 +- .../src/components/contacts/ContactList.vue | 75 ++- .../components/contacts/PhoneNumberField.vue | 2 + frontend/src/components/shared/NavBar.vue | 24 +- frontend/src/components/shared/TopMenu.vue | 2 +- .../src/components/tools/ParametersForm.vue | 187 ++++++ .../src/layouts/account/AccountLayout.vue | 37 +- frontend/src/layouts/admin/AdminLayout.vue | 4 +- .../src/layouts/connected/ConnectedView.vue | 6 +- frontend/src/plugins/index.js | 2 + frontend/src/plugins/permissions.js | 32 + frontend/src/plugins/rules.js | 6 +- frontend/src/router/index.js | 24 + frontend/src/views/account/ParametersView.vue | 55 ++ frontend/src/views/admin/ParametersView.vue | 159 +---- .../views/admin/identities/AccountView.vue | 2 +- frontend/src/views/calendars/CalendarView.vue | 314 ++++++++++ frontend/yarn.lock | 34 + ldap-requirements.txt | 4 + modoboa/admin/api/v2/serializers.py | 20 +- modoboa/calendars/__init__.py | 1 + modoboa/calendars/app_settings.py | 92 +++ modoboa/calendars/apps.py | 16 + modoboa/calendars/backends/__init__.py | 41 ++ modoboa/calendars/backends/caldav_.py | 163 +++++ modoboa/calendars/factories.py | 34 + modoboa/calendars/handlers.py | 79 +++ .../calendars/locale/br/LC_MESSAGES/django.po | 98 +++ .../locale/cs_CZ/LC_MESSAGES/django.po | 175 ++++++ .../calendars/locale/de/LC_MESSAGES/django.po | 179 ++++++ .../calendars/locale/en/LC_MESSAGES/django.po | 96 +++ .../calendars/locale/es/LC_MESSAGES/django.po | 100 +++ .../calendars/locale/fr/LC_MESSAGES/django.po | 98 +++ .../calendars/locale/it/LC_MESSAGES/django.po | 99 +++ .../locale/nl_NL/LC_MESSAGES/django.po | 177 ++++++ .../locale/pt_BR/LC_MESSAGES/django.po | 180 ++++++ .../locale/pt_PT/LC_MESSAGES/django.po | 183 ++++++ .../locale/ro_RO/LC_MESSAGES/django.po | 107 ++++ .../calendars/locale/ru/LC_MESSAGES/django.po | 146 +++++ .../calendars/locale/sv/LC_MESSAGES/django.po | 178 ++++++ .../locale/zh_TW/LC_MESSAGES/django.po | 101 +++ modoboa/calendars/management/__init__.py | 0 .../calendars/management/commands/__init__.py | 0 .../management/commands/generate_rights.py | 147 +++++ modoboa/calendars/migrations/0001_initial.py | 103 ++++ .../migrations/0002_auto_20170831_1721.py | 25 + .../migrations/0003_auto_20180324_1834.py | 46 ++ .../migrations/0004_auto_20200220_1816.py | 49 ++ .../migrations/0005_auto_20200220_1819.py | 23 + modoboa/calendars/migrations/__init__.py | 0 modoboa/calendars/mocks.py | 89 +++ modoboa/calendars/models.py | 167 +++++ modoboa/calendars/serializers.py | 244 ++++++++ modoboa/calendars/test_data/events.ics | 42 ++ modoboa/calendars/tests.py | 583 ++++++++++++++++++ modoboa/calendars/urls.py | 29 + modoboa/calendars/viewsets.py | 273 ++++++++ modoboa/contacts/app_settings.py | 54 ++ modoboa/contacts/apps.py | 10 +- modoboa/contacts/forms.py | 59 -- modoboa/contacts/handlers.py | 47 -- modoboa/contacts/importer/__init__.py | 5 +- .../contacts/importer/backends/__init__.py | 4 +- .../management/commands/import_contacts.py | 4 +- modoboa/contacts/models.py | 2 +- modoboa/contacts/serializers.py | 28 +- modoboa/contacts/settings.py | 28 - modoboa/contacts/tasks.py | 4 +- modoboa/contacts/tests.py | 34 +- modoboa/core/api/v2/viewsets.py | 21 +- .../core/commands/templates/settings.py.tpl | 2 + modoboa/parameters/api/v2/urls.py | 7 +- modoboa/parameters/api/v2/viewsets.py | 58 +- modoboa/parameters/tools.py | 67 +- modoboa/urls_api_v2.py | 1 + mysql-requirements.txt | 4 + postgresql-requirements.txt | 4 + pylintrc | 4 +- pyproject.toml | 1 + requirements.txt | 4 + test-requirements.txt | 4 + test_project/test_project/settings.py | 1 + 102 files changed, 6274 insertions(+), 423 deletions(-) create mode 100644 docker/Dockerfile.radicale.dev create mode 100644 docker/radicale_config/config create mode 100644 docker/radicale_config/rights create mode 100644 frontend/src/api/calendars.js create mode 100644 frontend/src/api/mailboxes.js create mode 100644 frontend/src/components/calendars/CalendarAccessRulesForm.vue create mode 100644 frontend/src/components/calendars/CalendarDetail.vue create mode 100644 frontend/src/components/calendars/CalendarForm.vue create mode 100644 frontend/src/components/calendars/EventForm.vue create mode 100644 frontend/src/components/calendars/ImportEventsForm.vue create mode 100644 frontend/src/components/contacts/AddressBookInfo.vue create mode 100644 frontend/src/components/tools/ParametersForm.vue create mode 100644 frontend/src/plugins/permissions.js create mode 100644 frontend/src/views/account/ParametersView.vue create mode 100644 frontend/src/views/calendars/CalendarView.vue create mode 100644 modoboa/calendars/__init__.py create mode 100644 modoboa/calendars/app_settings.py create mode 100644 modoboa/calendars/apps.py create mode 100644 modoboa/calendars/backends/__init__.py create mode 100644 modoboa/calendars/backends/caldav_.py create mode 100644 modoboa/calendars/factories.py create mode 100644 modoboa/calendars/handlers.py create mode 100644 modoboa/calendars/locale/br/LC_MESSAGES/django.po create mode 100644 modoboa/calendars/locale/cs_CZ/LC_MESSAGES/django.po create mode 100644 modoboa/calendars/locale/de/LC_MESSAGES/django.po create mode 100644 modoboa/calendars/locale/en/LC_MESSAGES/django.po create mode 100644 modoboa/calendars/locale/es/LC_MESSAGES/django.po create mode 100644 modoboa/calendars/locale/fr/LC_MESSAGES/django.po create mode 100644 modoboa/calendars/locale/it/LC_MESSAGES/django.po create mode 100644 modoboa/calendars/locale/nl_NL/LC_MESSAGES/django.po create mode 100644 modoboa/calendars/locale/pt_BR/LC_MESSAGES/django.po create mode 100644 modoboa/calendars/locale/pt_PT/LC_MESSAGES/django.po create mode 100644 modoboa/calendars/locale/ro_RO/LC_MESSAGES/django.po create mode 100644 modoboa/calendars/locale/ru/LC_MESSAGES/django.po create mode 100644 modoboa/calendars/locale/sv/LC_MESSAGES/django.po create mode 100644 modoboa/calendars/locale/zh_TW/LC_MESSAGES/django.po create mode 100644 modoboa/calendars/management/__init__.py create mode 100644 modoboa/calendars/management/commands/__init__.py create mode 100644 modoboa/calendars/management/commands/generate_rights.py create mode 100644 modoboa/calendars/migrations/0001_initial.py create mode 100644 modoboa/calendars/migrations/0002_auto_20170831_1721.py create mode 100644 modoboa/calendars/migrations/0003_auto_20180324_1834.py create mode 100644 modoboa/calendars/migrations/0004_auto_20200220_1816.py create mode 100644 modoboa/calendars/migrations/0005_auto_20200220_1819.py create mode 100644 modoboa/calendars/migrations/__init__.py create mode 100644 modoboa/calendars/mocks.py create mode 100644 modoboa/calendars/models.py create mode 100644 modoboa/calendars/serializers.py create mode 100644 modoboa/calendars/test_data/events.ics create mode 100644 modoboa/calendars/tests.py create mode 100644 modoboa/calendars/urls.py create mode 100644 modoboa/calendars/viewsets.py create mode 100644 modoboa/contacts/app_settings.py delete mode 100644 modoboa/contacts/forms.py delete mode 100644 modoboa/contacts/settings.py diff --git a/dev-requirements.txt b/dev-requirements.txt index ef340c24b..107f79360 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -74,6 +74,7 @@ django==4.2.18 # django-rq # django-xforwardedfor-middleware # djangorestframework + # drf-nested-routers # drf-spectacular # modoboa (pyproject.toml) django-bower==5.2.0 @@ -108,10 +109,13 @@ django-xforwardedfor-middleware==2.0 # via modoboa (pyproject.toml) djangorestframework==3.15.2 # via + # drf-nested-routers # drf-spectacular # modoboa (pyproject.toml) dnspython==2.7.0 # via modoboa (pyproject.toml) +drf-nested-routers==0.94.1 + # via modoboa (pyproject.toml) drf-spectacular==0.28.0 # via modoboa (pyproject.toml) feedparser==6.0.11 diff --git a/docker-compose.yml b/docker-compose.yml index f4a85ad2a..b37488bee 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -41,6 +41,8 @@ services: python3 manage.py load_initial_data --dev django-admin compilemessages python3 manage.py runserver_plus 0.0.0.0:8000 --cert-file cert.crt" + depends_on: + - radicale volumes: - .:/code @@ -63,6 +65,8 @@ services: command: > sh -c "cd test_project python3 manage.py runserver_plus 0.0.0.0:8001" + depends_on: + - radicale volumes: - .:/code @@ -92,3 +96,12 @@ services: - api volumes: - ./docker/dovecot_config:/etc/dovecot/conf.d + + radicale: + container_name: modoboa_radicale + restart: unless-stopped + build: + context: . + dockerfile: docker/Dockerfile.radicale.dev + volumes: + - ./docker/radicale_config:/etc/radicale diff --git a/docker/Dockerfile.dev b/docker/Dockerfile.dev index 9d71df25f..f77e10fb0 100644 --- a/docker/Dockerfile.dev +++ b/docker/Dockerfile.dev @@ -16,7 +16,7 @@ COPY requirements.txt /tmp COPY test-requirements.txt /tmp COPY dev-requirements.txt /tmp RUN python -m pip install -U pip -RUN pip install -r requirements.txt -r user-requirements.txt -r test-requirements.txt -r dev-requirements.txt +RUN pip install -r requirements.txt -r test-requirements.txt -r dev-requirements.txt FROM base AS run COPY docker/doveadm /usr/local/bin RUN chmod +x /usr/local/bin/doveadm diff --git a/docker/Dockerfile.radicale.dev b/docker/Dockerfile.radicale.dev new file mode 100644 index 000000000..36aaf5567 --- /dev/null +++ b/docker/Dockerfile.radicale.dev @@ -0,0 +1,14 @@ +FROM python:3.12-alpine3.20 + +LABEL org.opencontainers.image.authors="Antoine Nguyen " + +WORKDIR /app + +COPY radicale_modoboa_oauth2 /app/radicale_modoboa_oauth2 +ENV PYTHONPATH="${PYTHONPATH}:/app" + +RUN pip install Radicale pytz requests + +EXPOSE 5232 + +CMD python3 -m radicale diff --git a/docker/radicale_config/config b/docker/radicale_config/config new file mode 100644 index 000000000..e2322e62f --- /dev/null +++ b/docker/radicale_config/config @@ -0,0 +1,159 @@ +# -*- mode: conf -*- +# vim:ft=cfg + +# Config file for Radicale - A simple calendar server +# +# Place it into /etc/radicale/config (global) +# or ~/.config/radicale/config (user) +# +# The current values are the default ones + + +[server] + +# CalDAV server hostnames separated by a comma +# IPv4 syntax: address:port +# IPv6 syntax: [address]:port +# For example: 0.0.0.0:9999, [::]:9999 +hosts = 0.0.0.0:5232 + +# Daemon flag +#daemon = False + +# File storing the PID in daemon mode +#pid = + +# Max parallel connections +#max_connections = 20 + +# Max size of request body (bytes) +#max_content_length = 10000000 + +# Socket timeout (seconds) +#timeout = 10 + +# SSL flag, enable HTTPS protocol +#ssl = False + +# SSL certificate path +#certificate = /etc/ssl/radicale.cert.pem + +# SSL private key +#key = /etc/ssl/radicale.key.pem + +# CA certificate for validating clients. This can be used to secure +# TCP traffic between Radicale and a reverse proxy +#certificate_authority = + +# SSL Protocol used. See python's ssl module for available values +#protocol = PROTOCOL_TLSv1_2 + +# Available ciphers. See python's ssl module for available ciphers +#ciphers = + +# Reverse DNS to resolve client address in logs +#dns_lookup = True + +# Message displayed in the client when a password is needed +#realm = Radicale - Password Required + + +[encoding] + +# Encoding for responding requests +#request = utf-8 + +# Encoding for storing local collections +#stock = utf-8 + + +[auth] + +# Authentication method +# Value: none | htpasswd | remote_user | http_x_remote_user +type = radicale_modoboa_oauth2 + +# Htpasswd filename +# htpasswd_filename = users + +# Htpasswd encryption method +# Value: plain | sha1 | ssha | crypt | bcrypt | md5 +# Only bcrypt can be considered secure. +# bcrypt and md5 require the passlib library to be installed. +# htpasswd_encryption = plain + +# Incorrect authentication delay (seconds) +#delay = 1 + +oauth2_introspection_endpoint = http://dovecot:Toto12345@api-unsecured:8001/api/o/introspect/ + + +[rights] + +# Rights backend +# Value: none | authenticated | owner_only | owner_write | from_file +type = from_file + +# File for rights management from_file +file = /etc/radicale/rights + + +[storage] + +# Storage backend +# Value: multifilesystem +# type = +# radicale_storage_by_index_fields = dtstart, dtend, uid, summary + +# Folder for storing local collections, created if not present +filesystem_folder = /collections + +# Lock the storage. Never start multiple instances of Radicale or edit the +# storage externally while Radicale is running if disabled. +#filesystem_locking = True + +# Sync all changes to disk during requests. (This can impair performance.) +# Disabling it increases the risk of data loss, when the system crashes or +# power fails! +#filesystem_fsync = True + +# Delete sync token that are older (seconds) +#max_sync_token_age = 2592000 + +# Close the lock file when no more clients are waiting. +# This option is not very useful in general, but on Windows files that are +# opened cannot be deleted. +#filesystem_close_lock_file = False + +# Command that is run after changes to storage +# Example: ([ -d .git ] || git init) && git add -A && (git diff --cached --quiet || git commit -m "Changes by "%%(user)s) +#hook = + + +[web] + +# Web interface backend +# Value: none | internal +type = none + + +[logging] + +# Logging configuration file +# If no config is given, simple information is printed on the standard output +# For more information about the syntax of the configuration file, see: +# http://docs.python.org/library/logging.config.html +#config = /etc/radicale/logging + +# Store all environment variables (including those set in the shell) +#full_environment = False + +# Don't include passwords in logs +#mask_passwords = True + +level = debug + +[headers] + +# Additional HTTP headers +#Access-Control-Allow-Origin = * diff --git a/docker/radicale_config/rights b/docker/radicale_config/rights new file mode 100644 index 000000000..2eb3979c7 --- /dev/null +++ b/docker/radicale_config/rights @@ -0,0 +1,135 @@ +# -*- mode: conf -*- +# vim:ft=cfg +# Allow all rights for the Administrator +#[root] +#user: Administrator +#collection: .* +#permissions: RW + +# Allow reading principal collection (same as username) +#[principal] +#user: .+ +#collection: {user} +#permissions: R + +# Allow reading and writing private collection (same as username) +#[private] +#user: .+ +#collection: {user}/private/ +#permissions: RW + +# Allow reading calendars and address books that are direct +# children of the principal collection for other users +#[calendarsReader] +#user: .+ +#collection: {user}/[^/]+ +#permissions: r + +# Rights management file for Radicale - A simple calendar server +# +# The default path for this file is /etc/radicale/rights +# The path can be specified in the rights section of the configuration file +# +# Section names are used for naming rules and must be unique. +# The first rule matching both user and collection patterns will be used. + + +# Example: owner_only plugin + +# Allow reading root collection for authenticated users +#[root] +#user: .+ +#collection: +#permissions: R + +# Allow reading and writing principal collection (same as username) +[principal] +user: .+ +collection: {user} +permissions: RW + +# Allow reading and writing calendars and address books that are direct +# children of the principal collection +[calendars] +user: .+ +collection: {user}/[^/]+ +permissions: rw + + +# Example: owner_write plugin +# Only listed additional rules for the owner_only plugin example. + +# Allow reading principal collections of all users +#[read-all-principals] +#user: .+ +#collection: [^/]+ +#permissions: R + +# Allow reading all calendars and address books that are direct children of any +# principal collection +#[read-all-calendars] +#user: .+ +#collection: [^/]+/[^/]+ +#permissions: r + + +# Example: authenticated plugin + +# Allow reading and writing root and principal collections of all users +#[root-and-principals] +#user: .+ +#collection: [^/]* +#permissions: RW + +# Allow reading and writing all calendars and address books that are direct +# children of any principal collection +#[calendars] +#user: .+ +#collection: [^/]+/[^/]+ +#permissions: rw + + +# Example: Allow user "admin" to read everything +#[admin-read-all] +#user: admin +#collection: .* +#permissions: Rr + + +# Example: Allow everybody (including unauthenticated users) to read +# the collection "public" + +# Allow reading collection "public" for authenticated users +#[public-principal] +#user: .+ +#collection: public +#permissions: R + +# Allow reading all calendars and address books that are direct children of +# the collection "public" for authenticated users +#[public-calendars] +#user: .+ +#collection: public/[^/]+ +#permissions: r + +# Allow access to public calendars and address books via HTTP GET for everyone +#[public-calendars-restricted] +#user: .* +#collection: public/[^/]+ +#permissions: i + +# Example: Grant users of the form user@domain.tld read access to the +# collection "domain.tld" + +# Allow reading the domain collection +#[read-domain-principal] +#user: .+@([^@]+) +#collection: {0} +#permissions: R + +# Allow reading all calendars and address books that are direct children of +# the domain collection +#[read-domain-calendars] +#user: .+@([^@]+) +#collection: {0}/[^/]+ +#permissions: r diff --git a/frontend/package.json b/frontend/package.json index af54d4be1..db58a9f23 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,6 +15,11 @@ "gettext:compile": "vue-gettext-compile" }, "dependencies": { + "@fullcalendar/core": "^6.1.15", + "@fullcalendar/daygrid": "^6.1.15", + "@fullcalendar/interaction": "^6.1.15", + "@fullcalendar/timegrid": "^6.1.15", + "@fullcalendar/vue3": "^6.1.15", "@github/webauthn-json": "^2.1.1", "@mdi/font": "^7.3.67", "apexcharts": "^3.50.0", diff --git a/frontend/src/api/calendars.js b/frontend/src/api/calendars.js new file mode 100644 index 000000000..9143758b5 --- /dev/null +++ b/frontend/src/api/calendars.js @@ -0,0 +1,58 @@ +import repository from './repository' + +export default { + getUserCalendars() { + return repository.get('/user-calendars/') + }, + getUserCalendar(pk) { + return repository.get(`/user-calendars/${pk}/`) + }, + createUserCalendar(data) { + return repository.post('/user-calendars/', data) + }, + updateUserCalendar(pk, data) { + return repository.put(`/user-calendars/${pk}/`, data) + }, + deleteUserCalendar(pk) { + return repository.delete(`/user-calendars/${pk}/`) + }, + + getAccessRules(calendarPk) { + return repository.get('/accessrules/') + }, + createAccessRule(data) { + return repository.post('/accessrules/', data) + }, + updateAccessRule(ruleId, data) { + return repository.put(`/accessrules/${ruleId}/`, data) + }, + deleteAccessRule(ruleId) { + return repository.delete(`/accessrules/${ruleId}/`) + }, + + getUserCalendarEvents(calendarPk, params) { + return repository.get(`/user-calendars/${calendarPk}/events/`, { params }) + }, + getUserEvent(calendarPk, eventId) { + return repository.get(`/user-calendars/${calendarPk}/events/${eventId}/`) + }, + createUserEvent(calendarPk, data) { + return repository.post(`/user-calendars/${calendarPk}/events/`, data) + }, + patchUserEvent(calendarPk, eventId, data) { + return repository.patch( + `/user-calendars/${calendarPk}/events/${eventId}/`, + data + ) + }, + importUserEvents(calendarPk, data) { + return repository.post( + `/user-calendars/${calendarPk}/events/import_from_file/`, + data + ) + }, + + getAttendees() { + return repository.get('/attendees/') + }, +} diff --git a/frontend/src/api/contacts.js b/frontend/src/api/contacts.js index 304825f74..b28883e7d 100644 --- a/frontend/src/api/contacts.js +++ b/frontend/src/api/contacts.js @@ -1,6 +1,15 @@ import repository from './repository' export default { + getDefaultAddressBook() { + return repository.get('/address-books/default/') + }, + synchronizeToAddressBook() { + return repository.get('/address-books/sync_to_cdav/') + }, + synchronizeFromAddressBook() { + return repository.get('/address-books/sync_from_cdav/') + }, getContacts(params) { return repository.get('/contacts/', { params }) }, diff --git a/frontend/src/api/mailboxes.js b/frontend/src/api/mailboxes.js new file mode 100644 index 000000000..3db2e81b8 --- /dev/null +++ b/frontend/src/api/mailboxes.js @@ -0,0 +1,9 @@ +import repository from './repository' + +const resource = 'mailboxes' + +export default { + getAll() { + return repository.get(`${resource}/`) + }, +} diff --git a/frontend/src/api/parameters.js b/frontend/src/api/parameters.js index fb3140b17..805a1adf9 100644 --- a/frontend/src/api/parameters.js +++ b/frontend/src/api/parameters.js @@ -3,16 +3,29 @@ import repository from './repository' const resource = 'parameters' export default { - getApplications() { - return repository.get(`/${resource}/applications/`) + getGlobalApplications() { + return repository.get(`/${resource}/global/applications/`) }, - getApplication(app) { - return repository.get(`/${resource}/${app}/`) + getGlobalApplication(app) { + return repository.get(`/${resource}/global/${app}/`) }, - getApplicationStructure(app) { - return repository.get(`/${resource}/structure/?app=${app}`) + getGlobalApplicationStructure(app) { + return repository.get(`/${resource}/global/structure/?app=${app}`) }, - saveApplication(app, data) { - return repository.put(`/${resource}/${app}/`, data) + saveGlobalApplication(app, data) { + return repository.put(`/${resource}/global/${app}/`, data) + }, + + getUserApplications() { + return repository.get(`/${resource}/user/applications/`) + }, + getUserApplication(app) { + return repository.get(`/${resource}/user/${app}/`) + }, + getUserApplicationStructure(app) { + return repository.get(`/${resource}/user/structure/?app=${app}`) + }, + saveUserApplication(app, data) { + return repository.put(`/${resource}/user/${app}/`, data) }, } diff --git a/frontend/src/components/admin/domains/DomainCreationForm.vue b/frontend/src/components/admin/domains/DomainCreationForm.vue index a21943c4b..388c52c10 100644 --- a/frontend/src/components/admin/domains/DomainCreationForm.vue +++ b/frontend/src/components/admin/domains/DomainCreationForm.vue @@ -110,7 +110,7 @@ const domain = ref(defaultDomain) onMounted(() => { //TODO : Store this in Pinia - ParametersApi.getApplication('admin').then((resp) => { + ParametersApi.getGlobalApplication('admin').then((resp) => { const params = resp.data.params domain.value.quota = params.default_domain_quota domain.value.default_mailbox_quota = params.default_mailbox_quota diff --git a/frontend/src/components/admin/domains/DomainEditForm.vue b/frontend/src/components/admin/domains/DomainEditForm.vue index 6aa1e9f57..292f76a0f 100644 --- a/frontend/src/components/admin/domains/DomainEditForm.vue +++ b/frontend/src/components/admin/domains/DomainEditForm.vue @@ -261,7 +261,7 @@ async function save() { } } onMounted(() => { - parametersApi.getApplication('limits').then((resp) => { + parametersApi.getGlobalApplication('limits').then((resp) => { limitsConfig.value.data = resp.data }) }) diff --git a/frontend/src/components/admin/identities/AccountEditForm.vue b/frontend/src/components/admin/identities/AccountEditForm.vue index f45749486..029c7b9cd 100644 --- a/frontend/src/components/admin/identities/AccountEditForm.vue +++ b/frontend/src/components/admin/identities/AccountEditForm.vue @@ -316,7 +316,7 @@ onMounted(() => { .getAccount(route.params.id) .then((response) => (editedAccount.value = { ...response.data })) - parametersApi.getApplication('limits').then((response) => { + parametersApi.getGlobalApplication('limits').then((response) => { limitsConfig.value = response.data }) }) diff --git a/frontend/src/components/calendars/CalendarAccessRulesForm.vue b/frontend/src/components/calendars/CalendarAccessRulesForm.vue new file mode 100644 index 000000000..c35544d10 --- /dev/null +++ b/frontend/src/components/calendars/CalendarAccessRulesForm.vue @@ -0,0 +1,150 @@ + + + diff --git a/frontend/src/components/calendars/CalendarDetail.vue b/frontend/src/components/calendars/CalendarDetail.vue new file mode 100644 index 000000000..a3789435a --- /dev/null +++ b/frontend/src/components/calendars/CalendarDetail.vue @@ -0,0 +1,50 @@ + + + diff --git a/frontend/src/components/calendars/CalendarForm.vue b/frontend/src/components/calendars/CalendarForm.vue new file mode 100644 index 000000000..8a1d755b3 --- /dev/null +++ b/frontend/src/components/calendars/CalendarForm.vue @@ -0,0 +1,111 @@ + + + diff --git a/frontend/src/components/calendars/EventForm.vue b/frontend/src/components/calendars/EventForm.vue new file mode 100644 index 000000000..4334b3a1c --- /dev/null +++ b/frontend/src/components/calendars/EventForm.vue @@ -0,0 +1,186 @@ + + + diff --git a/frontend/src/components/calendars/ImportEventsForm.vue b/frontend/src/components/calendars/ImportEventsForm.vue new file mode 100644 index 000000000..18a3e71bb --- /dev/null +++ b/frontend/src/components/calendars/ImportEventsForm.vue @@ -0,0 +1,94 @@ + + + diff --git a/frontend/src/components/contacts/AddressBookInfo.vue b/frontend/src/components/contacts/AddressBookInfo.vue new file mode 100644 index 000000000..2f462718b --- /dev/null +++ b/frontend/src/components/contacts/AddressBookInfo.vue @@ -0,0 +1,40 @@ + + + diff --git a/frontend/src/components/contacts/CategoryForm.vue b/frontend/src/components/contacts/CategoryForm.vue index 41186a812..e1ccc6186 100644 --- a/frontend/src/components/contacts/CategoryForm.vue +++ b/frontend/src/components/contacts/CategoryForm.vue @@ -33,6 +33,7 @@ import { computed, ref, watch } from 'vue' import { useRouter } from 'vue-router' import { useGettext } from 'vue3-gettext' +import { useBusStore } from '@/stores' import contactsApi from '@/api/contacts' import rules from '@/plugins/rules' @@ -42,10 +43,11 @@ const props = defineProps({ default: null, }, }) -const emit = defineEmits(['close', 'added', 'updated']) +const emit = defineEmits(['close', 'updated']) const router = useRouter() const { $gettext } = useGettext() +const { displayNotification } = useBusStore() const form = ref({}) const formRef = ref() @@ -69,14 +71,12 @@ async function submit() { } working.value = true try { - let resp if (props.category) { - resp = await contactsApi.updateCategory(props.category.pk, form.value) - emit('updated', resp.data) + await contactsApi.updateCategory(props.category.pk, form.value) } else { - resp = await contactsApi.addCategory(form.value) - emit('added', resp.data) + await contactsApi.addCategory(form.value) } + emit('updated') emit('close') } catch (err) { console.log(err) @@ -88,6 +88,8 @@ async function submit() { function deleteCategory() { contactsApi.deleteCategory(props.category.pk).then(() => { close() + emit('updated') + displayNotification({ msg: $gettext('Category deleted') }) router.push({ name: 'ContactList' }) }) } diff --git a/frontend/src/components/contacts/ContactList.vue b/frontend/src/components/contacts/ContactList.vue index e33f75cd5..9ec7f9150 100644 --- a/frontend/src/components/contacts/ContactList.vue +++ b/frontend/src/components/contacts/ContactList.vue @@ -14,19 +14,24 @@ density="compact" class="flex-grow-0 w-33 mr-4" > - - - - - + + {{ $gettext('Add') }} + + + + + + + diff --git a/frontend/src/layouts/account/AccountLayout.vue b/frontend/src/layouts/account/AccountLayout.vue index 6568bccd5..86405bcd2 100644 --- a/frontend/src/layouts/account/AccountLayout.vue +++ b/frontend/src/layouts/account/AccountLayout.vue @@ -1,21 +1,32 @@ diff --git a/frontend/src/layouts/admin/AdminLayout.vue b/frontend/src/layouts/admin/AdminLayout.vue index 45ece8171..b4ea7248a 100644 --- a/frontend/src/layouts/admin/AdminLayout.vue +++ b/frontend/src/layouts/admin/AdminLayout.vue @@ -100,7 +100,7 @@ const adminMenuItems = [ onMounted(() => { if (authUser.value.role === 'SuperAdmins') { - parametersApi.getApplications().then((response) => { + parametersApi.getGlobalApplications().then((response) => { response.data.forEach((item) => { settings.push({ text: item.label, @@ -109,7 +109,7 @@ onMounted(() => { }) }) if (parametersStore.imapMigrationEnabled === null) { - parametersApi.getApplication('imap_migration').then((response) => { + parametersApi.getGlobalApplication('imap_migration').then((response) => { parametersStore.imapMigrationEnabled = response.data.params.enabled_imapmigration }) diff --git a/frontend/src/layouts/connected/ConnectedView.vue b/frontend/src/layouts/connected/ConnectedView.vue index 577ca6243..d765fd41d 100644 --- a/frontend/src/layouts/connected/ConnectedView.vue +++ b/frontend/src/layouts/connected/ConnectedView.vue @@ -1,11 +1,9 @@ - diff --git a/frontend/src/views/admin/identities/AccountView.vue b/frontend/src/views/admin/identities/AccountView.vue index 4e7b17701..fbf3ff169 100644 --- a/frontend/src/views/admin/identities/AccountView.vue +++ b/frontend/src/views/admin/identities/AccountView.vue @@ -62,7 +62,7 @@ const account = computed(() => { }) const limitsConfig = ref({}) -parametersApi.getApplication('limits').then((resp) => { +parametersApi.getGlobalApplication('limits').then((resp) => { limitsConfig.value = resp.data }) diff --git a/frontend/src/views/calendars/CalendarView.vue b/frontend/src/views/calendars/CalendarView.vue new file mode 100644 index 000000000..32a6ad201 --- /dev/null +++ b/frontend/src/views/calendars/CalendarView.vue @@ -0,0 +1,314 @@ + + + diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 571ed7b58..3803a7cf3 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -212,6 +212,35 @@ "@eslint/core" "^0.10.0" levn "^0.4.1" +"@fullcalendar/core@^6.1.15": + version "6.1.15" + resolved "https://registry.yarnpkg.com/@fullcalendar/core/-/core-6.1.15.tgz#6c3f5259fc4589870228853072131219bb533f6e" + integrity sha512-BuX7o6ALpLb84cMw1FCB9/cSgF4JbVO894cjJZ6kP74jzbUZNjtwffwRdA+Id8rrLjT30d/7TrkW90k4zbXB5Q== + dependencies: + preact "~10.12.1" + +"@fullcalendar/daygrid@^6.1.15", "@fullcalendar/daygrid@~6.1.15": + version "6.1.15" + resolved "https://registry.yarnpkg.com/@fullcalendar/daygrid/-/daygrid-6.1.15.tgz#91208b0955ba805ddad285a53ee6f53855146963" + integrity sha512-j8tL0HhfiVsdtOCLfzK2J0RtSkiad3BYYemwQKq512cx6btz6ZZ2RNc/hVnIxluuWFyvx5sXZwoeTJsFSFTEFA== + +"@fullcalendar/interaction@^6.1.15": + version "6.1.15" + resolved "https://registry.yarnpkg.com/@fullcalendar/interaction/-/interaction-6.1.15.tgz#1c685d5c269388d4877b75ab2185e97d7c386cc7" + integrity sha512-DOTSkofizM7QItjgu7W68TvKKvN9PSEEvDJceyMbQDvlXHa7pm/WAVtAc6xSDZ9xmB1QramYoWGLHkCYbTW1rQ== + +"@fullcalendar/timegrid@^6.1.15": + version "6.1.15" + resolved "https://registry.yarnpkg.com/@fullcalendar/timegrid/-/timegrid-6.1.15.tgz#c4630b7c03c813065154c6e3981f8d51d9d692e5" + integrity sha512-61ORr3A148RtxQ2FNG7JKvacyA/TEVZ7z6I+3E9Oeu3dqTf6M928bFcpehRTIK6zIA6Yifs7BeWHgOE9dFnpbw== + dependencies: + "@fullcalendar/daygrid" "~6.1.15" + +"@fullcalendar/vue3@^6.1.15": + version "6.1.15" + resolved "https://registry.yarnpkg.com/@fullcalendar/vue3/-/vue3-6.1.15.tgz#e0453c1aa76f09e0e83c80b8e5fc9a1de24a9d6b" + integrity sha512-ctfTICGrNEIj7gmLHQcUYe0WzDTSW5Vd9hyOnVChxPU75AZU9WqdDMkHwJYnfNxNhT6QQuiMHq/qsRRd5zQwOw== + "@github/webauthn-json@^2.1.1": version "2.1.1" resolved "https://registry.yarnpkg.com/@github/webauthn-json/-/webauthn-json-2.1.1.tgz#648e63fc28050917d2882cc2b27817a88cb420fc" @@ -1548,6 +1577,11 @@ postcss@^8.4.43, postcss@^8.4.48: picocolors "^1.1.1" source-map-js "^1.2.1" +preact@~10.12.1: + version "10.12.1" + resolved "https://registry.yarnpkg.com/preact/-/preact-10.12.1.tgz#8f9cb5442f560e532729b7d23d42fd1161354a21" + integrity sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg== + prelude-ls@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" diff --git a/ldap-requirements.txt b/ldap-requirements.txt index 7dfe61f7e..9a60a1124 100644 --- a/ldap-requirements.txt +++ b/ldap-requirements.txt @@ -58,6 +58,7 @@ django==4.2.18 # django-rq # django-xforwardedfor-middleware # djangorestframework + # drf-nested-routers # drf-spectacular # modoboa (pyproject.toml) django-auth-ldap==4.8.0 @@ -88,10 +89,13 @@ django-xforwardedfor-middleware==2.0 # via modoboa (pyproject.toml) djangorestframework==3.15.2 # via + # drf-nested-routers # drf-spectacular # modoboa (pyproject.toml) dnspython==2.7.0 # via modoboa (pyproject.toml) +drf-nested-routers==0.94.1 + # via modoboa (pyproject.toml) drf-spectacular==0.28.0 # via modoboa (pyproject.toml) feedparser==6.0.11 diff --git a/modoboa/admin/api/v2/serializers.py b/modoboa/admin/api/v2/serializers.py index c51e68086..b64860118 100644 --- a/modoboa/admin/api/v2/serializers.py +++ b/modoboa/admin/api/v2/serializers.py @@ -1,7 +1,6 @@ """Admin API v2 serializers.""" import ipaddress -import os from typing import List from django.conf import settings @@ -11,6 +10,7 @@ from django.shortcuts import get_object_or_404 from django.utils.translation import gettext as _ from django.contrib.auth import password_validation +from django.contrib.auth.models import Permission from rest_framework import serializers @@ -353,6 +353,24 @@ class AccountResourceSerializer(serializers.ModelSerializer): model = limits_models.UserObjectLimit +class AccountMeSerializer(v1_serializers.AccountSerializer): + """Custom Account serializer for connected user.""" + + permissions = serializers.SerializerMethodField() + + class Meta(v1_serializers.AccountSerializer.Meta): + fields = v1_serializers.AccountSerializer.Meta.fields + ("permissions",) + + def get_permissions(self, user) -> List[str]: + """Return available permissions for given user.""" + permissions = ( + user.user_permissions.all() | Permission.objects.filter(group__user=user) + ).select_related("content_type") + return [ + "{}.{}".format(p.content_type.app_label, p.codename) for p in permissions + ] + + class AccountSerializer(v1_serializers.AccountSerializer): """Add support for user resources.""" diff --git a/modoboa/calendars/__init__.py b/modoboa/calendars/__init__.py new file mode 100644 index 000000000..fc3b2c2ba --- /dev/null +++ b/modoboa/calendars/__init__.py @@ -0,0 +1 @@ +default_app_config = "modoboa.calendars.apps.CalendarsConfig" diff --git a/modoboa/calendars/app_settings.py b/modoboa/calendars/app_settings.py new file mode 100644 index 000000000..9c5cba2c2 --- /dev/null +++ b/modoboa/calendars/app_settings.py @@ -0,0 +1,92 @@ +import collections + +from django.utils.translation import gettext_lazy + + +GLOBAL_PARAMETERS_STRUCT = collections.OrderedDict( + [ + ( + "server", + { + "label": gettext_lazy("Server settings"), + "params": collections.OrderedDict( + [ + ( + "server_location", + { + "label": gettext_lazy("Server URL"), + "help_text": gettext_lazy( + "The URL of your Radicale server. " + "It will be used to construct calendar URLs." + ), + }, + ) + ] + ), + }, + ), + ( + "rights", + { + "label": gettext_lazy("Rights management"), + "params": collections.OrderedDict( + [ + ( + "rights_file_path", + { + "label": gettext_lazy("Rights file's path"), + "help_text": gettext_lazy( + "Path to the file that contains rights definition" + ), + }, + ), + ( + "allow_calendars_administration", + { + "label": gettext_lazy("Allow calendars administration"), + "help_text": gettext_lazy( + "Allow domain administrators to manage user calendars " + "(read and write)" + ), + }, + ), + ] + ), + }, + ), + ( + "misc", + { + "label": gettext_lazy("Miscellaneous"), + "params": collections.OrderedDict( + [ + ( + "max_ics_file_size", + { + "label": gettext_lazy("Maximum size of ICS files"), + "help_text": gettext_lazy( + "Maximum size in bytes of imported ICS files " + "(or KB, MB, GB if specified)" + ), + }, + ) + ] + ), + }, + ), + ] +) + + +def load_settings(): + """Load calendars settings.""" + from modoboa.parameters import tools as param_tools + from modoboa.calendars import serializers + + param_tools.registry.add2( + "global", + "calendars", + gettext_lazy("Calendars"), + GLOBAL_PARAMETERS_STRUCT, + serializers.GlobalParametersSerializer, + ) diff --git a/modoboa/calendars/apps.py b/modoboa/calendars/apps.py new file mode 100644 index 000000000..8b847e87e --- /dev/null +++ b/modoboa/calendars/apps.py @@ -0,0 +1,16 @@ +"""AppConfig for radicale.""" + +from django.apps import AppConfig + + +class CalendarsConfig(AppConfig): + """App configuration.""" + + name = "modoboa.calendars" + verbose_name = "Modoboa calendars" + + def ready(self): + from modoboa.calendars import handlers + from modoboa.calendars.app_settings import load_settings + + load_settings() diff --git a/modoboa/calendars/backends/__init__.py b/modoboa/calendars/backends/__init__.py new file mode 100644 index 000000000..8d463a517 --- /dev/null +++ b/modoboa/calendars/backends/__init__.py @@ -0,0 +1,41 @@ +"""Calendar backend definition.""" + +from importlib import import_module + +from modoboa.lib.cryptutils import decrypt + + +class CalendarBackend(object): + """Base backend class.""" + + def __init__(self, calendar=None): + """Default constructor.""" + self.calendar = calendar + + def create_event(self, event): + """Create a new event.""" + raise NotImplementedError + + def get_event(self, uid): + """Retrieve an even using its uid.""" + raise NotImplementedError + + def get_events(self, start, end): + """Retrieve a list of event.""" + raise NotImplementedError + + def delete_event(self, uid): + """Delete an event using its uid.""" + raise NotImplementedError + + +def get_backend(name, *args, **kwargs): + """Return a backend instance.""" + module = import_module("modoboa.calendars.backends.{}".format(name)) + return getattr(module, "{}Backend".format(name.capitalize()))(*args, **kwargs) + + +def get_backend_from_request(name, request, calendar=None): + """Return a backend instance from a request.""" + # password = decrypt(request.session["password"]) + return get_backend(name, request.user.email, request.auth, calendar=calendar) diff --git a/modoboa/calendars/backends/caldav_.py b/modoboa/calendars/backends/caldav_.py new file mode 100644 index 000000000..73df9b374 --- /dev/null +++ b/modoboa/calendars/backends/caldav_.py @@ -0,0 +1,163 @@ +"""CalDAV calendar backend.""" + +import datetime +import uuid + +import caldav +from caldav.elements import dav, ical +from caldav import Calendar +import vobject + +from django.utils import timezone +from django.utils.encoding import smart_str + +from modoboa.parameters import tools as param_tools + +from . import CalendarBackend + + +class Caldav_Backend(CalendarBackend): + """CalDAV calendar backend.""" + + def __init__(self, username, password, calendar=None): + """Constructor.""" + super().__init__(calendar) + server_url = smart_str(param_tools.get_global_parameter("server_location")) + self.client = caldav.DAVClient(server_url, username=username, password=password) + if self.calendar: + self.remote_cal = Calendar(self.client, calendar.encoded_path) + + def _serialize_event(self, event): + """Convert a vevent to a dictionary.""" + vevent = event.vobject_instance.vevent + description = ( + vevent.description.value if "description" in vevent.contents else "" + ) + result = { + "id": vevent.uid.value, + "title": vevent.summary.value, + "color": self.calendar.color, + "description": description, + "calendar": self.calendar, + "attendees": [], + } + if isinstance(vevent.dtstart.value, datetime.datetime): + all_day = False + start = vevent.dtstart.value + end = vevent.dtend.value + else: + tz = timezone.get_current_timezone() + all_day = True + start = datetime.datetime.combine( + vevent.dtstart.value, datetime.time.min + ).replace(tzinfo=tz) + end = datetime.datetime.combine( + vevent.dtend.value, datetime.time.min + ).replace(tzinfo=tz) + result.update({"allDay": all_day, "start": start, "end": end}) + if "attendee" in vevent.contents: + for attendee in vevent.contents["attendee"]: + email = attendee.value.replace("mailto:", "").replace("MAILTO:", "") + cn = attendee.params.get("CN") + result["attendees"].append( + {"display_name": cn[0] if cn else "", "email": email} + ) + return result + + def create_calendar(self, url): + """Create a new calendar.""" + self.client.mkcalendar(url) + + def update_calendar(self, calendar): + """Update an existing calendar.""" + remote_cal = Calendar(self.client, calendar.encoded_path) + remote_cal.set_properties( + [dav.DisplayName(calendar.name), ical.CalendarColor(calendar.color)] + ) + + def create_event(self, data): + """Create a new event.""" + uid = uuid.uuid4() + cal = vobject.iCalendar() + cal.add("vevent") + cal.vevent.add("uid").value = str(uid) + cal.vevent.add("summary").value = data["title"] + if not data["allDay"]: + cal.vevent.add("dtstart").value = data["start"] + cal.vevent.add("dtend").value = data["end"] + else: + cal.vevent.add("dtstart").value = data["start"].date() + cal.vevent.add("dtend").value = data["end"].date() + self.remote_cal.add_event(cal) + return uid + + def update_event(self, uid, original_data): + """Update an existing event.""" + data = dict(original_data) + url = "{}/{}.ics".format(self.remote_cal.url.geturl(), uid) + cal = self.remote_cal.event_by_url(url) + orig_evt = cal.vobject_instance.vevent + if "title" in data: + orig_evt.summary.value = data["title"] + if data.get("allDay"): + data["start"] = data["start"].date() + data["end"] = data["end"].date() + if "start" in data: + del orig_evt.contents["dtstart"] + orig_evt.add("dtstart").value = data["start"] + if "end" in data: + del orig_evt.contents["dtend"] + orig_evt.add("dtend").value = data["end"] + if "description" in data: + if "description" in orig_evt.contents: + orig_evt.description.value = data["description"] + else: + orig_evt.add("description").value = data["description"] + if "attendees" in data: + if "attendee" in orig_evt.contents: + del orig_evt.contents["attendee"] + for attdef in data.get("attendees", []): + attendee = orig_evt.add("attendee") + attendee.value = "MAILTO:{}".format(attdef["email"]) + attendee.params["CN"] = [attdef["display_name"]] + attendee.params["ROLE"] = ["REQ-PARTICIPANT"] + if "calendar" in data and self.calendar.pk != data["calendar"].pk: + # Calendar has been changed, remove old event first. + self.remote_cal.client.delete(url) + remote_cal = Calendar(self.client, data["calendar"].encoded_path) + url = "{}/{}.ics".format(remote_cal.url.geturl(), uid) + else: + remote_cal = self.remote_cal + remote_cal.add_event(cal.instance) + return uid + + def get_event(self, uid): + """Retrieve and event using its uid.""" + url = "{}/{}.ics".format(self.remote_cal.url.geturl(), uid) + event = self.remote_cal.event_by_url(url) + return self._serialize_event(event) + + def get_events(self, start, end): + """Retrieve a list of events.""" + orig_events = self.remote_cal.date_search(start, end) + events = [] + for event in orig_events: + events.append(self._serialize_event(event)) + return events + + def delete_event(self, uid): + """Delete an event using its uid.""" + url = "{}/{}.ics".format(self.remote_cal.url.geturl(), uid) + self.remote_cal.client.delete(url) + + def import_events(self, fp): + """Import events from file.""" + content = smart_str(fp.read()) + counter = 0 + for cal in vobject.base.readComponents(content): + for event in cal.vevent_list: + ical = vobject.iCalendar() + ical.add(event) + self.remote_cal.add_event(ical.serialize()) + counter += 1 + return counter diff --git a/modoboa/calendars/factories.py b/modoboa/calendars/factories.py new file mode 100644 index 000000000..b8b0b32f5 --- /dev/null +++ b/modoboa/calendars/factories.py @@ -0,0 +1,34 @@ +"""Fixtures factories.""" + +import factory + +from modoboa.admin import factories as admin_factories + +from . import models + + +class UserCalendarFactory(factory.django.DjangoModelFactory): + + class Meta: + model = models.UserCalendar + + name = factory.Sequence(lambda n: "User calendar %s" % n) + mailbox = factory.SubFactory(admin_factories.MailboxFactory) + + +class SharedCalendarFactory(factory.django.DjangoModelFactory): + + class Meta: + model = models.SharedCalendar + + name = factory.Sequence(lambda n: "Shared calendar %s" % n) + domain = factory.SubFactory(admin_factories.DomainFactory) + + +class AccessRuleFactory(factory.django.DjangoModelFactory): + + class Meta: + model = models.AccessRule + + mailbox = factory.SubFactory(admin_factories.MailboxFactory) + calendar = factory.SubFactory(UserCalendarFactory) diff --git a/modoboa/calendars/handlers.py b/modoboa/calendars/handlers.py new file mode 100644 index 000000000..7e6e719e6 --- /dev/null +++ b/modoboa/calendars/handlers.py @@ -0,0 +1,79 @@ +"""Radicale signals handlers.""" + +try: + from secrets import token_hex +except ImportError: + from os import urandom + + def token_hex(nbytes=None): + return urandom(nbytes).hex() + + +from django.db.models import signals +from django.urls import reverse +from django.dispatch import receiver +from django.utils.translation import gettext as _ + +from modoboa.core import signals as core_signals + +from . import models + + +PERMISSIONS = [ + ("calendars", "usercalendar", "add_usercalendar"), + ("calendars", "usercalendar", "change_usercalendar"), + ("calendars", "usercalendar", "delete_usercalendar"), + ("calendars", "sharedcalendar", "add_sharedcalendar"), + ("calendars", "sharedcalendar", "change_sharedcalendar"), + ("calendars", "sharedcalendar", "delete_sharedcalendar"), +] + +ROLES_PERMISSIONS = { + "DomainAdmins": PERMISSIONS, + "Resellers": PERMISSIONS, + "SimpleUsers": [ + ("calendars", "usercalendar", "add_usercalendar"), + ("calendars", "usercalendar", "change_usercalendar"), + ("calendars", "usercalendar", "delete_usercalendar"), + ], +} + + +@receiver(core_signals.extra_role_permissions) +def extra_permissions(sender, role, **kwargs): + """Extra permissions.""" + return ROLES_PERMISSIONS.get(role, []) + + +@receiver(core_signals.extra_user_menu_entries) +def top_menu(sender, location, user, **kwargs): + """Add extra menu entries.""" + if location == "top_menu" and hasattr(user, "mailbox"): + return [ + { + "name": "radicale", + "label": _("Calendars"), + "url": reverse("calendars:calendar_detail_view"), + } + ] + return [] + + +@receiver(signals.pre_save, sender=models.UserCalendar) +def set_user_calendar_path(sender, instance, **kwargs): + """Set path at creation.""" + if instance.pk: + return + instance._path = "{}/{}".format(instance.mailbox.full_address, instance.name) + if not instance.access_token: + instance.access_token = token_hex(16) + + +@receiver(signals.pre_save, sender=models.SharedCalendar) +def set_shared_calendar_path(sender, instance, **kwargs): + """Set path at creation.""" + if instance.pk: + return + instance._path = "{}/{}".format(instance.domain.name, instance.name) + if not instance.access_token: + instance.access_token = token_hex(16) diff --git a/modoboa/calendars/locale/br/LC_MESSAGES/django.po b/modoboa/calendars/locale/br/LC_MESSAGES/django.po new file mode 100644 index 000000000..ffe665893 --- /dev/null +++ b/modoboa/calendars/locale/br/LC_MESSAGES/django.po @@ -0,0 +1,98 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# +# Translators: +# Irriep Nala Novram , 2019 +msgid "" +msgstr "" +"Project-Id-Version: Modoboa\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2020-05-06 14:22+0200\n" +"PO-Revision-Date: 2020-05-06 12:26+0000\n" +"Last-Translator: Antoine Nguyen \n" +"Language-Team: Breton (http://www.transifex.com/tonio/modoboa/language/br/)\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Language: br\n" +"Plural-Forms: nplurals=5; plural=((n%10 == 1) && (n%100 != 11) && (n%100 !=71) && (n%100 !=91) ? 0 :(n%10 == 2) && (n%100 != 12) && (n%100 !=72) && (n%100 !=92) ? 1 :(n%10 ==3 || n%10==4 || n%10==9) && (n%100 < 10 || n% 100 > 19) && (n%100 < 70 || n%100 > 79) && (n%100 < 90 || n%100 > 99) ? 2 :(n != 0 && n % 1000000 == 0) ? 3 : 4);\n" + +#: forms.py:16 +msgid "Server settings" +msgstr "Arventennoù ar servijer" + +#: forms.py:20 +msgid "Server URL" +msgstr "URL ar servijer" + +#: forms.py:22 +msgid "" +"The URL of your Radicale server. It will be used to construct calendar URLs." +msgstr "URL ho Penn-servijer. Implijet e vo evit sevel URLoù deiziataer." + +#: forms.py:29 +msgid "Rights management" +msgstr "Meradur ar Gwirioù" + +#: forms.py:32 +msgid "Rights file's path" +msgstr "Hent restroù ar gwir" + +#: forms.py:35 +msgid "Path to the file that contains rights definition" +msgstr "Hent betek ar restr hag a endalc'h termenadurioù gwir" + +#: forms.py:41 +msgid "Allow calendars administration" +msgstr "Aotren deiziataerioù ar melestradurioù" + +#: forms.py:44 +msgid "Allow domain administrators to manage user calendars (read and write)" +msgstr "Aotren merourien an domani da verañ deiziataerioù an implijer (lenn ha skrivañ)" + +#: forms.py:50 +msgid "Miscellaneous" +msgstr "" + +#: forms.py:53 +msgid "Maximum size of ICS files" +msgstr "" + +#: forms.py:56 +msgid "" +"Maximum size in bytes of imported ICS files (or KB, MB, GB if specified)" +msgstr "" + +#: handlers.py:52 +msgid "Calendars" +msgstr "Deiziataerioù" + +#: models.py:53 models.py:70 +msgid "Server location is not set, please fix it." +msgstr "Neket termenet lec'hiadur ho servijer, reizhit an dra-se mar plij." + +#: modo_extension.py:17 +msgid "Radicale management" +msgstr "Penn-meradur" + +#: modo_extension.py:22 +msgid "" +"Management frontend for Radicale, a simple calendar and contact server." +msgstr "Etrefas Penn-merañ, un deiziataer eeun hag ur servijer darempredoù." + +#: serializers.py:173 serializers.py:175 +msgid "This field is required." +msgstr "Ar vaezienn-mañ a zo rekiset." + +#: templates/modoboa_radicale/calendar_display.html:6 +msgid "Calendar" +msgstr "Deiziataer" + +#: views.py:20 +msgid "A mailbox is required" +msgstr "Ur voest-posteloù a zo rekiset" + +#: viewsets.py:199 +msgid "Uploaded file is too big (max: {} bytes)" +msgstr "" diff --git a/modoboa/calendars/locale/cs_CZ/LC_MESSAGES/django.po b/modoboa/calendars/locale/cs_CZ/LC_MESSAGES/django.po new file mode 100644 index 000000000..517e3fe15 --- /dev/null +++ b/modoboa/calendars/locale/cs_CZ/LC_MESSAGES/django.po @@ -0,0 +1,175 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# +# Translators: +# Miroslav Abrahám , 2013-2015 +msgid "" +msgstr "" +"Project-Id-Version: Modoboa\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2020-05-06 14:22+0200\n" +"PO-Revision-Date: 2015-01-06 10:05+0000\n" +"Last-Translator: Miroslav Abrahám \n" +"Language-Team: Czech (Czech Republic) (http://www.transifex.com/projects/p/" +"modoboa/language/cs_CZ/)\n" +"Language: cs_CZ\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=3; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2;\n" + +#: forms.py:16 +msgid "Server settings" +msgstr "Nastavení serveru" + +#: forms.py:20 +msgid "Server URL" +msgstr "URL adresa serveru" + +#: forms.py:22 +msgid "" +"The URL of your Radicale server. It will be used to construct calendar URLs." +msgstr "" +"URL adresa serveru Radicale. Adresa bude použita k vytvoření adres kalendářů." + +#: forms.py:29 +msgid "Rights management" +msgstr "Nastavení oprávnění" + +#: forms.py:32 +msgid "Rights file's path" +msgstr "Cesta k souboru s právy" + +#: forms.py:35 +msgid "Path to the file that contains rights definition" +msgstr "Cesta k souboru, který obsahuje seznam oprávnění" + +#: forms.py:41 +msgid "Allow calendars administration" +msgstr "Povolit správu kalendářů" + +#: forms.py:44 +msgid "Allow domain administrators to manage user calendars (read and write)" +msgstr "" +"Povolit správcům domén spravovat kalendáře uživatelů (číst a zapisovat)" + +#: forms.py:50 +msgid "Miscellaneous" +msgstr "" + +#: forms.py:53 +msgid "Maximum size of ICS files" +msgstr "" + +#: forms.py:56 +msgid "" +"Maximum size in bytes of imported ICS files (or KB, MB, GB if specified)" +msgstr "" + +#: handlers.py:52 +msgid "Calendars" +msgstr "Kalendáře" + +#: models.py:53 models.py:70 +msgid "Server location is not set, please fix it." +msgstr "" + +#: modo_extension.py:17 +msgid "Radicale management" +msgstr "Nastavení serveru Radicale" + +#: modo_extension.py:22 +msgid "Management frontend for Radicale, a simple calendar and contact server." +msgstr "" +"Nastavení rozhraní Radicale, jednoduchého serveru pro kalendáře a kontakty." + +#: serializers.py:173 serializers.py:175 +msgid "This field is required." +msgstr "" + +#: templates/modoboa_radicale/calendar_display.html:6 +#, fuzzy +#| msgid "Calendars" +msgid "Calendar" +msgstr "Kalendáře" + +#: views.py:20 +msgid "A mailbox is required" +msgstr "" + +#: viewsets.py:199 +msgid "Uploaded file is too big (max: {} bytes)" +msgstr "" + +#~ msgid "Username" +#~ msgstr "Uživatelské jméno" + +#~ msgid "Read" +#~ msgstr "Číst" + +#~ msgid "Write" +#~ msgstr "Psát" + +#~ msgid "Mailbox %s does not exist" +#~ msgstr "Schránka %s neexistuje" + +#~ msgid "General" +#~ msgstr "Obecné" + +#~ msgid "Rights" +#~ msgstr "Oprávnění" + +#~ msgid "New calendar" +#~ msgstr "Nový kalendář" + +#~ msgid "Calendar created" +#~ msgstr "Kalendář vytvořen" + +#~ msgid "Calendar updated" +#~ msgstr "Kalendář aktualizován" + +#~ msgid "User" +#~ msgstr "Uživatel" + +#~ msgid "Shared" +#~ msgstr "Sdíleno" + +#~ msgid "To access this calendar, use the following address:" +#~ msgstr "Pro přístup k tomuto kalendáři použijte následující adresu:" + +#~ msgid "Name" +#~ msgstr "Jméno" + +#~ msgid "Owner" +#~ msgstr "Majitel" + +#~ msgid "Tags" +#~ msgstr "Značky" + +#~ msgid "Actions" +#~ msgstr "Akce" + +#~ msgid "Add calendar" +#~ msgstr "Přidat kalendář" + +#~ msgid "Add shared calendar" +#~ msgstr "Přidat sdílený kalendář" + +#~ msgid "View calendar detail" +#~ msgstr "Zobrazit detaily kalendáře" + +#~ msgid "Edit %s" +#~ msgstr "Upravit %s" + +#~ msgid "Delete %s?" +#~ msgstr "Smazat %s?" + +#~ msgid "Calendar removed" +#~ msgstr "Kalendář odstraněn" + +#~ msgid "New shared calendar" +#~ msgstr "Nový sdílený kalendář" + +#~ msgid "Submit" +#~ msgstr "Uložit" diff --git a/modoboa/calendars/locale/de/LC_MESSAGES/django.po b/modoboa/calendars/locale/de/LC_MESSAGES/django.po new file mode 100644 index 000000000..05006ae8c --- /dev/null +++ b/modoboa/calendars/locale/de/LC_MESSAGES/django.po @@ -0,0 +1,179 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# +# Translators: +# Arvedui , 2014 +# Patrick Koetter , 2010 +msgid "" +msgstr "" +"Project-Id-Version: Modoboa\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2020-05-06 14:22+0200\n" +"PO-Revision-Date: 2014-12-03 11:02+0000\n" +"Last-Translator: Arvedui \n" +"Language-Team: German (http://www.transifex.com/projects/p/modoboa/language/" +"de/)\n" +"Language: de\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: forms.py:16 +msgid "Server settings" +msgstr "Server Einstellungen" + +#: forms.py:20 +msgid "Server URL" +msgstr "Server URL" + +#: forms.py:22 +msgid "" +"The URL of your Radicale server. It will be used to construct calendar URLs." +msgstr "" +"Die URL Ihres Radicale Servers. Sie wird benutzt um Kalender-URLs zu " +"erstellen." + +#: forms.py:29 +msgid "Rights management" +msgstr "Rechteverwaltung" + +#: forms.py:32 +msgid "Rights file's path" +msgstr "Pfad zur Rechtedatei" + +#: forms.py:35 +msgid "Path to the file that contains rights definition" +msgstr "Pfad zur der Datei, die die Rechtedefinition enthält." + +#: forms.py:41 +msgid "Allow calendars administration" +msgstr "Erlaube Kalender Administration" + +#: forms.py:44 +msgid "Allow domain administrators to manage user calendars (read and write)" +msgstr "" +"Erlaube Domain-Administratoren Benutzer Kalender zu verwalten (lesend und " +"schreibend)." + +#: forms.py:50 +msgid "Miscellaneous" +msgstr "" + +#: forms.py:53 +msgid "Maximum size of ICS files" +msgstr "" + +#: forms.py:56 +msgid "" +"Maximum size in bytes of imported ICS files (or KB, MB, GB if specified)" +msgstr "" + +#: handlers.py:52 +msgid "Calendars" +msgstr "Kalender" + +#: models.py:53 models.py:70 +msgid "Server location is not set, please fix it." +msgstr "" + +#: modo_extension.py:17 +msgid "Radicale management" +msgstr "Radicale Verwaltung" + +#: modo_extension.py:22 +msgid "Management frontend for Radicale, a simple calendar and contact server." +msgstr "" +"Verwaltungsoberfläche für Radicale, einen simplen Kalender- und " +"Kontaktserver." + +#: serializers.py:173 serializers.py:175 +msgid "This field is required." +msgstr "" + +#: templates/modoboa_radicale/calendar_display.html:6 +#, fuzzy +#| msgid "Calendars" +msgid "Calendar" +msgstr "Kalender" + +#: views.py:20 +msgid "A mailbox is required" +msgstr "" + +#: viewsets.py:199 +msgid "Uploaded file is too big (max: {} bytes)" +msgstr "" + +#~ msgid "Username" +#~ msgstr "Benutzername" + +#~ msgid "Read" +#~ msgstr "Lesen" + +#~ msgid "Write" +#~ msgstr "Schreiben" + +#~ msgid "Mailbox %s does not exist" +#~ msgstr "Mailbox %s existiert nicht" + +#~ msgid "General" +#~ msgstr "Allgemein" + +#~ msgid "Rights" +#~ msgstr "Rechte" + +#~ msgid "New calendar" +#~ msgstr "Neuer Kalender" + +#~ msgid "Calendar created" +#~ msgstr "Kalender erstellt" + +#~ msgid "Calendar updated" +#~ msgstr "Kalender aktualisiert" + +#~ msgid "User" +#~ msgstr "Benutzer" + +#~ msgid "Shared" +#~ msgstr "Geteilt" + +#~ msgid "To access this calendar, use the following address:" +#~ msgstr "Benutzen Sie die folgende Adresse um auf dem Kalender zuzugreifen: " + +#~ msgid "Name" +#~ msgstr "Name" + +#~ msgid "Owner" +#~ msgstr "Besitzer" + +#~ msgid "Tags" +#~ msgstr "Tags" + +#~ msgid "Actions" +#~ msgstr "Aktionen" + +#~ msgid "Add calendar" +#~ msgstr "Kalender hinzufügen" + +#~ msgid "Add shared calendar" +#~ msgstr "Geteilten Kalender hinzufügen" + +#~ msgid "View calendar detail" +#~ msgstr "Kalender Details ansehen" + +#~ msgid "Edit %s" +#~ msgstr "Bearbeite %s" + +#~ msgid "Delete %s?" +#~ msgstr "%s löschen?" + +#~ msgid "Calendar removed" +#~ msgstr "Kalender entfernt" + +#~ msgid "New shared calendar" +#~ msgstr "Neuer geteilter Kalender" + +#~ msgid "Submit" +#~ msgstr "Abschicken" diff --git a/modoboa/calendars/locale/en/LC_MESSAGES/django.po b/modoboa/calendars/locale/en/LC_MESSAGES/django.po new file mode 100644 index 000000000..183fa87e7 --- /dev/null +++ b/modoboa/calendars/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,96 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2020-05-06 14:22+0200\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" + +#: forms.py:16 +msgid "Server settings" +msgstr "" + +#: forms.py:20 +msgid "Server URL" +msgstr "" + +#: forms.py:22 +msgid "" +"The URL of your Radicale server. It will be used to construct calendar URLs." +msgstr "" + +#: forms.py:29 +msgid "Rights management" +msgstr "" + +#: forms.py:32 +msgid "Rights file's path" +msgstr "" + +#: forms.py:35 +msgid "Path to the file that contains rights definition" +msgstr "" + +#: forms.py:41 +msgid "Allow calendars administration" +msgstr "" + +#: forms.py:44 +msgid "Allow domain administrators to manage user calendars (read and write)" +msgstr "" + +#: forms.py:50 +msgid "Miscellaneous" +msgstr "" + +#: forms.py:53 +msgid "Maximum size of ICS files" +msgstr "" + +#: forms.py:56 +msgid "" +"Maximum size in bytes of imported ICS files (or KB, MB, GB if specified)" +msgstr "" + +#: handlers.py:52 +msgid "Calendars" +msgstr "" + +#: models.py:53 models.py:70 +msgid "Server location is not set, please fix it." +msgstr "" + +#: modo_extension.py:17 +msgid "Radicale management" +msgstr "" + +#: modo_extension.py:22 +msgid "Management frontend for Radicale, a simple calendar and contact server." +msgstr "" + +#: serializers.py:173 serializers.py:175 +msgid "This field is required." +msgstr "" + +#: templates/modoboa_radicale/calendar_display.html:6 +msgid "Calendar" +msgstr "" + +#: views.py:20 +msgid "A mailbox is required" +msgstr "" + +#: viewsets.py:199 +msgid "Uploaded file is too big (max: {} bytes)" +msgstr "" diff --git a/modoboa/calendars/locale/es/LC_MESSAGES/django.po b/modoboa/calendars/locale/es/LC_MESSAGES/django.po new file mode 100644 index 000000000..5756199c9 --- /dev/null +++ b/modoboa/calendars/locale/es/LC_MESSAGES/django.po @@ -0,0 +1,100 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# +# Translators: +# Enrique Garcia , 2016 +# Evilham , 2017 +# Luis Fajardo López , 2018 +msgid "" +msgstr "" +"Project-Id-Version: Modoboa\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2020-05-06 14:22+0200\n" +"PO-Revision-Date: 2020-05-06 12:26+0000\n" +"Last-Translator: Antoine Nguyen \n" +"Language-Team: Spanish (http://www.transifex.com/tonio/modoboa/language/es/)\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Language: es\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: forms.py:16 +msgid "Server settings" +msgstr "Configuración del servidor" + +#: forms.py:20 +msgid "Server URL" +msgstr "URL del servidor" + +#: forms.py:22 +msgid "" +"The URL of your Radicale server. It will be used to construct calendar URLs." +msgstr "La URL de tu Radicale server. Será usado para construir las URLs de los calendarios" + +#: forms.py:29 +msgid "Rights management" +msgstr "Administración de permisos" + +#: forms.py:32 +msgid "Rights file's path" +msgstr "Ruta al fichero de permisos" + +#: forms.py:35 +msgid "Path to the file that contains rights definition" +msgstr "Ruta al fichero que define los permisos" + +#: forms.py:41 +msgid "Allow calendars administration" +msgstr "Permitir administración de los calendarios." + +#: forms.py:44 +msgid "Allow domain administrators to manage user calendars (read and write)" +msgstr "Permitir a los administradores de dominio administrar los calendarios de usuario (lectura y escritura)" + +#: forms.py:50 +msgid "Miscellaneous" +msgstr "" + +#: forms.py:53 +msgid "Maximum size of ICS files" +msgstr "" + +#: forms.py:56 +msgid "" +"Maximum size in bytes of imported ICS files (or KB, MB, GB if specified)" +msgstr "" + +#: handlers.py:52 +msgid "Calendars" +msgstr "Calendarios" + +#: models.py:53 models.py:70 +msgid "Server location is not set, please fix it." +msgstr "La ubicación del servidor no está establecida, por favor configúrela." + +#: modo_extension.py:17 +msgid "Radicale management" +msgstr "Administración de Radicale" + +#: modo_extension.py:22 +msgid "" +"Management frontend for Radicale, a simple calendar and contact server." +msgstr "Frontend de administración de Radicale, un servidor sencillo de calendarios y contactos." + +#: serializers.py:173 serializers.py:175 +msgid "This field is required." +msgstr "Este campo es necesario." + +#: templates/modoboa_radicale/calendar_display.html:6 +msgid "Calendar" +msgstr "Calendario" + +#: views.py:20 +msgid "A mailbox is required" +msgstr "Se requiere una cuenta" + +#: viewsets.py:199 +msgid "Uploaded file is too big (max: {} bytes)" +msgstr "" diff --git a/modoboa/calendars/locale/fr/LC_MESSAGES/django.po b/modoboa/calendars/locale/fr/LC_MESSAGES/django.po new file mode 100644 index 000000000..e3372744d --- /dev/null +++ b/modoboa/calendars/locale/fr/LC_MESSAGES/django.po @@ -0,0 +1,98 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# +# Translators: +# Antoine Nguyen , 2017,2020 +msgid "" +msgstr "" +"Project-Id-Version: Modoboa\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2020-05-06 14:22+0200\n" +"PO-Revision-Date: 2020-05-06 12:28+0000\n" +"Last-Translator: Antoine Nguyen \n" +"Language-Team: French (http://www.transifex.com/tonio/modoboa/language/fr/)\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Language: fr\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" + +#: forms.py:16 +msgid "Server settings" +msgstr "Paramètres serveur" + +#: forms.py:20 +msgid "Server URL" +msgstr "URL du serveur" + +#: forms.py:22 +msgid "" +"The URL of your Radicale server. It will be used to construct calendar URLs." +msgstr "L'URL de votre serveur Radicale. Elle sera utilisée pour construire l'URL des calendriers." + +#: forms.py:29 +msgid "Rights management" +msgstr "Gestion des droits" + +#: forms.py:32 +msgid "Rights file's path" +msgstr "Chemin d'accès du fichier contenant les droits" + +#: forms.py:35 +msgid "Path to the file that contains rights definition" +msgstr "Chemin d'accès du fichier contenant la définition des droits" + +#: forms.py:41 +msgid "Allow calendars administration" +msgstr "Autoriser l'administration des calendriers" + +#: forms.py:44 +msgid "Allow domain administrators to manage user calendars (read and write)" +msgstr "Autorise les administrateurs de domaine à gérer les calendriers des utilisateurs (lecture et écriture)" + +#: forms.py:50 +msgid "Miscellaneous" +msgstr "Divers" + +#: forms.py:53 +msgid "Maximum size of ICS files" +msgstr "Taille maximale des fichiers ICS" + +#: forms.py:56 +msgid "" +"Maximum size in bytes of imported ICS files (or KB, MB, GB if specified)" +msgstr "Taille maximale en octets des fichiers ICS importés (ou Ko, Mo, Go si précisé)" + +#: handlers.py:52 +msgid "Calendars" +msgstr "Calendriers" + +#: models.py:53 models.py:70 +msgid "Server location is not set, please fix it." +msgstr "La localisation du serveur n'est pas définie, veuillez le faire svp." + +#: modo_extension.py:17 +msgid "Radicale management" +msgstr "Gestion de Radicale" + +#: modo_extension.py:22 +msgid "" +"Management frontend for Radicale, a simple calendar and contact server." +msgstr "Console de gestion pour Radicale, un serveur simple de calendrier et de contact." + +#: serializers.py:173 serializers.py:175 +msgid "This field is required." +msgstr "Ce champ est requis." + +#: templates/modoboa_radicale/calendar_display.html:6 +msgid "Calendar" +msgstr "Calendrier" + +#: views.py:20 +msgid "A mailbox is required" +msgstr "Une boite aux lettres est requise." + +#: viewsets.py:199 +msgid "Uploaded file is too big (max: {} bytes)" +msgstr "Le fichier téléversé est trop gros (max : {} octets)" diff --git a/modoboa/calendars/locale/it/LC_MESSAGES/django.po b/modoboa/calendars/locale/it/LC_MESSAGES/django.po new file mode 100644 index 000000000..d006519c7 --- /dev/null +++ b/modoboa/calendars/locale/it/LC_MESSAGES/django.po @@ -0,0 +1,99 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# +# Translators: +# Giancarlo Gaifas , 2013 +# Rocco , 2015 +msgid "" +msgstr "" +"Project-Id-Version: Modoboa\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2020-05-06 14:22+0200\n" +"PO-Revision-Date: 2020-05-06 12:26+0000\n" +"Last-Translator: Antoine Nguyen \n" +"Language-Team: Italian (http://www.transifex.com/tonio/modoboa/language/it/)\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Language: it\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: forms.py:16 +msgid "Server settings" +msgstr "Impostazioni del Server" + +#: forms.py:20 +msgid "Server URL" +msgstr "Indirizzo del Server" + +#: forms.py:22 +msgid "" +"The URL of your Radicale server. It will be used to construct calendar URLs." +msgstr "L'URL del Server. Sarà utilizzato per la costrizione degli URLs del calendario." + +#: forms.py:29 +msgid "Rights management" +msgstr "Gestione permessi" + +#: forms.py:32 +msgid "Rights file's path" +msgstr "Percorso permessi del file" + +#: forms.py:35 +msgid "Path to the file that contains rights definition" +msgstr "Percorso del file che contiene le definizioni dei permessi" + +#: forms.py:41 +msgid "Allow calendars administration" +msgstr "Permetti amministrazione dei Calendari" + +#: forms.py:44 +msgid "Allow domain administrators to manage user calendars (read and write)" +msgstr "Permetti agli aministratori di dominio di aministrare i Calendari utenti (lettura e scrittura)" + +#: forms.py:50 +msgid "Miscellaneous" +msgstr "" + +#: forms.py:53 +msgid "Maximum size of ICS files" +msgstr "" + +#: forms.py:56 +msgid "" +"Maximum size in bytes of imported ICS files (or KB, MB, GB if specified)" +msgstr "" + +#: handlers.py:52 +msgid "Calendars" +msgstr "Calendari" + +#: models.py:53 models.py:70 +msgid "Server location is not set, please fix it." +msgstr "" + +#: modo_extension.py:17 +msgid "Radicale management" +msgstr "Amministrazione Radicale" + +#: modo_extension.py:22 +msgid "" +"Management frontend for Radicale, a simple calendar and contact server." +msgstr "Gestione frontend per Radicale , un semplice server di calendario e contatti ." + +#: serializers.py:173 serializers.py:175 +msgid "This field is required." +msgstr "" + +#: templates/modoboa_radicale/calendar_display.html:6 +msgid "Calendar" +msgstr "" + +#: views.py:20 +msgid "A mailbox is required" +msgstr "" + +#: viewsets.py:199 +msgid "Uploaded file is too big (max: {} bytes)" +msgstr "" diff --git a/modoboa/calendars/locale/nl_NL/LC_MESSAGES/django.po b/modoboa/calendars/locale/nl_NL/LC_MESSAGES/django.po new file mode 100644 index 000000000..6f3b8e7f9 --- /dev/null +++ b/modoboa/calendars/locale/nl_NL/LC_MESSAGES/django.po @@ -0,0 +1,177 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# +# Translators: +# Andrew , 2014 +# TuxBrother , 2014 +msgid "" +msgstr "" +"Project-Id-Version: Modoboa\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2020-05-06 14:22+0200\n" +"PO-Revision-Date: 2014-12-31 11:37+0000\n" +"Last-Translator: TuxBrother \n" +"Language-Team: Dutch (Netherlands) (http://www.transifex.com/projects/p/" +"modoboa/language/nl_NL/)\n" +"Language: nl_NL\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: forms.py:16 +msgid "Server settings" +msgstr "Server instellingen" + +#: forms.py:20 +msgid "Server URL" +msgstr "Server URL" + +#: forms.py:22 +msgid "" +"The URL of your Radicale server. It will be used to construct calendar URLs." +msgstr "" +"De URL van de Radicale server. Het zal gebruikt worden om kalender URL's " +"samen te stellen." + +#: forms.py:29 +msgid "Rights management" +msgstr "Rechten beheer" + +#: forms.py:32 +msgid "Rights file's path" +msgstr "Locatie van het rechtenbestand" + +#: forms.py:35 +msgid "Path to the file that contains rights definition" +msgstr "Locatie van het bestand dat de rechtendefinitie bevat" + +#: forms.py:41 +msgid "Allow calendars administration" +msgstr "Sta het beheer van kalenders toe" + +#: forms.py:44 +msgid "Allow domain administrators to manage user calendars (read and write)" +msgstr "" +"Sta domein administrators toe om kalenders van gebruikers te beheren (lezen " +"en schrijven)" + +#: forms.py:50 +msgid "Miscellaneous" +msgstr "" + +#: forms.py:53 +msgid "Maximum size of ICS files" +msgstr "" + +#: forms.py:56 +msgid "" +"Maximum size in bytes of imported ICS files (or KB, MB, GB if specified)" +msgstr "" + +#: handlers.py:52 +msgid "Calendars" +msgstr "Kalenders" + +#: models.py:53 models.py:70 +msgid "Server location is not set, please fix it." +msgstr "" + +#: modo_extension.py:17 +msgid "Radicale management" +msgstr "Radicale beheer" + +#: modo_extension.py:22 +msgid "Management frontend for Radicale, a simple calendar and contact server." +msgstr "Beheermodule voor Radicale, een simpele server kalenders en contacten." + +#: serializers.py:173 serializers.py:175 +msgid "This field is required." +msgstr "" + +#: templates/modoboa_radicale/calendar_display.html:6 +#, fuzzy +#| msgid "Calendars" +msgid "Calendar" +msgstr "Kalenders" + +#: views.py:20 +msgid "A mailbox is required" +msgstr "" + +#: viewsets.py:199 +msgid "Uploaded file is too big (max: {} bytes)" +msgstr "" + +#~ msgid "Username" +#~ msgstr "Gebruikersnaam" + +#~ msgid "Read" +#~ msgstr "Lezen" + +#~ msgid "Write" +#~ msgstr "Schrijven" + +#~ msgid "Mailbox %s does not exist" +#~ msgstr "Mailbox %s bestaat niet" + +#~ msgid "General" +#~ msgstr "Algemeen" + +#~ msgid "Rights" +#~ msgstr "Rechten" + +#~ msgid "New calendar" +#~ msgstr "Nieuwe kalender" + +#~ msgid "Calendar created" +#~ msgstr "Kalender aangemaakt" + +#~ msgid "Calendar updated" +#~ msgstr "Kalender bijgewerkt" + +#~ msgid "User" +#~ msgstr "Gebruiker" + +#~ msgid "Shared" +#~ msgstr "Gedeeld" + +#~ msgid "To access this calendar, use the following address:" +#~ msgstr "Deze kalender heeft het volgende adres:" + +#~ msgid "Name" +#~ msgstr "Naam" + +#~ msgid "Owner" +#~ msgstr "Eigenaar" + +#~ msgid "Tags" +#~ msgstr "Labels" + +#~ msgid "Actions" +#~ msgstr "Acties" + +#~ msgid "Add calendar" +#~ msgstr "Kalender toevoegen" + +#~ msgid "Add shared calendar" +#~ msgstr "Gedeelde kalender toevoegen" + +#~ msgid "View calendar detail" +#~ msgstr "Laat de kalender details zien" + +#~ msgid "Edit %s" +#~ msgstr "Bewerk %s" + +#~ msgid "Delete %s?" +#~ msgstr "Verwijder %s?" + +#~ msgid "Calendar removed" +#~ msgstr "Kalender verwijderd" + +#~ msgid "New shared calendar" +#~ msgstr "Nieuwe gedeelde kalender" + +#~ msgid "Submit" +#~ msgstr "Versturen" diff --git a/modoboa/calendars/locale/pt_BR/LC_MESSAGES/django.po b/modoboa/calendars/locale/pt_BR/LC_MESSAGES/django.po new file mode 100644 index 000000000..66cd83753 --- /dev/null +++ b/modoboa/calendars/locale/pt_BR/LC_MESSAGES/django.po @@ -0,0 +1,180 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# +# Translators: +# csmk, 2014 +# Paulino Michelazzo , 2014-2015 +# Rafael Barretto Alves , 2013 +msgid "" +msgstr "" +"Project-Id-Version: Modoboa\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2020-05-06 14:22+0200\n" +"PO-Revision-Date: 2015-08-11 06:52+0000\n" +"Last-Translator: Paulino Michelazzo \n" +"Language-Team: Portuguese (Brazil) (http://www.transifex.com/tonio/modoboa/" +"language/pt_BR/)\n" +"Language: pt_BR\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" + +#: forms.py:16 +msgid "Server settings" +msgstr "Configurações do servidor" + +#: forms.py:20 +msgid "Server URL" +msgstr "URL do Servidor" + +#: forms.py:22 +msgid "" +"The URL of your Radicale server. It will be used to construct calendar URLs." +msgstr "" +"A URL de seu servidor Radicale. Ela será usada para construir URL's de " +"calendários." + +#: forms.py:29 +msgid "Rights management" +msgstr "Gerenciamento de direitos" + +#: forms.py:32 +msgid "Rights file's path" +msgstr "Caminho do arquivo de direitos" + +#: forms.py:35 +msgid "Path to the file that contains rights definition" +msgstr "Caminho para o arquivo que contém as definições de direitos" + +#: forms.py:41 +msgid "Allow calendars administration" +msgstr "Permitir administração de calendários" + +#: forms.py:44 +msgid "Allow domain administrators to manage user calendars (read and write)" +msgstr "" +"Permite a administradores de domínio gerenciar calendários de usuários " +"(leitura e escrita)" + +#: forms.py:50 +msgid "Miscellaneous" +msgstr "" + +#: forms.py:53 +msgid "Maximum size of ICS files" +msgstr "" + +#: forms.py:56 +msgid "" +"Maximum size in bytes of imported ICS files (or KB, MB, GB if specified)" +msgstr "" + +#: handlers.py:52 +msgid "Calendars" +msgstr "Calendários" + +#: models.py:53 models.py:70 +msgid "Server location is not set, please fix it." +msgstr "" + +#: modo_extension.py:17 +msgid "Radicale management" +msgstr "Gerenciamento do Radicale" + +#: modo_extension.py:22 +msgid "Management frontend for Radicale, a simple calendar and contact server." +msgstr "" +"Gerenciador de frontend para Radicale, um servidor simples de calendário e " +"contato." + +#: serializers.py:173 serializers.py:175 +msgid "This field is required." +msgstr "" + +#: templates/modoboa_radicale/calendar_display.html:6 +#, fuzzy +#| msgid "Calendars" +msgid "Calendar" +msgstr "Calendários" + +#: views.py:20 +msgid "A mailbox is required" +msgstr "" + +#: viewsets.py:199 +msgid "Uploaded file is too big (max: {} bytes)" +msgstr "" + +#~ msgid "Username" +#~ msgstr "Nome de usuário" + +#~ msgid "Read" +#~ msgstr "Ler" + +#~ msgid "Write" +#~ msgstr "Escrever" + +#~ msgid "Mailbox %s does not exist" +#~ msgstr "A caixa de correio '%s não existe" + +#~ msgid "General" +#~ msgstr "Geral" + +#~ msgid "Rights" +#~ msgstr "Direitos" + +#~ msgid "New calendar" +#~ msgstr "Novo calendário" + +#~ msgid "Calendar created" +#~ msgstr "Calendário criado" + +#~ msgid "Calendar updated" +#~ msgstr "Calendário atualizado" + +#~ msgid "User" +#~ msgstr "Usuário" + +#~ msgid "Shared" +#~ msgstr "Compartilhado" + +#~ msgid "To access this calendar, use the following address:" +#~ msgstr "Para acessar este calendário, use o seguinte endereço:" + +#~ msgid "Name" +#~ msgstr "Nome" + +#~ msgid "Owner" +#~ msgstr "Proprietário" + +#~ msgid "Tags" +#~ msgstr "Tags" + +#~ msgid "Actions" +#~ msgstr "Ações" + +#~ msgid "Add calendar" +#~ msgstr "Adicionar calendário" + +#~ msgid "Add shared calendar" +#~ msgstr "Adicionar calendário compartilhado" + +#~ msgid "View calendar detail" +#~ msgstr "Ver detalhes do calendário" + +#~ msgid "Edit %s" +#~ msgstr "Editar %s" + +#~ msgid "Delete %s?" +#~ msgstr "Remover %s?" + +#~ msgid "Calendar removed" +#~ msgstr "Calendário removido" + +#~ msgid "New shared calendar" +#~ msgstr "Novo calendário compartilhado" + +#~ msgid "Submit" +#~ msgstr "Enviar" diff --git a/modoboa/calendars/locale/pt_PT/LC_MESSAGES/django.po b/modoboa/calendars/locale/pt_PT/LC_MESSAGES/django.po new file mode 100644 index 000000000..9e1457082 --- /dev/null +++ b/modoboa/calendars/locale/pt_PT/LC_MESSAGES/django.po @@ -0,0 +1,183 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# +# Translators: +# Damien Duponchelle , 2013 +# lusitan , 2013 +# Mike C. , 2012 +# Mike C. , 2013-2014 +# Sandra Ribeiro , 2012-2013 +# Sandra Ribeiro , 2012 +msgid "" +msgstr "" +"Project-Id-Version: Modoboa\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2020-05-06 14:22+0200\n" +"PO-Revision-Date: 2014-12-29 12:51+0000\n" +"Last-Translator: Mike C. \n" +"Language-Team: Portuguese (Portugal) (http://www.transifex.com/projects/p/" +"modoboa/language/pt_PT/)\n" +"Language: pt_PT\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: forms.py:16 +msgid "Server settings" +msgstr "Definições do Servidor" + +#: forms.py:20 +msgid "Server URL" +msgstr "Endereço do Servidor" + +#: forms.py:22 +msgid "" +"The URL of your Radicale server. It will be used to construct calendar URLs." +msgstr "" +"O endereço do servidor Radicale. Será usado para construir os endereços dos " +"Calendários." + +#: forms.py:29 +msgid "Rights management" +msgstr "Gestão de Permissões" + +#: forms.py:32 +msgid "Rights file's path" +msgstr "Caminho para o ficheiro de permissões" + +#: forms.py:35 +msgid "Path to the file that contains rights definition" +msgstr "Caminho para o ficheiro que contém as permissões de utilizadores" + +#: forms.py:41 +msgid "Allow calendars administration" +msgstr "Permitir administração de calendários" + +#: forms.py:44 +msgid "Allow domain administrators to manage user calendars (read and write)" +msgstr "" +"Permitir aos administradores de domínio gerir calendários de utilizadores " +"(leitura e escrita)" + +#: forms.py:50 +msgid "Miscellaneous" +msgstr "" + +#: forms.py:53 +msgid "Maximum size of ICS files" +msgstr "" + +#: forms.py:56 +msgid "" +"Maximum size in bytes of imported ICS files (or KB, MB, GB if specified)" +msgstr "" + +#: handlers.py:52 +msgid "Calendars" +msgstr "Calendários" + +#: models.py:53 models.py:70 +msgid "Server location is not set, please fix it." +msgstr "" + +#: modo_extension.py:17 +msgid "Radicale management" +msgstr "Gestão do Radicale" + +#: modo_extension.py:22 +msgid "Management frontend for Radicale, a simple calendar and contact server." +msgstr "" +"Front-end para gestão do Radicale, o servidor simples de contactos e " +"calendário." + +#: serializers.py:173 serializers.py:175 +msgid "This field is required." +msgstr "" + +#: templates/modoboa_radicale/calendar_display.html:6 +#, fuzzy +#| msgid "Calendars" +msgid "Calendar" +msgstr "Calendários" + +#: views.py:20 +msgid "A mailbox is required" +msgstr "" + +#: viewsets.py:199 +msgid "Uploaded file is too big (max: {} bytes)" +msgstr "" + +#~ msgid "Username" +#~ msgstr "Nome de Utilizador" + +#~ msgid "Read" +#~ msgstr "Leitura" + +#~ msgid "Write" +#~ msgstr "Escrita" + +#~ msgid "Mailbox %s does not exist" +#~ msgstr "Caixa de correio %s não existe" + +#~ msgid "General" +#~ msgstr "Geral" + +#~ msgid "Rights" +#~ msgstr "Permissões" + +#~ msgid "New calendar" +#~ msgstr "Novo calendário" + +#~ msgid "Calendar created" +#~ msgstr "Calendário criado" + +#~ msgid "Calendar updated" +#~ msgstr "Calendário atualizado" + +#~ msgid "User" +#~ msgstr "Utilizador" + +#~ msgid "Shared" +#~ msgstr "Partilhado" + +#~ msgid "To access this calendar, use the following address:" +#~ msgstr "Para aceder a este calendário, use o seguinte endrerço:" + +#~ msgid "Name" +#~ msgstr "Nome" + +#~ msgid "Owner" +#~ msgstr "Dono" + +#~ msgid "Tags" +#~ msgstr "Tags" + +#~ msgid "Actions" +#~ msgstr "Ações" + +#~ msgid "Add calendar" +#~ msgstr "Adicionar calendário" + +#~ msgid "Add shared calendar" +#~ msgstr "Adicionar calendário partilhado" + +#~ msgid "View calendar detail" +#~ msgstr "Ver detalhes do Calendario" + +#~ msgid "Edit %s" +#~ msgstr "Editar %s" + +#~ msgid "Delete %s?" +#~ msgstr "Eliminar %s?" + +#~ msgid "Calendar removed" +#~ msgstr "Calendário removido" + +#~ msgid "New shared calendar" +#~ msgstr "Novo calendário partilhado" + +#~ msgid "Submit" +#~ msgstr "Enviar" diff --git a/modoboa/calendars/locale/ro_RO/LC_MESSAGES/django.po b/modoboa/calendars/locale/ro_RO/LC_MESSAGES/django.po new file mode 100644 index 000000000..a08d46ef4 --- /dev/null +++ b/modoboa/calendars/locale/ro_RO/LC_MESSAGES/django.po @@ -0,0 +1,107 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# +# Translators: +# Andrei-Ilie Olteanu , 2018 +msgid "" +msgstr "" +"Project-Id-Version: Modoboa\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2020-05-06 14:22+0200\n" +"PO-Revision-Date: 2018-06-13 07:05+0000\n" +"Last-Translator: Andrei-Ilie Olteanu \n" +"Language-Team: Romanian (Romania) (http://www.transifex.com/tonio/modoboa/" +"language/ro_RO/)\n" +"Language: ro_RO\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=3; plural=(n==1?0:(((n%100>19)||((n%100==0)&&(n!=0)))?" +"2:1));\n" + +#: forms.py:16 +msgid "Server settings" +msgstr "Setări server" + +#: forms.py:20 +msgid "Server URL" +msgstr "URL server" + +#: forms.py:22 +msgid "" +"The URL of your Radicale server. It will be used to construct calendar URLs." +msgstr "" +"URL-ul pentru serverul tau Radicale. Va fi folosit pentru compunerea URL-" +"urilor pentru calendar. " + +#: forms.py:29 +msgid "Rights management" +msgstr "Manageriere drepturi" + +#: forms.py:32 +msgid "Rights file's path" +msgstr "Calea fișierului de drepturi" + +#: forms.py:35 +msgid "Path to the file that contains rights definition" +msgstr "Calea către fișierul ce conține definirea drepturilor" + +#: forms.py:41 +msgid "Allow calendars administration" +msgstr "Permite administrarea calendarelor" + +#: forms.py:44 +msgid "Allow domain administrators to manage user calendars (read and write)" +msgstr "" +"Permite administratorilor de domeniu să managerieze calendarele (scriere și " +"citire)" + +#: forms.py:50 +msgid "Miscellaneous" +msgstr "" + +#: forms.py:53 +msgid "Maximum size of ICS files" +msgstr "" + +#: forms.py:56 +msgid "" +"Maximum size in bytes of imported ICS files (or KB, MB, GB if specified)" +msgstr "" + +#: handlers.py:52 +msgid "Calendars" +msgstr "Calendare" + +#: models.py:53 models.py:70 +msgid "Server location is not set, please fix it." +msgstr "Locația serverului nu este definită, te rugăm repară. " + +#: modo_extension.py:17 +msgid "Radicale management" +msgstr "Management Radicale" + +#: modo_extension.py:22 +msgid "Management frontend for Radicale, a simple calendar and contact server." +msgstr "" +"Management Frontend pentru Radicale, un server simplu pentru calendar si " +"contact. " + +#: serializers.py:173 serializers.py:175 +msgid "This field is required." +msgstr "Acest câmp este necesar." + +#: templates/modoboa_radicale/calendar_display.html:6 +msgid "Calendar" +msgstr "Calendar" + +#: views.py:20 +#, fuzzy +#| msgid "This field is required." +msgid "A mailbox is required" +msgstr "Acest câmp este necesar." + +#: viewsets.py:199 +msgid "Uploaded file is too big (max: {} bytes)" +msgstr "" diff --git a/modoboa/calendars/locale/ru/LC_MESSAGES/django.po b/modoboa/calendars/locale/ru/LC_MESSAGES/django.po new file mode 100644 index 000000000..a6f5b0394 --- /dev/null +++ b/modoboa/calendars/locale/ru/LC_MESSAGES/django.po @@ -0,0 +1,146 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# +# Translators: +msgid "" +msgstr "" +"Project-Id-Version: Modoboa\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2020-05-06 14:22+0200\n" +"PO-Revision-Date: 2018-04-17 16:39+0300\n" +"Last-Translator: Antoine Nguyen \n" +"Language-Team: Russian (http://www.transifex.com/projects/p/modoboa/language/" +"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=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n" +"%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" +"X-Generator: Poedit 2.0.6\n" + +#: forms.py:16 +msgid "Server settings" +msgstr "Параметры сервера" + +#: forms.py:20 +msgid "Server URL" +msgstr "URL сервера" + +#: forms.py:22 +msgid "" +"The URL of your Radicale server. It will be used to construct calendar URLs." +msgstr "URL вашего сервера. Будет использован для создания URL календаря." + +#: forms.py:29 +msgid "Rights management" +msgstr "Управление правами" + +#: forms.py:32 +msgid "Rights file's path" +msgstr "Путь до файла описания прав" + +#: forms.py:35 +msgid "Path to the file that contains rights definition" +msgstr "Путь доя файла, который содержит определение прав доступа" + +#: forms.py:41 +msgid "Allow calendars administration" +msgstr "Разрешить администрирование календарей" + +#: forms.py:44 +msgid "Allow domain administrators to manage user calendars (read and write)" +msgstr "" +"Разрешить админам доменов управлять календарями пользователей (чтение и " +"запись)" + +#: forms.py:50 +msgid "Miscellaneous" +msgstr "" + +#: forms.py:53 +msgid "Maximum size of ICS files" +msgstr "" + +#: forms.py:56 +msgid "" +"Maximum size in bytes of imported ICS files (or KB, MB, GB if specified)" +msgstr "" + +#: handlers.py:52 +msgid "Calendars" +msgstr "Календари" + +#: models.py:53 models.py:70 +msgid "Server location is not set, please fix it." +msgstr "Местоположение сервера не указано, пожалуйста, исправьте это." + +#: modo_extension.py:17 +msgid "Radicale management" +msgstr "Управление Radicale" + +#: modo_extension.py:22 +msgid "Management frontend for Radicale, a simple calendar and contact server." +msgstr "" +"Интерфейс управления Radicale, простого сервера календарей и контактов." + +#: serializers.py:173 serializers.py:175 +msgid "This field is required." +msgstr "Это поле обязательно." + +#: templates/modoboa_radicale/calendar_display.html:6 +msgid "Calendar" +msgstr "Календарь" + +#: views.py:20 +#, fuzzy +#| msgid "This field is required." +msgid "A mailbox is required" +msgstr "Это поле обязательно." + +#: viewsets.py:199 +msgid "Uploaded file is too big (max: {} bytes)" +msgstr "" + +#~ msgid "Username" +#~ msgstr "Логин пользователя" + +#, fuzzy +#~ msgid "Mailbox %s does not exist" +#~ msgstr "Домен '%s' не существует" + +#~ msgid "General" +#~ msgstr "Основные" + +#, fuzzy +#~ msgid "Calendar created" +#~ msgstr "Фильтр создан" + +#, fuzzy +#~ msgid "Calendar updated" +#~ msgstr "Профиль обновлен" + +#, fuzzy +#~ msgid "User" +#~ msgstr "Логин пользователя" + +#~ msgid "Name" +#~ msgstr "Логин" + +#~ msgid "Tags" +#~ msgstr "Теги" + +#~ msgid "Actions" +#~ msgstr "Действие" + +#, fuzzy +#~ msgid "Add calendar" +#~ msgstr "Добавить синоним" + +#~ msgid "Delete %s?" +#~ msgstr "Удалить %s?" + +#, fuzzy +#~ msgid "Calendar removed" +#~ msgstr "Фильтр удален" diff --git a/modoboa/calendars/locale/sv/LC_MESSAGES/django.po b/modoboa/calendars/locale/sv/LC_MESSAGES/django.po new file mode 100644 index 000000000..5ade544e4 --- /dev/null +++ b/modoboa/calendars/locale/sv/LC_MESSAGES/django.po @@ -0,0 +1,178 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# +# Translators: +# Martin Persson , 2014 +# Olle Gustafsson , 2013 +# Olle Gustafsson , 2013,2015 +msgid "" +msgstr "" +"Project-Id-Version: Modoboa\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2020-05-06 14:22+0200\n" +"PO-Revision-Date: 2015-01-13 15:18+0000\n" +"Last-Translator: Olle Gustafsson \n" +"Language-Team: Swedish (http://www.transifex.com/projects/p/modoboa/language/" +"sv/)\n" +"Language: sv\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: forms.py:16 +msgid "Server settings" +msgstr "Serverinställningar" + +#: forms.py:20 +msgid "Server URL" +msgstr "Server URL" + +#: forms.py:22 +msgid "" +"The URL of your Radicale server. It will be used to construct calendar URLs." +msgstr "" +"URL till din Radicale server. Den kommer användas för att konstruera " +"calender URL:er." + +#: forms.py:29 +msgid "Rights management" +msgstr "Rättighetshantering" + +#: forms.py:32 +msgid "Rights file's path" +msgstr "Rättighets fil sökväg" + +#: forms.py:35 +msgid "Path to the file that contains rights definition" +msgstr "Sökväg till filen som innehåller rättighetsdefinitioner" + +#: forms.py:41 +msgid "Allow calendars administration" +msgstr "Tillåt kalendrars administration" + +#: forms.py:44 +msgid "Allow domain administrators to manage user calendars (read and write)" +msgstr "" +"Tillåt domänadmininstratörer att ändra användares kalendrar (läsa och skriva)" + +#: forms.py:50 +msgid "Miscellaneous" +msgstr "" + +#: forms.py:53 +msgid "Maximum size of ICS files" +msgstr "" + +#: forms.py:56 +msgid "" +"Maximum size in bytes of imported ICS files (or KB, MB, GB if specified)" +msgstr "" + +#: handlers.py:52 +msgid "Calendars" +msgstr "Kalendrar" + +#: models.py:53 models.py:70 +msgid "Server location is not set, please fix it." +msgstr "" + +#: modo_extension.py:17 +msgid "Radicale management" +msgstr "Radicale hantering" + +#: modo_extension.py:22 +msgid "Management frontend for Radicale, a simple calendar and contact server." +msgstr "" +"Hanterings frontend för Radicale, en enkel kalender- och kontakt-server" + +#: serializers.py:173 serializers.py:175 +msgid "This field is required." +msgstr "" + +#: templates/modoboa_radicale/calendar_display.html:6 +#, fuzzy +#| msgid "Calendars" +msgid "Calendar" +msgstr "Kalendrar" + +#: views.py:20 +msgid "A mailbox is required" +msgstr "" + +#: viewsets.py:199 +msgid "Uploaded file is too big (max: {} bytes)" +msgstr "" + +#~ msgid "Username" +#~ msgstr "Användarnamn" + +#~ msgid "Read" +#~ msgstr "Läsa" + +#~ msgid "Write" +#~ msgstr "Skriva" + +#~ msgid "Mailbox %s does not exist" +#~ msgstr "Brevlåda %s existerar inte" + +#~ msgid "General" +#~ msgstr "Generellt" + +#~ msgid "Rights" +#~ msgstr "Rättigheter" + +#~ msgid "New calendar" +#~ msgstr "Ny kalendrar" + +#~ msgid "Calendar created" +#~ msgstr "Kalender skapad" + +#~ msgid "Calendar updated" +#~ msgstr "Kalender uppdaterad" + +#~ msgid "User" +#~ msgstr "Användare" + +#~ msgid "Shared" +#~ msgstr "Delad" + +#~ msgid "To access this calendar, use the following address:" +#~ msgstr "För att få tillgång till denna kalender, använd följande adress:" + +#~ msgid "Name" +#~ msgstr "Namn" + +#~ msgid "Owner" +#~ msgstr "Ägare" + +#~ msgid "Tags" +#~ msgstr "Tagg" + +#~ msgid "Actions" +#~ msgstr "Åtgärder" + +#~ msgid "Add calendar" +#~ msgstr "Lägg till kalender" + +#~ msgid "Add shared calendar" +#~ msgstr "Lägg till delad kalender" + +#~ msgid "View calendar detail" +#~ msgstr "Visa kalender detaljer" + +#~ msgid "Edit %s" +#~ msgstr "Redigera %s" + +#~ msgid "Delete %s?" +#~ msgstr "Radera %s?" + +#~ msgid "Calendar removed" +#~ msgstr "Kalender flyttad" + +#~ msgid "New shared calendar" +#~ msgstr "Ny delad kalender" + +#~ msgid "Submit" +#~ msgstr "Skicka" diff --git a/modoboa/calendars/locale/zh_TW/LC_MESSAGES/django.po b/modoboa/calendars/locale/zh_TW/LC_MESSAGES/django.po new file mode 100644 index 000000000..dd3f2064f --- /dev/null +++ b/modoboa/calendars/locale/zh_TW/LC_MESSAGES/django.po @@ -0,0 +1,101 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# +# Translators: +# akong , 2018 +# Radium , 2017 +msgid "" +msgstr "" +"Project-Id-Version: Modoboa\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2020-05-06 14:22+0200\n" +"PO-Revision-Date: 2018-06-28 09:32+0000\n" +"Last-Translator: akong \n" +"Language-Team: Chinese (Taiwan) (http://www.transifex.com/tonio/modoboa/" +"language/zh_TW/)\n" +"Language: zh_TW\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#: forms.py:16 +msgid "Server settings" +msgstr "伺服器設定" + +#: forms.py:20 +msgid "Server URL" +msgstr "伺服器 URL" + +#: forms.py:22 +msgid "" +"The URL of your Radicale server. It will be used to construct calendar URLs." +msgstr "此網址是您的 Radicale 伺服器。這是用於行事曆的網址。" + +#: forms.py:29 +msgid "Rights management" +msgstr "管理權限" + +#: forms.py:32 +msgid "Rights file's path" +msgstr "檔案路徑的權限" + +#: forms.py:35 +msgid "Path to the file that contains rights definition" +msgstr "這個檔案的路徑包含定義的權限" + +#: forms.py:41 +msgid "Allow calendars administration" +msgstr "允許行事曆管理" + +#: forms.py:44 +msgid "Allow domain administrators to manage user calendars (read and write)" +msgstr "允許網域管理者管理使用者的行事曆 (讀和寫)" + +#: forms.py:50 +msgid "Miscellaneous" +msgstr "" + +#: forms.py:53 +msgid "Maximum size of ICS files" +msgstr "" + +#: forms.py:56 +msgid "" +"Maximum size in bytes of imported ICS files (or KB, MB, GB if specified)" +msgstr "" + +#: handlers.py:52 +msgid "Calendars" +msgstr "行事曆" + +#: models.py:53 models.py:70 +msgid "Server location is not set, please fix it." +msgstr "伺服器位置未設定,請修正。" + +#: modo_extension.py:17 +msgid "Radicale management" +msgstr "Radicale 管理" + +#: modo_extension.py:22 +msgid "Management frontend for Radicale, a simple calendar and contact server." +msgstr "管理 Radicale 的前台,簡單的行事曆和連絡人伺服器。" + +#: serializers.py:173 serializers.py:175 +msgid "This field is required." +msgstr "這個欄位是必填的" + +#: templates/modoboa_radicale/calendar_display.html:6 +msgid "Calendar" +msgstr "行事曆" + +#: views.py:20 +#, fuzzy +#| msgid "This field is required." +msgid "A mailbox is required" +msgstr "這個欄位是必填的" + +#: viewsets.py:199 +msgid "Uploaded file is too big (max: {} bytes)" +msgstr "" diff --git a/modoboa/calendars/management/__init__.py b/modoboa/calendars/management/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/modoboa/calendars/management/commands/__init__.py b/modoboa/calendars/management/commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/modoboa/calendars/management/commands/generate_rights.py b/modoboa/calendars/management/commands/generate_rights.py new file mode 100644 index 000000000..bcbcf28cf --- /dev/null +++ b/modoboa/calendars/management/commands/generate_rights.py @@ -0,0 +1,147 @@ +"""Management command to handle file generation.""" + +import os +import datetime + +from django.core.management.base import BaseCommand +from django.utils import timezone + +from modoboa.admin.models import Domain +from modoboa.core.models import User +from modoboa.parameters import tools as param_tools + +from ... import models +from ...modo_extension import Radicale + + +class Command(BaseCommand): + """Management command to handle file generation.""" + + help = "Generate Radicale rights file" + + def add_arguments(self, parser): + """Add extra arguments to command line.""" + parser.add_argument( + "--force", + action="store_true", + default=False, + help="Force generation of rights file", + ) + + def _generate_acr(self, name, user, collection, perm="rw", comment=None): + """Write a new access control rule to the config file.""" + if comment is not None: + self._cfgfile.write("\n# %s" % comment) + self._cfgfile.write( + """ +[%s] +user = %s +collection = %s +permissions = %s +""" + % (name, user, collection, perm) + ) + + def _user_access_rules(self): + """Create user access rules.""" + for acr in models.AccessRule.objects.select_related().all(): + section = "%s-to-%s-acr" % (acr.mailbox, acr.calendar) + permission = "" + if acr.read: + permission += "r" + if acr.write: + permission += "w" + self._generate_acr( + section, + acr.mailbox.full_address, + acr.calendar.path, + permission, + ) + + def _token_access_rules(self): + """Create token access rules.""" + for model in [models.UserCalendar, models.SharedCalendar]: + for calendar in model.objects.all(): + section = "token-{}-access".format(calendar._path) + self._generate_acr( + section, + calendar.access_token, + calendar._path, + perm="r", + comment="Read-only access using a token", + ) + + def _super_admin_rules(self): + """Generate access rules for super administrators.""" + for sa in User.objects.filter(is_superuser=True): + section = "sa-%s-acr" % sa.username + self._generate_acr(section, sa.username, ".*") + + def _domain_admin_rules(self): + """Generate access rules for domain adminstrators.""" + for da in User.objects.filter(groups__name="DomainAdmins"): + for domain in Domain.objects.get_for_admin(da): + section = "da-%s-to-%s-acr" % (da.email, domain.name) + self._generate_acr(section, da.email, "%s/user/.*" % domain.name) + + def _generate_file(self, target): + """ + A user must not declare a rule for his direct admin! + """ + self._cfgfile = open(target, "w") + self._cfgfile.write( + """# Rights management file for Radicale +# This file was generated by Modoboa on %s +# DO NOT EDIT MANUALLY! + """ + % datetime.datetime.today() + ) + + allow_calendars_administration = param_tools.get_global_parameter( + "allow_calendars_administration", app="calendars" + ) + if allow_calendars_administration: + self._super_admin_rules() + self._domain_admin_rules() + + # self._generate_acr( + # "domain-shared-calendars", r"^(.+)@(.+)$", r"{1}/.+$", + # comment="Access rule to domain shared calendars" + # ) + self._generate_acr( + "principal", + r".+", + r"{user}", + perm="RW", + comment="Allow reading and writing principal collection " + "(same as user name)", + ) + self._generate_acr( + "calendars", + r".+", + r"{user}/[^/]+", + comment="Allow reading and writing calendars and address books " + "that are direct children of the principal collection", + ) + + self._user_access_rules() + self._token_access_rules() + self._cfgfile.close() + + def handle(self, *args, **options): + """Command entry point.""" + Radicale().load() + path = param_tools.get_global_parameter("rights_file_path", app="calendars") + if not options["force"]: + try: + mtime = datetime.datetime.fromtimestamp(int(os.path.getmtime(path))) + except OSError: + pass + else: + tz = timezone.get_current_timezone() + qset = models.AccessRule.objects.filter( + last_update__gt=mtime.replace(tzinfo=tz) + ) + if not qset.exists(): + return + self._generate_file(path) diff --git a/modoboa/calendars/migrations/0001_initial.py b/modoboa/calendars/migrations/0001_initial.py new file mode 100644 index 000000000..98288afd2 --- /dev/null +++ b/modoboa/calendars/migrations/0001_initial.py @@ -0,0 +1,103 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("admin", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="AccessRule", + fields=[ + ( + "id", + models.AutoField( + verbose_name="ID", + serialize=False, + auto_created=True, + primary_key=True, + ), + ), + ("read", models.BooleanField(default=False)), + ("write", models.BooleanField(default=False)), + ("last_update", models.DateTimeField(auto_now=True)), + ], + options={ + "db_table": "radicale_accessrule", + }, + bases=(models.Model,), + ), + migrations.CreateModel( + name="SharedCalendar", + fields=[ + ( + "id", + models.AutoField( + verbose_name="ID", + serialize=False, + auto_created=True, + primary_key=True, + ), + ), + ("name", models.CharField(max_length=200)), + ( + "domain", + models.ForeignKey(to="admin.Domain", on_delete=models.CASCADE), + ), + ], + options={ + "abstract": False, + "db_table": "radicale_sharedcalendar", + }, + bases=(models.Model,), + ), + migrations.CreateModel( + name="UserCalendar", + fields=[ + ( + "id", + models.AutoField( + verbose_name="ID", + serialize=False, + auto_created=True, + primary_key=True, + ), + ), + ("name", models.CharField(max_length=200)), + ( + "mailbox", + models.ForeignKey(to="admin.Mailbox", on_delete=models.CASCADE), + ), + ], + options={ + "abstract": False, + "db_table": "radicale_usercalendar", + }, + bases=(models.Model,), + ), + migrations.AddField( + model_name="accessrule", + name="calendar", + field=models.ForeignKey( + related_name="rules", + to="calendars.UserCalendar", + on_delete=models.CASCADE, + ), + preserve_default=True, + ), + migrations.AddField( + model_name="accessrule", + name="mailbox", + field=models.ForeignKey(to="admin.Mailbox", on_delete=models.CASCADE), + preserve_default=True, + ), + migrations.AlterUniqueTogether( + name="accessrule", + unique_together=set([("mailbox", "calendar")]), + ), + ] diff --git a/modoboa/calendars/migrations/0002_auto_20170831_1721.py b/modoboa/calendars/migrations/0002_auto_20170831_1721.py new file mode 100644 index 000000000..ba1ff2da6 --- /dev/null +++ b/modoboa/calendars/migrations/0002_auto_20170831_1721.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.7 on 2017-08-31 15:21 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("calendars", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="sharedcalendar", + name="color", + field=models.CharField(default="#3a87ad", max_length=7), + ), + migrations.AddField( + model_name="usercalendar", + name="color", + field=models.CharField(default="#3a87ad", max_length=7), + ), + ] diff --git a/modoboa/calendars/migrations/0003_auto_20180324_1834.py b/modoboa/calendars/migrations/0003_auto_20180324_1834.py new file mode 100644 index 000000000..79a7f9cfb --- /dev/null +++ b/modoboa/calendars/migrations/0003_auto_20180324_1834.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.11 on 2018-03-24 17:34 +from __future__ import unicode_literals + +from django.db import migrations, models + + +def set_path(apps, schema_editor): + """Set path.""" + UserCalendar = apps.get_model("calendars", "UserCalendar") + for calendar in UserCalendar.objects.select_related("mailbox__domain"): + calendar._path = "{}@{}/{}".format( + calendar.mailbox.address, calendar.mailbox.domain.name, calendar.name + ) + calendar.save() + SharedCalendar = apps.get_model("calendars", "SharedCalendar") + for calendar in SharedCalendar.objects.select_related("domain"): + calendar._path = "{}/{}".format(calendar.domain.name, calendar.name) + calendar.save() + + +def backwards(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ("calendars", "0002_auto_20170831_1721"), + ] + + operations = [ + migrations.AddField( + model_name="sharedcalendar", + name="_path", + field=models.TextField(default=""), + preserve_default=False, + ), + migrations.AddField( + model_name="usercalendar", + name="_path", + field=models.TextField(default=""), + preserve_default=False, + ), + migrations.RunPython(set_path, backwards), + ] diff --git a/modoboa/calendars/migrations/0004_auto_20200220_1816.py b/modoboa/calendars/migrations/0004_auto_20200220_1816.py new file mode 100644 index 000000000..d77c473ce --- /dev/null +++ b/modoboa/calendars/migrations/0004_auto_20200220_1816.py @@ -0,0 +1,49 @@ +# Generated by Django 2.2.9 on 2020-02-20 17:16 + +try: + from secrets import token_hex +except ImportError: + from os import urandom + + def token_hex(nbytes=None): + return urandom(nbytes).hex() + + +from django.db import migrations, models + + +def fill_access_tokens(apps, schema_editor): + """Fill access tokens.""" + UserCalendar = apps.get_model("calendars", "UserCalendar") + SharedCalendar = apps.get_model("calendars", "SharedCalendar") + for calendar in UserCalendar.objects.all(): + calendar.access_token = token_hex(24) + calendar.save(update_fields=["access_token"]) + for calendar in SharedCalendar.objects.all(): + calendar.access_token = token_hex(24) + calendar.save(update_fields=["access_token"]) + + +def backwards(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ("calendars", "0003_auto_20180324_1834"), + ] + + operations = [ + migrations.AddField( + model_name="sharedcalendar", + name="access_token", + field=models.CharField(max_length=50, null=True), + ), + migrations.AddField( + model_name="usercalendar", + name="access_token", + field=models.CharField(max_length=50, null=True), + ), + migrations.RunPython(fill_access_tokens, backwards), + ] diff --git a/modoboa/calendars/migrations/0005_auto_20200220_1819.py b/modoboa/calendars/migrations/0005_auto_20200220_1819.py new file mode 100644 index 000000000..166359e51 --- /dev/null +++ b/modoboa/calendars/migrations/0005_auto_20200220_1819.py @@ -0,0 +1,23 @@ +# Generated by Django 2.2.9 on 2020-02-20 17:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("calendars", "0004_auto_20200220_1816"), + ] + + operations = [ + migrations.AlterField( + model_name="sharedcalendar", + name="access_token", + field=models.CharField(max_length=50), + ), + migrations.AlterField( + model_name="usercalendar", + name="access_token", + field=models.CharField(max_length=50), + ), + ] diff --git a/modoboa/calendars/migrations/__init__.py b/modoboa/calendars/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/modoboa/calendars/mocks.py b/modoboa/calendars/mocks.py new file mode 100644 index 000000000..bc17cd742 --- /dev/null +++ b/modoboa/calendars/mocks.py @@ -0,0 +1,89 @@ +"""Radicale test mocks.""" + +from caldav import objects +from caldav.lib.url import URL + + +EV1 = """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Example Corp.//CalDAV Client//EN +BEGIN:VEVENT +UID:20010712T182145Z-123401@example.com +DTSTAMP:20060712T182145Z +DTSTART:20060714T170000Z +DTEND:20060715T040000Z +SUMMARY:Bastille Day Party +END:VEVENT +END:VCALENDAR +""" + +EV2 = """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Example Corp.//CalDAV Client//EN +BEGIN:VEVENT +UID:20010712T182145Z-123401@example.com +DTSTAMP:20070712T182145Z +DTSTART:20070714T170000Z +DTEND:20070715T040000Z +SUMMARY:Bastille Day Party +1year +END:VEVENT +END:VCALENDAR +""" + + +class Url(object): + + def __init__(self, path): + self.path = path + + def geturl(self): + return self.path + + +class Calendar: + + def __init__(self, client=None, url=None, parent=None, name=None, id=None, **extra): + self.url = Url(url) + self.client = client + + def add_event(self, data): + return True + + def event_by_url(self, url): + res = objects.Event(url=url, data=EV1, parent=self) + return res + + def date_search(self, start, end): + return [ + objects.Event(data=EV1, parent=self), + objects.Event(data=EV2, parent=self), + ] + + def set_properties(self, properties): + return True + + +class Response: + """Fake requests response.""" + + def __init__(self, status): + self.status = status + self.raw = "" + + +class DAVClientMock: + """Mock class for DAVClient instance.""" + + url = URL.objectify("http://localhost") + + def mkcalendar(self, url): + return True + + def delete(self, url): + return True + + def proppatch(self, url, body, dummy=None): + return Response(200) + + def request(self, url, method="GET", body="", headers={}): + return Response(200) diff --git a/modoboa/calendars/models.py b/modoboa/calendars/models.py new file mode 100644 index 000000000..f2ee8de1d --- /dev/null +++ b/modoboa/calendars/models.py @@ -0,0 +1,167 @@ +"""Radicale extension models.""" + +import os + +from six.moves import urllib + +from django.db import models +from django.utils.encoding import smart_str +from django.utils.translation import gettext as _ + +from modoboa.admin.models import Domain, Mailbox + +from modoboa.lib import exceptions as lib_exceptions +from modoboa.parameters import tools as param_tools + + +class Calendar(models.Model): + """Abstract calendar definition.""" + + name = models.CharField(max_length=200) + color = models.CharField(max_length=7, default="#3a87ad") + _path = models.TextField() + access_token = models.CharField(max_length=50) + + class Meta: + abstract = True + + def __str__(self): + return smart_str(self.name) + + @property + def path(self): + """Return the calendar path.""" + return self._path + + @property + def encoded_path(self): + """Return url encoded path.""" + return urllib.parse.quote(smart_str(self._path)) + + @property + def full_url(self): + """Return the calendar URL.""" + if not hasattr(self, "_url"): + server_location = param_tools.get_global_parameter("server_location") + if not server_location: + raise lib_exceptions.InternalError( + _("Server location is not set, please fix it.") + ) + self._url = os.path.join(server_location, self._path) + return self._url + + @property + def share_url(self): + """Return calendar share url.""" + return "{}?token={}".format(self.full_url, self.access_token) + + @property + def encoded_url(self): + """Return encoded url.""" + if not hasattr(self, "_encoded_url"): + server_location = param_tools.get_global_parameter("server_location") + if not server_location: + raise lib_exceptions.InternalError( + _("Server location is not set, please fix it.") + ) + self._encoded_url = os.path.join(server_location, self.encoded_path) + return self._encoded_url + + @property + def tags(self): + """Return calendar tags.""" + raise NotImplementedError + + @property + def owner(self): + """Return calendar owner.""" + raise NotImplementedError + + +class UserCalendarManager(models.Manager): + """Custom UserCalendar manager.""" + + def get_for_admin(self, admin): + """Return the list of calendars this admin can access. + + :param ``core.User`` admin: administrator + """ + domains = Domain.objects.get_for_admin(admin) + return self.get_queryset().filter(mailbox__domain__in=domains) + + +class UserCalendar(Calendar): + """User calendar. + + We associate the calendar to a mailbox because we need to access + the related domain. + + """ + + mailbox = models.ForeignKey(Mailbox, on_delete=models.CASCADE) + + objects = UserCalendarManager() + + class Meta(Calendar.Meta): + db_table = "radicale_usercalendar" + + @property + def owner(self): + return self.mailbox.user + + +class SharedCalendarManager(models.Manager): + """Custom SharedCalendar manager.""" + + def get_for_admin(self, admin): + """Return the list of calendars this admin can access. + + :param ``core.User`` admin: administrator + """ + domains = Domain.objects.get_for_admin(admin) + return self.get_queryset().filter(domain__in=domains) + + +class SharedCalendar(Calendar): + """Shared calendar. + + A shared calendar is associated to a domain and is readable and + writable by all domain members. + + """ + + domain = models.ForeignKey(Domain, on_delete=models.CASCADE) + + objects = SharedCalendarManager() + + class Meta(Calendar.Meta): + db_table = "radicale_sharedcalendar" + + @property + def owner(self): + """Return calendar owner.""" + return self.domain + + +class AccessRule(models.Model): + """Access rules to user calendars.""" + + mailbox = models.ForeignKey(Mailbox, on_delete=models.CASCADE) + read = models.BooleanField(default=False) + write = models.BooleanField(default=False) + calendar = models.ForeignKey( + UserCalendar, related_name="rules", on_delete=models.CASCADE + ) + last_update = models.DateTimeField(auto_now=True) + + class Meta: + unique_together = ("mailbox", "calendar") + db_table = "radicale_accessrule" + + def __str__(self): + access = "r" if self.read else "" + access += "w" if self.write else "" + return smart_str( + "%s access rule to %s -> %s" + % (self.mailbox, self.calendar, access if access else "no access") + ) diff --git a/modoboa/calendars/serializers.py b/modoboa/calendars/serializers.py new file mode 100644 index 000000000..5bec9d239 --- /dev/null +++ b/modoboa/calendars/serializers.py @@ -0,0 +1,244 @@ +"""Calendar serializers.""" + +from django.utils.translation import gettext as _ + +from rest_framework import serializers + +from modoboa.admin import models as admin_models +from modoboa.lib import fields as lib_fields + +from . import backends +from . import models + + +class CalDAVCalendarMixin: + """Mixin for calendar serializers.""" + + def create_remote_calendar(self, calendar): + """Create caldav calendar.""" + request = self.context["request"] + backend = backends.get_backend_from_request("caldav_", request) + backend.create_calendar(calendar.encoded_url) + + def update_remote_calendar(self, calendar): + """Update caldav calendar.""" + request = self.context["request"] + backend = backends.get_backend_from_request("caldav_", request) + backend.update_calendar(calendar) + + +class UserCalendarSerializer(CalDAVCalendarMixin, serializers.ModelSerializer): + """User calendar serializer.""" + + class Meta: + model = models.UserCalendar + fields = ("pk", "name", "color", "path", "full_url", "share_url") + read_only_fields = ("pk", "path", "full_url", "share_url") + + def create(self, validated_data): + """Use current user.""" + user = self.context["request"].user + calendar = models.UserCalendar.objects.create( + mailbox=user.mailbox, **validated_data + ) + self.create_remote_calendar(calendar) + return calendar + + def update(self, instance, validated_data): + """Update calendar.""" + old_name = instance.name + old_color = instance.color + for key, value in validated_data.items(): + setattr(instance, key, value) + instance.save() + if old_name != instance.name or old_color != instance.color: + self.update_remote_calendar(instance) + return instance + + +class DomainSerializer(serializers.ModelSerializer): + """Domain serializer.""" + + pk = serializers.IntegerField() + name = serializers.CharField() + + class Meta: + model = admin_models.Domain + fields = ("pk", "name") + read_only_fields = ( + "pk", + "name", + ) + + +class SharedCalendarSerializer(CalDAVCalendarMixin, serializers.ModelSerializer): + """Shared calendar serializer.""" + + domain = DomainSerializer() + + class Meta: + model = models.SharedCalendar + fields = ("pk", "name", "color", "path", "domain", "full_url", "share_url") + read_only_fields = ("pk", "path", "full_url", "share_url") + + def create(self, validated_data): + """Create shared calendar.""" + domain = validated_data.pop("domain") + calendar = models.SharedCalendar(**validated_data) + calendar.domain_id = domain["pk"] + calendar.save() + self.create_remote_calendar(calendar) + return calendar + + def update(self, instance, validated_data): + """Update calendar.""" + domain = validated_data.pop("domain") + old_name = instance.name + old_color = instance.color + for key, value in validated_data.items(): + setattr(instance, key, value) + instance.domain_id = domain["pk"] + instance.save() + if old_name != instance.name or old_color != instance.color: + self.update_remote_calendar(instance) + return instance + + +class AttendeeSerializer(serializers.Serializer): + """Attendee serializer.""" + + display_name = serializers.CharField() + email = serializers.EmailField() + + +class EventSerializer(serializers.Serializer): + """Base event serializer (fullcalendar output).""" + + id = serializers.CharField(read_only=True) + title = serializers.CharField() + start = serializers.DateTimeField() + end = serializers.DateTimeField() + allDay = serializers.BooleanField(default=False) + color = serializers.CharField(read_only=True) + description = serializers.CharField(required=False) + + attendees = AttendeeSerializer(many=True, required=False) + + +class ROEventSerializer(EventSerializer): + """Event serializer for read operations.""" + + def __init__(self, *args, **kwargs): + """Set calendar field based on type.""" + calendar_type = kwargs.pop("calendar_type", None) + super().__init__(*args, **kwargs) + self.fields["calendar"] = ( + SharedCalendarSerializer() + if calendar_type != "user" + else UserCalendarSerializer() + ) + + +class WritableEventSerializer(EventSerializer): + """Event serializer for write operations.""" + + calendar = serializers.PrimaryKeyRelatedField( + queryset=models.UserCalendar.objects.none() + ) + new_calendar_type = serializers.CharField(required=False) + + def __init__(self, *args, **kwargs): + """Set calendar list.""" + calendar_type = kwargs.pop("calendar_type") + super(EventSerializer, self).__init__(*args, **kwargs) + self.update_calendar_field(calendar_type) + + def update_calendar_field(self, calendar_type): + """Update field based on given type.""" + user = self.context["request"].user + if user.is_anonymous: + return + if calendar_type == "user": + self.fields["calendar"].queryset = models.UserCalendar.objects.filter( + mailbox__user=user + ) + elif hasattr(user, "mailbox"): + self.fields["calendar"].queryset = models.SharedCalendar.objects.filter( + domain=user.mailbox.domain + ) + + def validate(self, data): + """Make sure dates are present with allDay flag.""" + errors = {} + if "allDay" in data: + if "start" not in data: + errors["start"] = _("This field is required.") + if "end" not in data: + errors["end"] = _("This field is required.") + if errors: + raise serializers.ValidationError(errors) + return data + + +class MailboxSerializer(serializers.ModelSerializer): + """Mailbox serializer.""" + + pk = serializers.IntegerField() + full_address = lib_fields.DRFEmailFieldUTF8() + + class Meta: + model = admin_models.Mailbox + fields = ("pk", "full_address") + read_only_fields = ( + "pk", + "full_address", + ) + + +class AccessRuleSerializer(serializers.ModelSerializer): + """AccessRule serializer.""" + + mailbox = MailboxSerializer() + + class Meta: + model = models.AccessRule + fields = ("pk", "mailbox", "calendar", "read", "write") + + def create(self, validated_data): + """Create access rule.""" + mailbox = validated_data.pop("mailbox") + rule = models.AccessRule(**validated_data) + rule.mailbox_id = mailbox["pk"] + rule.save() + return rule + + def update(self, instance, validated_data): + """Update access rule.""" + mailbox = validated_data.pop("mailbox") + for key, value in validated_data.items(): + setattr(instance, key, value) + instance.mailbox_id = mailbox["pk"] + instance.save() + return instance + + +class CheckTokenSerializer(serializers.Serializer): + """Serializer for the check_token action.""" + + calendar = serializers.CharField() + token = serializers.CharField() + + +class ImportFromFileSerializer(serializers.Serializer): + """Serializer for the import_from_file action.""" + + ics_file = serializers.FileField() + + +class GlobalParametersSerializer(serializers.Serializer): + """A serializer for global parameters.""" + + server_location = serializers.CharField(default="") + rights_file_path = serializers.CharField(default="/etc/radicale/rights") + allow_calendars_administration = serializers.BooleanField(default=False) + max_ics_file_size = serializers.CharField(default="10240") diff --git a/modoboa/calendars/test_data/events.ics b/modoboa/calendars/test_data/events.ics new file mode 100644 index 000000000..d2c8d4112 --- /dev/null +++ b/modoboa/calendars/test_data/events.ics @@ -0,0 +1,42 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//ical.marudot.com//iCal Event Maker +X-WR-CALNAME:Test +NAME:Test +CALSCALE:GREGORIAN +BEGIN:VTIMEZONE +TZID:Europe/Berlin +TZURL:http://tzurl.org/zoneinfo-outlook/Europe/Berlin +X-LIC-LOCATION:Europe/Berlin +BEGIN:DAYLIGHT +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +TZNAME:CEST +DTSTART:19700329T020000 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU +END:DAYLIGHT +BEGIN:STANDARD +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +TZNAME:CET +DTSTART:19701025T030000 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +DTSTAMP:20200224T100900Z +UID:20200224T100900Z-1262895527@marudot.com +DTSTART;TZID=Europe/Berlin:20200224T120000 +DTEND;TZID=Europe/Berlin:20200224T150000 +SUMMARY:Test event +DESCRIPTION:Test +END:VEVENT +BEGIN:VEVENT +DTSTAMP:20200224T100900Z +UID:20200224T100900Z-638087544@marudot.com +DTSTART;TZID=Europe/Berlin:20200227T120000 +DTEND;TZID=Europe/Berlin:20200227T150000 +SUMMARY:Test event 2 +DESCRIPTION:Test 2 +END:VEVENT +END:VCALENDAR \ No newline at end of file diff --git a/modoboa/calendars/tests.py b/modoboa/calendars/tests.py new file mode 100644 index 000000000..665db6c98 --- /dev/null +++ b/modoboa/calendars/tests.py @@ -0,0 +1,583 @@ +# coding: utf-8 + +"""Radicale extension unit tests.""" + +import os +import tempfile +from unittest import mock + +from configparser import ConfigParser + +from django.urls import reverse +from django.core import management + +from modoboa.admin import factories as admin_factories +from modoboa.admin import models as admin_models +from modoboa.core import factories as core_factories +from modoboa.core import models as core_models +from modoboa.lib.tests import ModoTestCase, ModoAPITestCase + +from modoboa.admin.factories import populate_database + +from . import factories +from . import models +from . import mocks + + +class TestDataMixin(object): + """Create some data.""" + + @classmethod + def setUpTestData(cls): + """Create test data.""" + super(TestDataMixin, cls).setUpTestData() + admin_factories.populate_database() + cls.account = core_models.User.objects.get(username="user@test.com") + cls.calendar = factories.UserCalendarFactory(mailbox=cls.account.mailbox) + cls.admin_account = core_models.User.objects.get(username="admin@test.com") + cls.calendar2 = factories.UserCalendarFactory(mailbox=cls.admin_account.mailbox) + cls.acr1 = factories.AccessRuleFactory( + calendar=cls.calendar, + mailbox=cls.admin_account.mailbox, + read=True, + write=True, + ) + cls.account2 = core_factories.UserFactory( + username="user2@test.com", + groups=("SimpleUsers",), + ) + admin_factories.MailboxFactory.create( + address="user2", domain=cls.account.mailbox.domain, user=cls.account2 + ) + cls.domain = admin_models.Domain.objects.get(name="test.com") + cls.scalendar = factories.SharedCalendarFactory(domain=cls.domain) + cls.domain2 = admin_models.Domain.objects.get(name="test2.com") + cls.scalendar2 = factories.SharedCalendarFactory(domain=cls.domain2) + + +class AccessRuleTestCase(ModoTestCase): + + @classmethod + def setUpTestData(cls): + """Create test data.""" + super(AccessRuleTestCase, cls).setUpTestData() + populate_database() + + def setUp(self): + """Initialize tests.""" + super(AccessRuleTestCase, self).setUp() + self.rights_file_path = tempfile.mktemp() + self.set_global_parameter( + "rights_file_path", self.rights_file_path, app="calendars" + ) + + def tearDown(self): + os.unlink(self.rights_file_path) + + def test_rights_file_generation(self): + mbox = admin_models.Mailbox.objects.get( + address="admin", domain__name="test.com" + ) + cal = factories.UserCalendarFactory(mailbox=mbox) + + factories.AccessRuleFactory( + mailbox=admin_models.Mailbox.objects.get( + address="user", domain__name="test.com" + ), + calendar=cal, + read=True, + ) + management.call_command("generate_rights", verbosity=False) + + cfg = ConfigParser() + with open(self.rights_file_path) as fpo: + cfg.read_file(fpo) + + # Check mandatory rules + # self.assertTrue(cfg.has_section("domain-shared-calendars")) + self.assertTrue(cfg.has_section("principal")) + self.assertTrue(cfg.has_section("calendars")) + + # Check user-defined rules + section = "user@test.com-to-User calendar 0-acr" + self.assertTrue(cfg.has_section(section)) + self.assertEqual(cfg.get(section, "user"), "user@test.com") + self.assertEqual( + cfg.get(section, "collection"), "admin@test.com/User calendar 0" + ) + self.assertEqual(cfg.get(section, "permissions"), "r") + + # Call a second time + management.call_command("generate_rights", verbosity=False) + + def test_rights_file_generation_with_admin(self): + self.set_global_parameter( + "allow_calendars_administration", True, app="calendars" + ) + management.call_command("generate_rights", verbosity=False) + cfg = ConfigParser() + with open(self.rights_file_path) as fpo: + cfg.read_file(fpo) + + # Check mandatory rules + # self.assertTrue(cfg.has_section("domain-shared-calendars")) + self.assertTrue(cfg.has_section("principal")) + self.assertTrue(cfg.has_section("calendars")) + + # Super admin rules + section = "sa-admin-acr" + self.assertTrue(cfg.has_section(section)) + + # Domain admin rules + for section in [ + "da-admin@test.com-to-test.com-acr", + "da-admin@test2.com-to-test2.com-acr", + ]: + self.assertTrue(cfg.has_section(section)) + + +class UserCalendarViewSetTestCase(TestDataMixin, ModoAPITestCase): + """UserCalendar viewset tests.""" + + def setUp(self): + """Initiate test context.""" + self.client.force_login(self.account) + self.set_global_parameter("server_location", "http://localhost:5232") + + def test_get_calendars(self): + """List or retrieve calendars.""" + url = reverse("api:user-calendar-list") + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.json()), 1) + + url = reverse("api:user-calendar-detail", args=[self.calendar.pk]) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + @mock.patch("caldav.DAVClient") + def test_create_calendar(self, client_mock): + """Create a new calendar.""" + client_mock.return_value = mocks.DAVClientMock() + data = {"username": "user@test.com", "password": "toto"} + response = self.client.post(reverse("core:login"), data) + + data = {"name": "Test calendaré", "color": "#ffffff"} + url = reverse("api:user-calendar-list") + response = self.client.post(url, data, format="json") + self.assertEqual(response.status_code, 201) + + @mock.patch("caldav.DAVClient") + @mock.patch("caldav.Calendar") + def test_update_calendar(self, cal_mock, client_mock): + """Update existing calendar.""" + client_mock.return_value = mocks.DAVClientMock() + cal_mock.return_value = mocks.Calendar() + data = {"username": "user@test.com", "password": "toto"} + self.client.post(reverse("core:login"), data) + + data = {"name": "Modified calendar", "color": "#ffffff"} + url = reverse("api:user-calendar-detail", args=[self.calendar.pk]) + response = self.client.put(url, data, format="json") + self.assertEqual(response.status_code, 200) + oldpath = self.calendar.path + self.calendar.refresh_from_db() + self.assertEqual(self.calendar.name, data["name"]) + self.assertEqual(self.calendar.path, oldpath) + + def test_delete_calendar(self): + """Delete existing calendar.""" + url = reverse("api:user-calendar-detail", args=[self.calendar.pk]) + response = self.client.delete(url) + self.assertEqual(response.status_code, 204) + with self.assertRaises(models.UserCalendar.DoesNotExist): + self.calendar.refresh_from_db() + + def test_check_token(self): + """Check token access.""" + url = reverse("api:user-calendar-check-token") + data = {"calendar": self.calendar._path, "token": self.calendar.access_token} + response = self.client.post(url, data, format="json") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["status"], "ok") + + data["token"] = "pouet" + response = self.client.post(url, data, format="json") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["status"], "ko") + + data["calendar"] = "unknown" + response = self.client.post(url, data, format="json") + self.assertEqual(response.status_code, 404) + + +class SharedCalendarViewSetTestCase(TestDataMixin, ModoAPITestCase): + """SharedCalendar viewset tests.""" + + def setUp(self): + """Initiate test context.""" + self.client.force_login(self.admin_account) + self.set_global_parameter("server_location", "http://localhost:5232") + + def test_get_calendars(self): + """List or retrieve calendars.""" + url = reverse("api:shared-calendar-list") + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.json()), 1) + + url = reverse("api:shared-calendar-detail", args=[self.scalendar.pk]) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + account = core_models.User.objects.get(username="user@test.com") + self.client.force_login(account) + url = reverse("api:shared-calendar-list") + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.json()), 1) + + @mock.patch("caldav.DAVClient") + def test_create_calendar(self, client_mock): + """Create a new calendar.""" + client_mock.return_value = mocks.DAVClientMock() + data = {"username": "admin@test.com", "password": "toto"} + response = self.client.post(reverse("core:login"), data) + + data = { + "name": "Shared calendar", + "color": "#ffffff", + "domain": {"pk": self.domain.pk, "name": "test.com"}, + } + url = reverse("api:shared-calendar-list") + response = self.client.post(url, data, format="json") + self.assertEqual(response.status_code, 201) + + @mock.patch("caldav.DAVClient") + @mock.patch("caldav.Calendar") + def test_update_calendar(self, cal_mock, client_mock): + """Update existing calendar.""" + client_mock.return_value = mocks.DAVClientMock() + cal_mock.return_value = mocks.Calendar() + data = {"username": "admin@test.com", "password": "toto"} + self.client.post(reverse("core:login"), data) + + data = { + "name": "Modified calendar", + "color": "#ffffff", + "domain": {"pk": self.domain.pk, "name": "test.com"}, + } + url = reverse("api:shared-calendar-detail", args=[self.scalendar.pk]) + response = self.client.put(url, data, format="json") + self.assertEqual(response.status_code, 200) + oldpath = self.scalendar.path + self.scalendar.refresh_from_db() + self.assertEqual(self.scalendar.name, data["name"]) + self.assertEqual(self.scalendar.path, oldpath) + + def test_delete_calendar(self): + """Delete existing calendar.""" + url = reverse("api:shared-calendar-detail", args=[self.scalendar.pk]) + response = self.client.delete(url) + self.assertEqual(response.status_code, 204) + with self.assertRaises(models.SharedCalendar.DoesNotExist): + self.scalendar.refresh_from_db() + + def test_check_token(self): + """Check token access.""" + url = reverse("api:shared-calendar-check-token") + data = {"calendar": self.scalendar._path, "token": self.scalendar.access_token} + response = self.client.post(url, data, format="json") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["status"], "ok") + + data["token"] = "pouet" + response = self.client.post(url, data, format="json") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["status"], "ko") + + data["calendar"] = "unknown" + response = self.client.post(url, data, format="json") + self.assertEqual(response.status_code, 404) + + +class AccessRuleViewSetTestCase(TestDataMixin, ModoAPITestCase): + """AccessRule viewset tests.""" + + def setUp(self): + """Initiate test context.""" + self.client.force_login(self.account) + + def test_get_accessrules(self): + """Test access rule retrieval.""" + url = reverse("api:access-rule-list") + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.json()), 1) + + def test_create_accessrule(self): + data = { + "mailbox": { + "pk": self.account2.mailbox.pk, + "full_address": self.account2.mailbox.full_address, + }, + "read": True, + "calendar": self.calendar.pk, + } + url = reverse("api:access-rule-list") + response = self.client.post(url, data=data, format="json") + self.assertEqual(response.status_code, 201) + + def test_update_accessrule(self): + """Test access rule modification.""" + data = { + "mailbox": { + "pk": self.admin_account.pk, + "full_address": self.admin_account.email, + }, + "calendar": self.calendar.pk, + "read": False, + "write": True, + } + url = reverse("api:access-rule-detail", args=[self.acr1.pk]) + response = self.client.put(url, data=data, format="json") + self.assertEqual(response.status_code, 200) + self.acr1.refresh_from_db() + self.assertFalse(self.acr1.read) + + def test_update_accessrule_permission(self): + """Try to modify an access rule the user does not own.""" + calendar = factories.UserCalendarFactory(mailbox=self.admin_account.mailbox) + acr = factories.AccessRuleFactory( + calendar=calendar, mailbox=self.account.mailbox, read=True, write=True + ) + data = { + "mailbox": self.account.pk, + "calendar": calendar.pk, + "read": False, + "write": True, + } + url = reverse("api:access-rule-detail", args=[acr.pk]) + response = self.client.put(url, data=data, format="json") + self.assertEqual(response.status_code, 404) + + def test_delete_accessrule(self): + """Test access rule removal.""" + url = reverse("api:access-rule-detail", args=[self.acr1.pk]) + response = self.client.delete(url) + self.assertEqual(response.status_code, 204) + with self.assertRaises(models.AccessRule.DoesNotExist): + self.acr1.refresh_from_db() + + +class EventViewSetTestCase(TestDataMixin, ModoAPITestCase): + """Event viewset tests.""" + + def setUp(self): + """Initiate test context.""" + patcher1 = mock.patch("caldav.DAVClient") + self.client_mock = patcher1.start() + self.client_mock.return_value = mocks.DAVClientMock() + self.addCleanup(patcher1.stop) + + patcher2 = mock.patch("caldav.Calendar") + self.cal_mock = patcher2.start() + self.cal_mock.return_value = mocks.Calendar(client=self.client_mock) + self.addCleanup(patcher2.stop) + + self.client.force_login(self.account) + self.set_global_parameter("server_location", "http://localhost") + + def test_get_user_events(self): + """Test event(s) retrieval.""" + data = {"username": "user@test.com", "password": "toto"} + self.client.post(reverse("core:login"), data) + + # FIXME: drf nested routers does not handle reverse() properly + url = "/api/v1/user-calendars/{}/events/{}/".format(self.calendar.pk, 1234) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertTrue(response.json()["title"].startswith("Bastille")) + + url = "/api/v1/user-calendars/{}/events/".format(self.calendar.pk) + url = "{}?start={}&end={}".format(url, "20060712T182145Z", "20070712T182145Z") + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.json()), 2) + + def test_get_shared_events(self): + """Test event(s) retrieval.""" + data = {"username": "user@test.com", "password": "toto"} + self.client.post(reverse("core:login"), data) + + # FIXME: drf nested routers does not handle reverse() properly + url = "/api/v1/shared-calendars/{}/events/{}/".format(self.scalendar.pk, 1234) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertTrue(response.json()["title"].startswith("Bastille")) + + url = "/api/v1/shared-calendars/{}/events/".format(self.scalendar.pk) + url = "{}?start={}&end={}".format(url, "20060712T182145Z", "20070712T182145Z") + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.json()), 2) + + def test_create_event(self): + """Test event creation.""" + data = {"username": "user@test.com", "password": "toto"} + self.client.post(reverse("core:login"), data) + + url = "/api/v1/user-calendars/{}/events/".format(self.calendar.pk) + data = { + "title": "Test event", + "start": "2018-03-27T00:00:00Z", + "end": "2018-03-28T00:00:00Z", + "allDay": True, + "color": "#ffdddd", + "description": "Description", + "calendar": self.calendar.pk, + } + response = self.client.post(url, data=data, format="json") + self.assertEqual(response.status_code, 201) + self.assertIn("id", response.json()) + + url = "/api/v1/shared-calendars/{}/events/".format(self.scalendar.pk) + data = { + "title": "Test event 2", + "start": "2018-03-27T00:00:00Z", + "end": "2018-03-28T00:00:00Z", + "allDay": False, + "color": "#ffdddd", + "description": "Description", + "calendar": self.scalendar.pk, + } + response = self.client.post(url, data=data, format="json") + self.assertEqual(response.status_code, 201) + self.assertIn("id", response.json()) + + def test_update_event(self): + """Test event update.""" + data = {"username": "user@test.com", "password": "toto"} + self.client.post(reverse("core:login"), data) + + url = "/api/v1/user-calendars/{}/events/1234/".format(self.calendar.pk) + data = { + "title": "Test event", + "start": "2018-03-27T00:00:00Z", + "end": "2018-03-28T00:00:00Z", + "allDay": True, + "color": "#ffdddd", + "description": "Description", + "calendar": self.calendar.pk, + # "attendees": [{ + # "display_name": "Test User", + # "email": "user@domain.test", + # }] + } + response = self.client.put(url, data=data, format="json") + self.assertEqual(response.status_code, 200) + self.assertIn("id", response.json()) + + def test_patch_event(self): + """Test event partial update.""" + data = {"username": "user@test.com", "password": "toto"} + self.client.post(reverse("core:login"), data) + + url = "/api/v1/user-calendars/{}/events/1234/".format(self.calendar.pk) + data = { + "start": "2018-03-27T00:00:00Z", + "end": "2018-03-28T00:00:00Z", + "allDay": True, + } + response = self.client.patch(url, data=data, format="json") + self.assertEqual(response.status_code, 200) + + def test_move_event_between_cals(self): + """Move an event.""" + data = {"username": "user@test.com", "password": "toto"} + self.client.post(reverse("core:login"), data) + data = { + "title": "Test event", + "start": "2018-03-27T00:00:00Z", + "end": "2018-03-28T00:00:00Z", + "allDay": True, + "color": "#ffdddd", + "description": "Description", + "calendar": self.scalendar.pk, + "new_calendar_type": "shared", + } + url = "/api/v1/user-calendars/{}/events/1234/".format(self.calendar.pk) + response = self.client.put(url, data=data, format="json") + self.assertEqual(response.status_code, 200) + + def test_delete_event(self): + """Test event deletion.""" + data = {"username": "user@test.com", "password": "toto"} + self.client.post(reverse("core:login"), data) + + url = "/api/v1/shared-calendars/{}/events/1234/".format(self.scalendar.pk) + response = self.client.delete(url) + self.assertEqual(response.status_code, 200) + + def test_import_from_file(self): + """Check import feature.""" + data = {"username": "user@test.com", "password": "toto"} + self.client.post(reverse("core:login"), data) + url = reverse("api:user-event-import-from-file", args=[self.calendar.pk]) + path = os.path.join( + os.path.abspath(os.path.dirname(__file__)), "test_data/events.ics" + ) + # File too big => fails + self.set_global_parameter("max_ics_file_size", "1") + with open(path) as fp: + data = {"ics_file": fp} + response = self.client.post(url, data) + self.assertEqual(response.status_code, 400) + self.set_global_parameter("max_ics_file_size", "2048") + with open(path) as fp: + data = {"ics_file": fp} + response = self.client.post(url, data) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["counter"], 2) + + +class AttendeeViewSetTestCase(ModoAPITestCase): + """Attendee viewset test case.""" + + @classmethod + def setUpTestData(cls): + super(AttendeeViewSetTestCase, cls).setUpTestData() + populate_database() + cls.account = core_models.User.objects.get(username="user@test.com") + + def setUp(self): + """Initiate test context.""" + self.client.force_login(self.account) + + def test_get_attendees(self): + """Test attendees retrieval.""" + url = reverse("api:attendee-list") + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.json()), 1) + + +class MailboxViewSetTestCase(ModoAPITestCase): + """Mailbox viewset test case.""" + + @classmethod + def setUpTestData(cls): + super(MailboxViewSetTestCase, cls).setUpTestData() + populate_database() + cls.account = core_models.User.objects.get(username="user@test.com") + + def setUp(self): + """Initiate test context.""" + self.client.force_login(self.account) + + def test_get_mailboxes(self): + """Test mailbox retrieval.""" + url = reverse("api:mailbox-list") + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.json()), 1) diff --git a/modoboa/calendars/urls.py b/modoboa/calendars/urls.py new file mode 100644 index 000000000..ac9e623ef --- /dev/null +++ b/modoboa/calendars/urls.py @@ -0,0 +1,29 @@ +"""Radicale urls.""" + +from rest_framework_nested import routers + +from . import viewsets + +router = routers.SimpleRouter() +router.register( + r"user-calendars", viewsets.UserCalendarViewSet, basename="user-calendar" +) +router.register( + r"shared-calendars", viewsets.SharedCalendarViewSet, basename="shared-calendar" +) +router.register(r"attendees", viewsets.AttendeeViewSet, basename="attendee") +router.register(r"mailboxes", viewsets.MailboxViewSet, basename="mailbox") +router.register(r"accessrules", viewsets.AccessRuleViewSet, basename="access-rule") + +calendars_router = routers.NestedSimpleRouter( + router, r"user-calendars", lookup="calendar" +) +calendars_router.register(r"events", viewsets.UserEventViewSet, basename="user-event") +shared_calendars_router = routers.NestedSimpleRouter( + router, r"shared-calendars", lookup="calendar" +) +shared_calendars_router.register( + r"events", viewsets.SharedEventViewSet, basename="shared-event" +) + +urlpatterns = router.urls + calendars_router.urls + shared_calendars_router.urls diff --git a/modoboa/calendars/viewsets.py b/modoboa/calendars/viewsets.py new file mode 100644 index 000000000..b0ac8c387 --- /dev/null +++ b/modoboa/calendars/viewsets.py @@ -0,0 +1,273 @@ +"""Calendar viewsets.""" + +import os + +import dateutil + +from django import http +from django.utils.translation import gettext as _ + +from django_filters.rest_framework import DjangoFilterBackend +from rest_framework.decorators import action +from rest_framework import permissions, response, viewsets + +from modoboa.admin import models as admin_models +from modoboa.lib.web_utils import size2integer + +from . import backends +from . import models +from . import serializers + + +def parse_date_from_iso(value): + """Return a tz aware datetime parsed from an ISO date.""" + return dateutil.parser.parse(value) + + +class CheckTokenMixin: + """Mixin to provide the check_token feature.""" + + calendar_class = None + + @action( + detail=False, + methods=["post"], + serializer_class=serializers.CheckTokenSerializer, + ) + def check_token(self, request): + """Check token validity.""" + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + calendar = self.calendar_class.objects.filter( + _path=serializer.validated_data["calendar"] + ).first() + if not calendar: + raise http.Http404() + if calendar.access_token != serializer.validated_data["token"]: + return response.Response({"status": "ko"}) + return response.Response({"status": "ok"}) + + +class UserCalendarViewSet(CheckTokenMixin, viewsets.ModelViewSet): + """Calendar viewset.""" + + calendar_class = models.UserCalendar + permission_classes = ( + permissions.IsAuthenticated, + permissions.DjangoModelPermissions, + ) + serializer_class = serializers.UserCalendarSerializer + + def get_queryset(self): + """Filter based on current user.""" + qset = models.UserCalendar.objects.filter(mailbox__user=self.request.user) + return qset + + +class SharedCalendarViewSet(CheckTokenMixin, viewsets.ModelViewSet): + """Shared calendar viewset.""" + + calendar_class = models.SharedCalendar + permission_classes = ( + permissions.IsAuthenticated, + permissions.DjangoModelPermissions, + ) + serializer_class = serializers.SharedCalendarSerializer + + def get_queryset(self): + """Filter based on current user.""" + if self.request.user.role == "SimpleUsers": + return models.SharedCalendar.objects.filter( + domain=self.request.user.mailbox.domain + ) + return models.SharedCalendar.objects.filter( + domain__in=admin_models.Domain.objects.get_for_admin(self.request.user) + ) + + +class BaseEventViewSet(viewsets.ViewSet): + """Event viewset.""" + + lookup_value_regex = r"[0-9a-zA-Z\-\.@]+" + + def get_serializer(self, data=None, **kwargs): + args = [] + options = { + "context": {"request": self.request}, + "calendar_type": self.type, + } + options.update(kwargs) + if data is None: + data = self.request.data + if self.action in ["list", "retrieve"]: + sclass = serializers.ROEventSerializer + args = [data] + else: + sclass = serializers.WritableEventSerializer + options.update({"data": data}) + return sclass(*args, **options) + + def create(self, request, calendar_pk): + """Create new event.""" + serializer = self.get_serializer() + serializer.is_valid(raise_exception=True) + backend = backends.get_backend_from_request( + "caldav_", request, serializer.validated_data["calendar"] + ) + uid = backend.create_event(serializer.validated_data) + event = dict(serializer.validated_data) + calendar = serializer.validated_data["calendar"] + event["id"] = uid + event["color"] = calendar.color + event["calendar"] = {"pk": calendar.pk} + if self.type == "shared": + event["calendar"]["domain"] = calendar.domain.pk + return response.Response(event, status=201) + + def update(self, request, pk, calendar_pk): + """Update existing event.""" + serializer = self.get_serializer() + new_calendar_type = request.data.get("new_calendar_type") + if new_calendar_type: + serializer.update_calendar_field(new_calendar_type) + serializer.is_valid(raise_exception=True) + calendar = self.get_calendar(calendar_pk) + backend = backends.get_backend_from_request("caldav_", request, calendar) + uid = backend.update_event(pk, serializer.validated_data) + event = dict(serializer.data) + event["id"] = uid + return response.Response(event, status=200) + + def partial_update(self, request, pk, calendar_pk): + """Update existing event.""" + serializer = self.get_serializer(partial=True) + serializer.is_valid(raise_exception=True) + calendar = self.get_calendar(calendar_pk) + backend = backends.get_backend_from_request("caldav_", request, calendar) + backend.update_event(pk, serializer.validated_data) + return response.Response(status=200) + + def list(self, request, calendar_pk): + """Get a list of event.""" + start = request.GET.get("start") + end = request.GET.get("end") + if not start or not end: + return response.Response() + events = [] + calendar = self.get_calendar(calendar_pk) + backend = backends.get_backend_from_request("caldav_", request, calendar) + events += backend.get_events( + parse_date_from_iso(start), parse_date_from_iso(end) + ) + serializer = self.get_serializer(events, many=True) + return response.Response(serializer.data) + + def retrieve(self, request, pk, calendar_pk): + """Get a specific event.""" + calendar = self.get_calendar(calendar_pk) + backend = backends.get_backend_from_request("caldav_", request, calendar) + event = backend.get_event(pk) + serializer = self.get_serializer(event) + return response.Response(serializer.data) + + def destroy(self, request, pk, calendar_pk): + """Destroy a specific event.""" + calendar = self.get_calendar(calendar_pk) + backend = backends.get_backend_from_request("caldav_", request, calendar) + backend.delete_event(pk) + return response.Response() + + @action(detail=False, methods=["post"]) + def import_from_file(self, request, calendar_pk): + """Import events from file.""" + serializer = serializers.ImportFromFileSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + calendar = self.get_calendar(calendar_pk) + backend = backends.get_backend_from_request("caldav_", request, calendar) + ics_file = serializer.validated_data["ics_file"] + ics_file.seek(0, os.SEEK_END) + size = ics_file.tell() + max_size = size2integer( + request.localconfig.parameters.get_value("max_ics_file_size") + ) + if size > max_size: + return response.Response( + { + "ics_file": [ + _("Uploaded file is too big (max: {} bytes)").format(max_size) + ] + }, + status=400, + ) + ics_file.seek(0) + counter = backend.import_events(ics_file) + return response.Response({"counter": counter}) + + +class UserEventViewSet(BaseEventViewSet): + + type = "user" + + def get_calendar(self, pk): + """Return UserCalendar instance.""" + return models.UserCalendar.objects.get(pk=pk) + + +class SharedEventViewSet(BaseEventViewSet): + + type = "shared" + + def get_calendar(self, pk): + """Return UserCalendar instance.""" + return models.SharedCalendar.objects.get(pk=pk) + + +class AttendeeViewSet(viewsets.ReadOnlyModelViewSet): + """Attendee viewset.""" + + permission_classes = [permissions.IsAuthenticated] + serializer_class = serializers.AttendeeSerializer + + def list(self, request, *args, **kwargs): + """Return attendees available for current user.""" + domain_pk = request.user.mailbox.domain_id + mb_qset = admin_models.Mailbox.objects.filter( + domain__pk=domain_pk, domain__enabled=True, user__is_active=True + ).exclude(pk=request.user.mailbox.pk) + attendees = [] + for mb in mb_qset: + attendees.append( + {"display_name": mb.user.fullname, "email": mb.full_address} + ) + serializer = serializers.AttendeeSerializer( + attendees, many=True, context={"request": request} + ) + return response.Response(serializer.data) + + +class MailboxViewSet(viewsets.ReadOnlyModelViewSet): + """RO mailbox viewset.""" + + permission_classes = [permissions.IsAuthenticated] + serializer_class = serializers.MailboxSerializer + + def get_queryset(self): + """Filter queryset based on current user.""" + domain_pk = self.request.user.mailbox.domain_id + return admin_models.Mailbox.objects.filter( + domain__pk=domain_pk, domain__enabled=True, user__is_active=True + ).exclude(pk=self.request.user.mailbox.pk) + + +class AccessRuleViewSet(viewsets.ModelViewSet): + """AccessRule viewset.""" + + filter_backends = (DjangoFilterBackend,) + filter_fields = ("calendar",) + permission_classes = [permissions.IsAuthenticated] + serializer_class = serializers.AccessRuleSerializer + + def get_queryset(self): + return models.AccessRule.objects.filter( + calendar__mailbox=self.request.user.mailbox + ) diff --git a/modoboa/contacts/app_settings.py b/modoboa/contacts/app_settings.py new file mode 100644 index 000000000..4570d8aa0 --- /dev/null +++ b/modoboa/contacts/app_settings.py @@ -0,0 +1,54 @@ +import collections + +from django.utils.translation import gettext_lazy + + +USER_PREFERENCES_STRUCT = collections.OrderedDict( + [ + ( + "synchronization", + { + "label": gettext_lazy("Synchronization"), + "params": collections.OrderedDict( + [ + ( + "enable_carddav_sync", + { + "label": gettext_lazy( + "Synchonize address book using CardDAV" + ), + "help_text": gettext_lazy( + "Choose to synchronize or not your address book using CardDAV. " + "You will be able to access your contacts from the outside." + ), + }, + ), + ( + "sync_frequency", + { + "label": gettext_lazy("Synchronization frequency"), + "help_text": gettext_lazy( + "Interval in seconds between 2 synchronization requests" + ), + }, + ), + ] + ), + }, + ) + ] +) + + +def load_settings(): + """Load calendars settings.""" + from modoboa.parameters import tools as param_tools + from modoboa.contacts import serializers + + param_tools.registry.add2( + "user", + "contacts", + gettext_lazy("Contacts"), + USER_PREFERENCES_STRUCT, + serializers.UserPreferencesSerializer, + ) diff --git a/modoboa/contacts/apps.py b/modoboa/contacts/apps.py index 009e23d28..fb3cab3ff 100644 --- a/modoboa/contacts/apps.py +++ b/modoboa/contacts/apps.py @@ -1,15 +1,6 @@ """AppConfig for contacts.""" from django.apps import AppConfig -from django.utils.translation import gettext - - -def load_settings(): - """Load app settings.""" - from modoboa.contacts.forms import UserSettings - from modoboa.parameters import tools as param_tools - - param_tools.registry.add("user", UserSettings, gettext("Contacts")) class ModoboaContactsConfig(AppConfig): @@ -17,5 +8,6 @@ class ModoboaContactsConfig(AppConfig): def ready(self): from . import handlers + from modoboa.contacts.app_settings import load_settings load_settings() diff --git a/modoboa/contacts/forms.py b/modoboa/contacts/forms.py deleted file mode 100644 index a9b100a48..000000000 --- a/modoboa/contacts/forms.py +++ /dev/null @@ -1,59 +0,0 @@ -"""Contacts forms.""" - -from django import forms -from django.utils import timezone -from django.utils.translation import gettext_lazy as _ - -from modoboa.lib import cryptutils -from modoboa.lib import form_utils -from modoboa.lib import signals as lib_signals -from modoboa.parameters import forms as param_forms - -from . import tasks - - -class UserSettings(param_forms.UserParametersForm): - """User settings.""" - - app = "contacts" - - sep1 = form_utils.SeparatorField(label=_("Synchronization")) - - enable_carddav_sync = form_utils.YesNoField( - initial=False, - label=_("Synchonize address book using CardDAV"), - help_text=_( - "Choose to synchronize or not your address book using CardDAV. " - "You will be able to access your contacts from the outside." - ), - ) - - sync_frequency = forms.IntegerField( - initial=300, - label=_("Synchronization frequency"), - help_text=_("Interval in seconds between 2 synchronization requests"), - ) - - visibility_rules = {"sync_frequency": "enable_carddav_sync=True"} - - def clean_sync_frequency(self): - """Make sure frequency is a positive integer.""" - if self.cleaned_data["sync_frequency"] < 60: - raise forms.ValidationError(_("Minimum allowed value is 60s")) - return self.cleaned_data["sync_frequency"] - - def save(self, *args, **kwargs): - """Create remote cal if necessary.""" - super(UserSettings, self).save(*args, **kwargs) - if not self.cleaned_data["enable_carddav_sync"]: - return - abook = self.user.addressbook_set.first() - if abook.last_sync: - return - request = lib_signals.get_request() - tasks.create_cdav_addressbook( - abook, cryptutils.decrypt(request.session["password"]) - ) - if not abook.contact_set.exists(): - abook.last_sync = timezone.now() - abook.save(update_fields=["last_sync"]) diff --git a/modoboa/contacts/handlers.py b/modoboa/contacts/handlers.py index 17fb9e698..46b42e890 100644 --- a/modoboa/contacts/handlers.py +++ b/modoboa/contacts/handlers.py @@ -1,38 +1,13 @@ """Webmail handlers.""" from django.db.models import signals -from django.urls import reverse from django.dispatch import receiver -from django.utils.translation import gettext as _ from modoboa.admin import models as admin_models -from modoboa.core import signals as core_signals -from modoboa.lib import signals as lib_signals from . import models -@receiver(core_signals.get_top_notifications) -def check_addressbook_first_sync(sender, include_all, **kwargs): - """Check if address book first sync has been made.""" - request = lib_signals.get_request() - qset = request.user.addressbook_set.filter(last_sync__isnull=True) - condition = ( - not request.user.parameters.get_value("enable_carddav_sync", app="contacts") - or not qset.exists() - ) - if condition: - return [] - return [ - { - "id": "abook_sync_required", - "url": reverse("modoboa_contacts:index"), - "text": _("Your address book must be synced"), - "level": "warning", - } - ] - - @receiver(signals.post_save, sender=admin_models.Mailbox) def create_addressbook(sender, instance, created, **kwargs): """Create default address book for new mailbox.""" @@ -41,25 +16,3 @@ def create_addressbook(sender, instance, created, **kwargs): models.AddressBook.objects.create( user=instance.user, name="Contacts", _path="contacts" ) - - -# @receiver(core_signals.extra_static_content) -# def inject_sync_poller(sender, caller, st_type, user, **kwargs): -# """Inject javascript code.""" -# condition = ( -# caller != "top" or -# st_type != "js" or -# not hasattr(user, "mailbox") or -# not user.parameters.get_value("enable_carddav_sync") -# ) -# if condition: -# return "" -# return """ -# """ % (reverse("api:addressbook-sync-from-cdav"), -# user.parameters.get_value("sync_frequency")) diff --git a/modoboa/contacts/importer/__init__.py b/modoboa/contacts/importer/__init__.py index 0ae0a65df..76b391812 100644 --- a/modoboa/contacts/importer/__init__.py +++ b/modoboa/contacts/importer/__init__.py @@ -1,6 +1,7 @@ import csv +from typing import Optional -from modoboa_contacts.importer.backends.outlook import OutlookBackend +from modoboa.contacts.importer.backends.outlook import OutlookBackend BACKENDS = [ @@ -28,7 +29,7 @@ def import_csv_file( backend_name: str, csv_filename: str, delimiter: str, - carddav_password: str = None, + carddav_password: Optional[str] = None, ): with open(csv_filename) as fp: backend, rows = get_import_backend(fp, delimiter, backend_name) diff --git a/modoboa/contacts/importer/backends/__init__.py b/modoboa/contacts/importer/backends/__init__.py index 767704dd2..56dd0fc4a 100644 --- a/modoboa/contacts/importer/backends/__init__.py +++ b/modoboa/contacts/importer/backends/__init__.py @@ -1,10 +1,10 @@ -from modoboa_contacts import models, tasks +from modoboa.contacts import models, tasks class ImporterBackend: """Base class of all importer backends.""" - name: str = None + name: str field_names: dict = {} diff --git a/modoboa/contacts/management/commands/import_contacts.py b/modoboa/contacts/management/commands/import_contacts.py index 972ab21c2..f1fffc527 100644 --- a/modoboa/contacts/management/commands/import_contacts.py +++ b/modoboa/contacts/management/commands/import_contacts.py @@ -2,8 +2,8 @@ from django.core.management.base import BaseCommand, CommandError -from modoboa_contacts import models -from modoboa_contacts.importer import import_csv_file +from modoboa.contacts import models +from modoboa.contacts.importer import import_csv_file class Command(BaseCommand): diff --git a/modoboa/contacts/models.py b/modoboa/contacts/models.py index 81576f409..16b6d90e0 100644 --- a/modoboa/contacts/models.py +++ b/modoboa/contacts/models.py @@ -31,7 +31,7 @@ class AddressBook(models.Model): @property def url(self): server_location = param_tools.get_global_parameter( - "server_location", app="modoboa_radicale" + "server_location", app="calendars" ) if not server_location: raise lib_exceptions.InternalError( diff --git a/modoboa/contacts/serializers.py b/modoboa/contacts/serializers.py index 8ba9ee83e..77dec85a5 100644 --- a/modoboa/contacts/serializers.py +++ b/modoboa/contacts/serializers.py @@ -1,5 +1,6 @@ """Contacts serializers.""" +from django.utils import timezone from django.utils.translation import gettext as _ from rest_framework import serializers @@ -13,7 +14,7 @@ class AddressBookSerializer(serializers.ModelSerializer): class Meta: model = models.AddressBook - fields = ("pk", "name", "url") + fields = ("pk", "name", "url", "last_sync") class EmailAddressSerializer(serializers.ModelSerializer): @@ -218,3 +219,28 @@ class ContactSerializer(serializers.ModelSerializer): tasks.update_contact_cdav(self.context["request"], instance) return instance + + +class UserPreferencesSerializer(serializers.Serializer): + """Serializer to save user preferences.""" + + enable_carddav_sync = serializers.BooleanField(default=False) + sync_frequency = serializers.IntegerField(default=300) + + def validate_sync_frequency(self, value): + """Make sure frequency is a positive integer.""" + if value < 60: + raise serializers.ValidationError(_("Minimum allowed value is 60s")) + return value + + def post_save(self, request): + """Create remote cal if necessary.""" + if not self.validated_data["enable_carddav_sync"]: + return + abook = request.user.addressbook_set.first() + if abook.last_sync: + return + tasks.create_cdav_addressbook(abook, request.auth) + if not abook.contact_set.exists(): + abook.last_sync = timezone.now() + abook.save(update_fields=["last_sync"]) diff --git a/modoboa/contacts/settings.py b/modoboa/contacts/settings.py deleted file mode 100644 index 74eaf5144..000000000 --- a/modoboa/contacts/settings.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Default Contacts settings.""" - -import os - -PLUGIN_BASE_DIR = os.path.dirname(__file__) - -CONTACTS_STATS_FILE = os.path.join( - PLUGIN_BASE_DIR, "static/modoboa_contacts/webpack-stats.json" -) - - -def apply(settings): - """Modify settings.""" - DEBUG = settings["DEBUG"] - if "webpack_loader" not in settings["INSTALLED_APPS"]: - settings["INSTALLED_APPS"] += ("webpack_loader",) - wpl_config = { - "CONTACTS": { - "CACHE": not DEBUG, - "BUNDLE_DIR_NAME": "modoboa_contacts/", - "STATS_FILE": CONTACTS_STATS_FILE, - "IGNORE": [".+\.hot-update.js", ".+\.map"], - } - } - if "WEBPACK_LOADER" in settings: - settings["WEBPACK_LOADER"].update(wpl_config) - else: - settings["WEBPACK_LOADER"] = wpl_config diff --git a/modoboa/contacts/tasks.py b/modoboa/contacts/tasks.py index ac3dd045c..0726e99ca 100644 --- a/modoboa/contacts/tasks.py +++ b/modoboa/contacts/tasks.py @@ -2,8 +2,6 @@ from django.utils import timezone -from modoboa.lib import cryptutils - from .lib import carddav from . import models @@ -20,7 +18,7 @@ def get_cdav_client_from_request(request, addressbook, *args, **kwargs): return get_cdav_client( addressbook, request.user.username, - passwd=cryptutils.decrypt(request.session["password"]), + passwd=request.auth, **kwargs, ) diff --git a/modoboa/contacts/tests.py b/modoboa/contacts/tests.py index c3668cf98..128a9e0e3 100644 --- a/modoboa/contacts/tests.py +++ b/modoboa/contacts/tests.py @@ -5,7 +5,6 @@ import os import httmock -from django import forms from django.core import management from django.urls import reverse @@ -14,32 +13,12 @@ from django.utils import timezone from modoboa.admin import factories as admin_factories from modoboa.core import models as core_models from modoboa.lib.tests import ModoAPITestCase, ModoTestCase -from modoboa.parameters import forms as param_forms -from modoboa.parameters import tools as param_tools from . import factories from . import mocks from . import models -class RadicaleParametersForm(param_forms.AdminParametersForm): - """Since contacts plugin depends on radicale, we need this.""" - - app = "modoboa_radicale" - - server_location = forms.URLField( - label="Server URL", - help_text=( - "The URL of your Radicale server. " - "It will be used to construct calendar URLs." - ), - widget=forms.TextInput(attrs={"class": "form-control"}), - ) - - -param_tools.registry.add("global", RadicaleParametersForm, "Radicale") - - class TestDataMixin: """Create some data.""" @@ -70,7 +49,7 @@ class TestDataMixin: """Initiate test context.""" self.client.force_login(self.user) self.set_global_parameter( - "server_location", "http://example.test/radicale/", app="modoboa_radicale" + "server_location", "http://example.test/radicale/", app="calendars" ) def enable_cdav_sync(self): @@ -80,8 +59,8 @@ class TestDataMixin: response = self.client.post( url, { - "modoboa_contacts-enable_carddav_sync": True, - "modoboa_contacts-sync_frequency": 300, + "contacts-enable_carddav_sync": True, + "contacts-sync_frequency": 300, }, ) self.assertEqual(response.status_code, 200) @@ -111,13 +90,6 @@ class ViewsTestCase(TestDataMixin, ModoTestCase): abook.refresh_from_db() self.assertIsNot(abook.last_sync, None) - # def test_index(self): - # """Test index view.""" - # url = reverse("modoboa_contacts:index") - # with self.assertRaises(IOError): - # response = self.client.get(url) - # self.assertContains(response, '
') - class AddressBookViewSetTestCase(TestDataMixin, ModoAPITestCase): """Address book ViewSet tests.""" diff --git a/modoboa/core/api/v2/viewsets.py b/modoboa/core/api/v2/viewsets.py index 5974db84a..ecde8ee79 100644 --- a/modoboa/core/api/v2/viewsets.py +++ b/modoboa/core/api/v2/viewsets.py @@ -10,6 +10,7 @@ from rest_framework.decorators import action from rest_framework.exceptions import PermissionDenied from modoboa.admin.api.v1 import serializers as admin_v1_serializers +from modoboa.admin.api.v2 import serializers as admin_v2_serializers from modoboa.core.api.v1 import serializers as core_v1_serializers from modoboa.core.api.v1 import viewsets as core_v1_viewsets from modoboa.core.extensions import exts_pool @@ -46,7 +47,7 @@ def create_static_tokens(request): class AccountViewSet(core_v1_viewsets.AccountViewSet): """Account viewset.""" - @extend_schema(responses=admin_v1_serializers.AccountSerializer) + @extend_schema(responses=admin_v2_serializers.AccountMeSerializer) @action(methods=["get"], detail=False) def me(self, request): """Return information about connected user.""" @@ -151,13 +152,27 @@ class AccountViewSet(core_v1_viewsets.AccountViewSet): } ) apps += [ + { + "name": "calendar", + "label": _("Calendars"), + "icon": "mdi-calendar", + "description": _("Calendar"), + "url": "/user/calendars", + }, { "name": "contacts", "label": _("Contacts"), - "icon": "mdi-contacts", + "icon": "mdi-contacts-outline", "description": _("Address book"), "url": "/user/contacts", - } + }, + # { + # "name": "webmail", + # "label": _("Webmail"), + # "icon": "mdi-at", + # "description": _("Webmail"), + # "url": "/user/webmail", + # } ] apps += exts_pool.get_available_apps() serializer = serializers.ModoboaApplicationSerializer(apps, many=True) diff --git a/modoboa/core/commands/templates/settings.py.tpl b/modoboa/core/commands/templates/settings.py.tpl index 3e38d492c..d70664a39 100644 --- a/modoboa/core/commands/templates/settings.py.tpl +++ b/modoboa/core/commands/templates/settings.py.tpl @@ -97,6 +97,8 @@ MODOBOA_APPS = ( 'modoboa.imap_migration', 'modoboa.postfix_autoreply', 'modoboa.sievefilters', + 'modoboa.contacts', + 'modoboa.calendars', # Modoboa extensions here. {% for extension in extensions %} '{{ extension }}', {% endfor %} diff --git a/modoboa/parameters/api/v2/urls.py b/modoboa/parameters/api/v2/urls.py index 418037966..584262d6f 100644 --- a/modoboa/parameters/api/v2/urls.py +++ b/modoboa/parameters/api/v2/urls.py @@ -6,5 +6,10 @@ from . import viewsets router = routers.SimpleRouter() -router.register(r"parameters", viewsets.ParametersViewSet, basename="parameter") +router.register( + r"parameters/global", viewsets.GlobalParametersViewSet, basename="parameter-global" +) +router.register( + r"parameters/user", viewsets.UserParametersViewSet, basename="parameter-user" +) urlpatterns = router.urls diff --git a/modoboa/parameters/api/v2/viewsets.py b/modoboa/parameters/api/v2/viewsets.py index cb2d785fa..4bc2ec732 100644 --- a/modoboa/parameters/api/v2/viewsets.py +++ b/modoboa/parameters/api/v2/viewsets.py @@ -11,18 +11,18 @@ from . import serializers from ... import tools -class ParametersViewSet(GetThrottleViewsetMixin, viewsets.ViewSet): +class BaseParametersViewSet(GetThrottleViewsetMixin, viewsets.ViewSet): """Parameter viewset.""" + level: str lookup_value_regex = r"\w+" - permission_classes = [permissions.IsAuthenticated, IsSuperUser] serializer_class = None @extend_schema(responses=serializers.ApplicationSerializer(many=True)) @action(methods=["get"], detail=False) def applications(self, request): """Return the list of registered applications.""" - applications = tools.registry.get_applications("global") + applications = tools.registry.get_applications(self.level) return response.Response(applications) @extend_schema(responses=serializers.ParameterSerializer(many=True)) @@ -30,9 +30,12 @@ class ParametersViewSet(GetThrottleViewsetMixin, viewsets.ViewSet): def structure(self, request): """Return parameter schema.""" app = request.GET.get("app") - data = tools.registry.get_structure("global", app) + data = tools.registry.get_structure(self.level, app) return response.Response(data) + def _get_parameter_values(self, request, app: str) -> dict: + raise NotImplementedError + @extend_schema( parameters=[ OpenApiParameter( @@ -47,13 +50,19 @@ class ParametersViewSet(GetThrottleViewsetMixin, viewsets.ViewSet): ) def retrieve(self, request, pk: str): """Return all parameters for given app.""" - parameters = request.localconfig.parameters.get_values_dict(pk) - serializer = tools.registry.get_serializer_class("global", pk)(parameters) + parameters = self._get_parameter_values(request, pk) + serializer = tools.registry.get_serializer_class(self.level, pk)(parameters) result = serializers.AppParametersSerializer( - {"label": tools.registry.get_label("global", pk), "params": serializer.data} + { + "label": tools.registry.get_label(self.level, pk), + "params": serializer.data, + } ) return response.Response(result.data) + def _save_parameter_values(self, request, app: str, data: dict) -> None: + raise NotImplementedError + @extend_schema( parameters=[ OpenApiParameter( @@ -67,10 +76,39 @@ class ParametersViewSet(GetThrottleViewsetMixin, viewsets.ViewSet): ) def update(self, request, pk: str): """Save parameters for given app.""" - serializer = tools.registry.get_serializer_class("global", pk)( + serializer = tools.registry.get_serializer_class(self.level, pk)( data=request.data ) serializer.is_valid(raise_exception=True) - request.localconfig.parameters.set_values(serializer.validated_data, app=pk) - request.localconfig.save(update_fields=["_parameters"]) + self._save_parameter_values(request, pk, serializer.validated_data) + if hasattr(serializer, "post_save"): + serializer.post_save(request) return response.Response() + + +class GlobalParametersViewSet(BaseParametersViewSet): + """Parameter viewset, global level.""" + + level = "global" + permission_classes = [permissions.IsAuthenticated, IsSuperUser] + + def _get_parameter_values(self, request, app: str) -> dict: + return request.localconfig.parameters.get_values_dict(app) + + def _save_parameter_values(self, request, app: str, data: dict) -> None: + request.localconfig.parameters.set_values(data, app=app) + request.localconfig.save(update_fields=["_parameters"]) + + +class UserParametersViewSet(BaseParametersViewSet): + """Parameter viewset, user level.""" + + level = "user" + permission_classes = [permissions.IsAuthenticated] + + def _get_parameter_values(self, request, app: str) -> dict: + return request.user.parameters.get_values_dict(app) + + def _save_parameter_values(self, request, app: str, data: dict) -> None: + request.user.parameters.set_values(data, app=app) + request.user.save(update_fields=["_parameters"]) diff --git a/modoboa/parameters/tools.py b/modoboa/parameters/tools.py index 5cade577b..a1f96eded 100644 --- a/modoboa/parameters/tools.py +++ b/modoboa/parameters/tools.py @@ -1,6 +1,9 @@ """Parameters management.""" import copy +from typing import Optional + +from rest_framework.fields import empty from modoboa.lib import exceptions, form_utils, signals from modoboa.lib.sysutils import guess_extension_name @@ -37,13 +40,22 @@ class Registry: "defaults": {}, } - def add2(self, level, app, label, structure, serializer_class, is_extension=False): + def add2( + self, + level: str, + app: str, + label: str, + structure: dict, + serializer_class, + is_extension: bool = False, + ) -> None: """Add new parameters for given app and level.""" self._registry2[level][app] = { "label": label, "structure": structure, "serializer_class": serializer_class, "is_extension": is_extension, + "defaults": {}, } def _load_default_values(self, level): @@ -54,6 +66,11 @@ class Registry: if isinstance(field, form_utils.SeparatorField): continue data["defaults"][name] = field.initial + for data in list(self._registry2[level].values()): + serializer = data["serializer_class"]() + for name, field in list(serializer.fields.items()): + if field.default is not empty: + data["defaults"][name] = field.default def get_applications(self, level): """Return all applications registered for level.""" @@ -67,7 +84,7 @@ class Registry: ] return result - def get_label(self, level, app): + def get_label(self, level: str, app: str) -> str: return self._registry2[level][app]["label"] def get_forms(self, level, *args, **kwargs): @@ -94,13 +111,13 @@ class Registry: ) return result - def get_serializer_class(self, level, app): + def get_serializer_class(self, level: str, app: str): """Return serializer class for given level and app.""" if app not in self._registry2[level]: raise NotDefined(app) return self._registry2[level][app]["serializer_class"] - def get_structure(self, level, for_app=None): + def get_structure(self, level: str, for_app: Optional[str] = None) -> list: """Return fields structure.""" result = [] for app, fields in list(self._registry2[level].items()): @@ -137,27 +154,41 @@ class Registry: result.append(item) return result - def exists(self, level, app, parameter=None): + def exists(self, level: str, app: str, parameter: Optional[str] = None) -> bool: """Check if parameter exists.""" parameters = self._registry[level] result = app in parameters + if parameter: + result = result and parameter in parameters[app]["defaults"] + if result: + return result + parameters = self._registry2[level] + result = app in parameters if parameter: result = result and parameter in parameters[app]["defaults"] return result - def get_default(self, level, app, parameter): + def get_default(self, level: str, app: str, parameter: str): """Retrieve default value for parameter.""" - if app not in self._registry[level]: - raise NotDefined(app) - if parameter not in self._registry[level][app]["defaults"]: - raise NotDefined(app, parameter) - return self._registry[level][app]["defaults"][parameter] + if ( + app in self._registry[level] + and parameter in self._registry[level][app]["defaults"] + ): + return self._registry[level][app]["defaults"][parameter] + if ( + app in self._registry2[level] + and parameter in self._registry2[level][app]["defaults"] + ): + return self._registry2[level][app]["defaults"][parameter] + raise NotDefined(app) - def get_defaults(self, level, app): + def get_defaults(self, level: str, app: str) -> dict: """Retrieve default values for application.""" - if app not in self._registry[level]: - raise NotDefined(app) - return self._registry[level][app]["defaults"] + if app in self._registry[level]: + return self._registry[level][app]["defaults"] + if app in self._registry2[level]: + return self._registry2[level][app]["defaults"] + raise NotDefined(app) registry = Registry() @@ -166,7 +197,7 @@ registry = Registry() class Manager: """A manager for parameters.""" - def __init__(self, level, parameters): + def __init__(self, level: str, parameters) -> None: """Constructor.""" self._level = level self._parameters = parameters @@ -188,7 +219,7 @@ class Manager: return self._parameters[app].get(parameter, default) return default - def get_values(self, app=None): + def get_values(self, app: Optional[str] = None): """Return all values for the given level/app.""" if app is None: app = guess_extension_name() @@ -220,7 +251,7 @@ class Manager: ) self._parameters[app][parameter] = value - def set_values(self, values, app=None): + def set_values(self, values: dict, app: Optional[str] = None) -> None: """Set/update values for the given app.""" if app is None: app = guess_extension_name() diff --git a/modoboa/urls_api_v2.py b/modoboa/urls_api_v2.py index f8b748250..ad8671dc7 100644 --- a/modoboa/urls_api_v2.py +++ b/modoboa/urls_api_v2.py @@ -21,6 +21,7 @@ urlpatterns = [ path("", include("modoboa.postfix_autoreply.api.v2.urls")), path("", include("modoboa.sievefilters.api.v2.urls")), path("", include("modoboa.contacts.urls")), + path("", include("modoboa.calendars.urls")), ] urlpatterns += exts_pool.get_urls(category="api") diff --git a/mysql-requirements.txt b/mysql-requirements.txt index 3ae620105..b064df0f0 100644 --- a/mysql-requirements.txt +++ b/mysql-requirements.txt @@ -57,6 +57,7 @@ django==4.2.18 # django-rq # django-xforwardedfor-middleware # djangorestframework + # drf-nested-routers # drf-spectacular # modoboa (pyproject.toml) django-ckeditor==6.7.2 @@ -85,10 +86,13 @@ django-xforwardedfor-middleware==2.0 # via modoboa (pyproject.toml) djangorestframework==3.15.2 # via + # drf-nested-routers # drf-spectacular # modoboa (pyproject.toml) dnspython==2.7.0 # via modoboa (pyproject.toml) +drf-nested-routers==0.94.1 + # via modoboa (pyproject.toml) drf-spectacular==0.28.0 # via modoboa (pyproject.toml) feedparser==6.0.11 diff --git a/postgresql-requirements.txt b/postgresql-requirements.txt index fc79e3863..506d60651 100644 --- a/postgresql-requirements.txt +++ b/postgresql-requirements.txt @@ -57,6 +57,7 @@ django==4.2.18 # django-rq # django-xforwardedfor-middleware # djangorestframework + # drf-nested-routers # drf-spectacular # modoboa (pyproject.toml) django-ckeditor==6.7.2 @@ -85,10 +86,13 @@ django-xforwardedfor-middleware==2.0 # via modoboa (pyproject.toml) djangorestframework==3.15.2 # via + # drf-nested-routers # drf-spectacular # modoboa (pyproject.toml) dnspython==2.7.0 # via modoboa (pyproject.toml) +drf-nested-routers==0.94.1 + # via modoboa (pyproject.toml) drf-spectacular==0.28.0 # via modoboa (pyproject.toml) feedparser==6.0.11 diff --git a/pylintrc b/pylintrc index 2f7565b28..84b99bf45 100644 --- a/pylintrc +++ b/pylintrc @@ -434,7 +434,9 @@ disable=raw-checker-failed, too-few-public-methods, too-many-public-methods, too-many-ancestors, - line-too-long + line-too-long, + missing-class-docstring, + missing-function-docstring # Enable the message, report, category or checker with the given id(s). You can # either give multiple identifier separated by comma (,) or put this option diff --git a/pyproject.toml b/pyproject.toml index a6d900139..e46718e1e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -77,6 +77,7 @@ dependencies = [ # Sieve "sievelib>=1.4.1", # User + "drf-nested-routers", "vobject", "caldav==1.3.9" ] diff --git a/requirements.txt b/requirements.txt index 59f458bff..7925593a1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -57,6 +57,7 @@ django==4.2.18 # django-rq # django-xforwardedfor-middleware # djangorestframework + # drf-nested-routers # drf-spectacular # modoboa (pyproject.toml) django-ckeditor==6.7.2 @@ -85,10 +86,13 @@ django-xforwardedfor-middleware==2.0 # via modoboa (pyproject.toml) djangorestframework==3.15.2 # via + # drf-nested-routers # drf-spectacular # modoboa (pyproject.toml) dnspython==2.7.0 # via modoboa (pyproject.toml) +drf-nested-routers==0.94.1 + # via modoboa (pyproject.toml) drf-spectacular==0.28.0 # via modoboa (pyproject.toml) feedparser==6.0.11 diff --git a/test-requirements.txt b/test-requirements.txt index 13de704c0..645a01866 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -72,6 +72,7 @@ django==4.2.18 # django-rq # django-xforwardedfor-middleware # djangorestframework + # drf-nested-routers # drf-spectacular # modoboa (pyproject.toml) django-ckeditor==6.7.2 @@ -100,10 +101,13 @@ django-xforwardedfor-middleware==2.0 # via modoboa (pyproject.toml) djangorestframework==3.15.2 # via + # drf-nested-routers # drf-spectacular # modoboa (pyproject.toml) dnspython==2.7.0 # via modoboa (pyproject.toml) +drf-nested-routers==0.94.1 + # via modoboa (pyproject.toml) drf-spectacular==0.28.0 # via modoboa (pyproject.toml) factory-boy==3.3.3 diff --git a/test_project/test_project/settings.py b/test_project/test_project/settings.py index 1723c6114..fcb302006 100644 --- a/test_project/test_project/settings.py +++ b/test_project/test_project/settings.py @@ -103,6 +103,7 @@ MODOBOA_APPS = ( "modoboa.sievefilters", # Modoboa extensions here. "modoboa.contacts", + "modoboa.calendars", ) try: