mirror of
https://github.com/jwinterm/modoboa.git
synced 2026-10-17 05:41:25 +02:00
Added calendars plugin and improved used part
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
FROM python:3.12-alpine3.20
|
||||
|
||||
LABEL org.opencontainers.image.authors="Antoine Nguyen <tonio@ngyn.org>"
|
||||
|
||||
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
|
||||
@@ -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 = *
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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/')
|
||||
},
|
||||
}
|
||||
@@ -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 })
|
||||
},
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import repository from './repository'
|
||||
|
||||
const resource = 'mailboxes'
|
||||
|
||||
export default {
|
||||
getAll() {
|
||||
return repository.get(`${resource}/`)
|
||||
},
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -261,7 +261,7 @@ async function save() {
|
||||
}
|
||||
}
|
||||
onMounted(() => {
|
||||
parametersApi.getApplication('limits').then((resp) => {
|
||||
parametersApi.getGlobalApplication('limits').then((resp) => {
|
||||
limitsConfig.value.data = resp.data
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
<template>
|
||||
<v-card>
|
||||
<v-card-title>
|
||||
<span class="headline">{{ $gettext('Calendar sharing') }}</span>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<v-form ref="formRef" @submit.prevent="submit">
|
||||
<div class="d-flex">
|
||||
<v-select
|
||||
v-model="currentRule.mailbox"
|
||||
:label="$gettext('Full address')"
|
||||
:form-errors="formErrors['mailbox']"
|
||||
:items="mailboxes"
|
||||
item-title="full_address"
|
||||
return-object
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
/>
|
||||
<v-checkbox
|
||||
v-model="currentRule.read"
|
||||
:label="$gettext('Read')"
|
||||
class="ml-4"
|
||||
/>
|
||||
<v-checkbox
|
||||
v-model="currentRule.write"
|
||||
:label="$gettext('Write')"
|
||||
class="ml-4"
|
||||
/>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
color="primary"
|
||||
icon="mdi-content-save-outline"
|
||||
type="submit"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</v-form>
|
||||
<br />
|
||||
<v-table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $gettext('Account') }}</th>
|
||||
<th>{{ $gettext('Read') }}</th>
|
||||
<th>{{ $gettext('Write') }}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="rule in accessRules" :key="rule.pk">
|
||||
<td>{{ rule.mailbox.full_address }}</td>
|
||||
<td>
|
||||
<v-icon :icon="rule.read ? 'mdi-check' : 'mdi-close'" />
|
||||
</td>
|
||||
<td>
|
||||
<v-icon :icon="rule.write ? 'mdi-check' : 'mdi-close'" />
|
||||
</td>
|
||||
<td>
|
||||
<v-btn
|
||||
icon="mdi-pencil"
|
||||
size="x-small"
|
||||
variant="flat"
|
||||
@click="editRule(rule)"
|
||||
/>
|
||||
<v-btn
|
||||
icon="mdi-trash-can"
|
||||
size="x-small"
|
||||
variant="flat"
|
||||
@click="deleteRule(rule)"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn @click="close">{{ $gettext('Close') }}</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import api from '@/api/calendars'
|
||||
import mailboxesApi from '@/api/mailboxes'
|
||||
|
||||
const props = defineProps({
|
||||
calendarPk: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
})
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const accessRules = ref([])
|
||||
const mailboxes = ref([])
|
||||
const currentRule = ref({})
|
||||
const formErrors = ref({})
|
||||
|
||||
api.getAccessRules(props.calendarPk).then((response) => {
|
||||
accessRules.value = response.data
|
||||
if (!accessRules.value.length) {
|
||||
accessRules.value = []
|
||||
}
|
||||
})
|
||||
mailboxesApi.getAll().then((response) => {
|
||||
mailboxes.value = response.data
|
||||
})
|
||||
|
||||
async function submit() {
|
||||
if (!currentRule.value.pk) {
|
||||
currentRule.value.calendar = props.calendarPk
|
||||
api.createAccessRule(currentRule.value).then((response) => {
|
||||
accessRules.value.push(response.data)
|
||||
resetForm()
|
||||
}, onError)
|
||||
} else {
|
||||
api.updateAccessRule(currentRule.value.pk, currentRule.value).then(() => {
|
||||
accessRules.value.filter((item, pos) => {
|
||||
if (item.pk === currentRule.value.pk) {
|
||||
accessRules.value[pos] = currentRule.value
|
||||
}
|
||||
})
|
||||
resetForm()
|
||||
}, onError)
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
currentRule.value = {}
|
||||
formErrors.value = {}
|
||||
}
|
||||
function onError(response) {
|
||||
formErrors.value = response.data
|
||||
}
|
||||
function editRule(rule) {
|
||||
currentRule.value = JSON.parse(JSON.stringify(rule))
|
||||
}
|
||||
function deleteRule(rule) {
|
||||
api.deleteAccessRule(rule.pk).then(() => {
|
||||
accessRules.value = accessRules.value.filter((localRule) => {
|
||||
return localRule.pk !== rule.pk
|
||||
})
|
||||
})
|
||||
}
|
||||
function close() {
|
||||
formErrors.value = {}
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<v-card v-if="calendar">
|
||||
<v-card-title>
|
||||
<span class="headline">{{ $gettext('Calendar information') }}</span>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<p>
|
||||
{{
|
||||
$gettext(
|
||||
'To access this calendar from the outside (such as Mozilla Thunderbird or your smartphone), use the following URL:'
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<v-alert type="info" class="my-4" variant="tonal">
|
||||
{{ calendar.full_url }}
|
||||
</v-alert>
|
||||
<p>
|
||||
{{
|
||||
$gettext(
|
||||
'The credentials are the same than the ones you use to access Modoboa.'
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<p>
|
||||
{{
|
||||
$gettext(
|
||||
'You can also share a read-only version of this calendar using the following URL: '
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<v-alert type="info" class="my-4" variant="tonal">
|
||||
{{ calendar.share_url }}
|
||||
</v-alert>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn @click="$emit('close')">{{ $gettext('Close') }}</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
calendar: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
const emit = defineEmits(['close'])
|
||||
</script>
|
||||
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<v-card>
|
||||
<v-form ref="formRef" @submit.prevent="submit">
|
||||
<v-card-title>
|
||||
<span v-if="calendar.pk" class="headline">{{
|
||||
$gettext('Edit calendar')
|
||||
}}</span>
|
||||
<span v-else class="modal-title">{{ $gettext('New calendar') }}</span>
|
||||
</v-card-title>
|
||||
<v-card-text class="py-4">
|
||||
<v-row no-gutters>
|
||||
<v-col cols="12">
|
||||
<v-text-field
|
||||
v-model="calendar.name"
|
||||
:label="$gettext('Name')"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
:rules="[rules.required]"
|
||||
:error-messages="formErrors.name"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12">
|
||||
<v-color-picker
|
||||
v-model="calendar.color"
|
||||
:label="$gettext('Color')"
|
||||
class="ma-2"
|
||||
swatches-max-height="200px"
|
||||
show-swatches
|
||||
hide-inputs
|
||||
:rules="[rules.required]"
|
||||
></v-color-picker>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn @click="close">{{ $gettext('Close') }}</v-btn>
|
||||
<v-btn color="primary" type="submit">
|
||||
{{ submitLabel }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-form>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
import { useBusStore } from '@/stores'
|
||||
import rules from '@/plugins/rules'
|
||||
import calendarsApi from '@/api/calendars'
|
||||
|
||||
const emit = defineEmits(['close', 'colorChanged'])
|
||||
const props = defineProps({
|
||||
initialCalendar: {
|
||||
type: Object,
|
||||
default: () => {
|
||||
return { color: '' }
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
const busStore = useBusStore()
|
||||
|
||||
onMounted(() => {
|
||||
calendar.value = { ...props.initialCalendar }
|
||||
shared.value = props.initialCalendar.domain !== undefined
|
||||
})
|
||||
|
||||
const calendar = ref({})
|
||||
const shared = ref(false)
|
||||
const formErrors = ref({})
|
||||
const formRef = ref()
|
||||
|
||||
const submitLabel = computed(() => {
|
||||
if (calendar.value.pk) {
|
||||
return $gettext('Update')
|
||||
}
|
||||
return $gettext('Create')
|
||||
})
|
||||
|
||||
function close() {
|
||||
calendar.value = {}
|
||||
shared.value = false
|
||||
formErrors.value = {}
|
||||
emit('close')
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const { valid } = await formRef.value.validate()
|
||||
if (!valid) {
|
||||
return
|
||||
}
|
||||
let msg
|
||||
if (calendar.value.pk) {
|
||||
await calendarsApi.updateUserCalendar(calendar.value.pk, calendar.value)
|
||||
msg = $gettext('Calendar updated')
|
||||
} else {
|
||||
await calendarsApi.createUserCalendar(calendar.value)
|
||||
msg = $gettext('Calendar created')
|
||||
}
|
||||
if (props.initialCalendar.color !== calendar.value.color) {
|
||||
emit('colorChanged', calendar.value)
|
||||
}
|
||||
close()
|
||||
busStore.displayNotification({
|
||||
msg,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,186 @@
|
||||
<template>
|
||||
<v-card>
|
||||
<v-form ref="formRef" @submit.prevent="saveEvent">
|
||||
<v-card-title>
|
||||
<span class="headline">{{ title }}</span>
|
||||
</v-card-title>
|
||||
<v-card-text class="py-4">
|
||||
<v-row class="mb-2">
|
||||
<v-col cols="12">
|
||||
<v-text-field
|
||||
v-model="form.title"
|
||||
:label="$gettext('Title')"
|
||||
:rules="[rules.required]"
|
||||
:error-messages="formErrors.title"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<div class="d-flex mb-3">
|
||||
<v-icon icon="mdi-clock-outline" class="mr-4" />
|
||||
<v-text-field
|
||||
v-model="form.start"
|
||||
:label="$gettext('From')"
|
||||
:rules="[rules.required]"
|
||||
:error-messages="formErrors.start"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
type="datetime-local"
|
||||
class="mr-4"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model="form.end"
|
||||
:label="$gettext('To')"
|
||||
:rules="[rules.required]"
|
||||
:error-messages="formErrors.end"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
type="datetime-local"
|
||||
/>
|
||||
</div>
|
||||
<div class="d-flex mb-3">
|
||||
<v-icon icon="mdi-calendar" class="mr-4" />
|
||||
<v-select
|
||||
v-model="form.calendar"
|
||||
:items="userCalendars"
|
||||
item-title="name"
|
||||
item-value="pk"
|
||||
:label="$gettext('Calendar')"
|
||||
:rules="[rules.required]"
|
||||
:error-messages="formErrors.calendar"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
/>
|
||||
</div>
|
||||
<template v-if="event">
|
||||
<div class="d-flex mb-3">
|
||||
<v-icon icon="mdi-account-multiple-outline" class="mr-4" />
|
||||
<v-select
|
||||
v-model="form.attendees"
|
||||
:label="$gettext('Attendees')"
|
||||
:items="attendees"
|
||||
item-title="display_name"
|
||||
item-value="email"
|
||||
return-object
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
multiple
|
||||
chips
|
||||
/>
|
||||
</div>
|
||||
<v-textarea
|
||||
v-model="form.description"
|
||||
:label="$gettext('Description')"
|
||||
auto-grow
|
||||
rows="2"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
class="mb-3"
|
||||
/>
|
||||
</template>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn @click="close">{{ $gettext('Close') }}</v-btn>
|
||||
<v-btn color="primary" type="submit">
|
||||
{{ $gettext('Save') }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-form>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { DateTime } from 'luxon'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
import { useBusStore } from '@/stores'
|
||||
import api from '@/api/calendars'
|
||||
import rules from '@/plugins/rules'
|
||||
|
||||
const props = defineProps({
|
||||
info: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
event: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
const busStore = useBusStore()
|
||||
|
||||
const attendees = ref([])
|
||||
const userCalendars = ref([])
|
||||
const form = ref({})
|
||||
const formErrors = ref({})
|
||||
const formRef = ref()
|
||||
|
||||
const title = computed(() => {
|
||||
return props.event ? $gettext('Edit event') : $gettext('New event')
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.info,
|
||||
(value) => {
|
||||
if (value) {
|
||||
form.value = {
|
||||
start: DateTime.fromISO(value.startStr).toFormat('yyyy-MM-dd HH:mm'),
|
||||
end: DateTime.fromISO(value.endStr).toFormat('yyyy-MM-dd HH:mm'),
|
||||
allDay: value.allDay,
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.event,
|
||||
(value) => {
|
||||
if (value) {
|
||||
form.value = { ...value }
|
||||
form.value.calendar = form.value.calendar.pk
|
||||
form.value.start = DateTime.fromISO(value.start).toFormat(
|
||||
'yyyy-MM-dd HH:mm'
|
||||
)
|
||||
form.value.end = DateTime.fromISO(value.end).toFormat('yyyy-MM-dd HH:mm')
|
||||
console.log(form.value)
|
||||
api.getAttendees().then((resp) => {
|
||||
attendees.value = resp.data
|
||||
})
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function close() {
|
||||
formErrors.value = {}
|
||||
emit('close')
|
||||
}
|
||||
async function saveEvent() {
|
||||
const { valid } = await formRef.value.validate()
|
||||
if (!valid) {
|
||||
return
|
||||
}
|
||||
var data = JSON.parse(JSON.stringify(form.value))
|
||||
if (!props.event) {
|
||||
api.createUserEvent(form.value.calendar, data).then(() => {
|
||||
busStore.displayNotification({ msg: $gettext('Event added') })
|
||||
close()
|
||||
})
|
||||
} else {
|
||||
api.patchUserEvent(form.value.calendar, props.event.id, data).then(() => {
|
||||
busStore.displayNotification({ msg: $gettext('Event updated') })
|
||||
close()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
api.getUserCalendars().then((resp) => {
|
||||
userCalendars.value = resp.data
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<v-card>
|
||||
<v-form ref="formRef" @submit.prevent="sendFile">
|
||||
<v-card-title>
|
||||
<span class="headline">{{ $gettext('Import events') }}</span>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<v-alert type="info" variant="tonal" class="my-4">
|
||||
{{
|
||||
$gettext(
|
||||
'Select an ICS file to import and click on the Send button'
|
||||
)
|
||||
}}
|
||||
</v-alert>
|
||||
<v-file-input
|
||||
v-model="file"
|
||||
:label="$gettext('Select an ICS file')"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
:rules="[rules.required]"
|
||||
:error-messages="formErrors['ics_file']"
|
||||
/>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn :disabled="runningUpload" @click="close">{{
|
||||
$gettext('Close')
|
||||
}}</v-btn>
|
||||
<v-btn type="submit" color="primary" :disabled="runningUpload">
|
||||
{{ $gettext('Send') }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-form>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useBusStore } from '@/stores'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
import api from '@/api/calendars'
|
||||
import rules from '@/plugins/rules'
|
||||
|
||||
const props = defineProps({
|
||||
calendar: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
const emit = defineEmits(['close', 'eventsImported'])
|
||||
|
||||
const { $ngettext } = useGettext()
|
||||
const busStore = useBusStore()
|
||||
|
||||
const file = ref(null)
|
||||
const formErrors = ref({})
|
||||
const formRef = ref()
|
||||
const runningUpload = ref(false)
|
||||
|
||||
function close() {
|
||||
formErrors.value = {}
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function onSendError(response) {
|
||||
formErrors.value = response.data
|
||||
runningUpload.value = false
|
||||
}
|
||||
|
||||
async function sendFile() {
|
||||
const { valid } = await formRef.value.validate()
|
||||
if (!valid) {
|
||||
return
|
||||
}
|
||||
let data = new FormData()
|
||||
data.append('ics_file', file.value)
|
||||
runningUpload.value = true
|
||||
try {
|
||||
const response = await api.importUserEvents(props.calendar, data)
|
||||
close()
|
||||
var msg = $ngettext(
|
||||
'%{ n } event imported',
|
||||
'%{ n } events imported',
|
||||
response.data.counter
|
||||
)
|
||||
busStore.displayNotification({ msg })
|
||||
emit('eventsImported', props.calendar)
|
||||
} catch (err) {
|
||||
onSendError(err.response)
|
||||
} finally {
|
||||
runningUpload.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<v-card>
|
||||
<v-card-title>
|
||||
<span class="headline">{{ $gettext('Address book information') }}</span>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<p>
|
||||
{{
|
||||
$gettext(
|
||||
'To access this address book from the outside (such as Mozilla Thunderbird or your smartphone), use the following URL:'
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<v-alert type="info" class="my-4" variant="tonal">
|
||||
{{ addressBookUrl }}
|
||||
</v-alert>
|
||||
<p>
|
||||
{{
|
||||
$gettext(
|
||||
'The credentials are the same than the ones you use to access Modoboa.'
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn @click="$emit('close')">{{ $gettext('Close') }}</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
addressBookUrl: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
const emit = defineEmits(['close'])
|
||||
</script>
|
||||
@@ -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' })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -14,19 +14,24 @@
|
||||
density="compact"
|
||||
class="flex-grow-0 w-33 mr-4"
|
||||
></v-text-field>
|
||||
|
||||
<v-menu location="bottom">
|
||||
<template #activator>
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
@click="showForm = true"
|
||||
>
|
||||
{{ $gettext('Add') }}
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list density="compact"> </v-list>
|
||||
</v-menu>
|
||||
<v-btn color="primary" variant="outlined" @click="showForm = true">
|
||||
{{ $gettext('Add') }}
|
||||
</v-btn>
|
||||
<template v-if="preferences.enable_carddav_sync">
|
||||
<v-btn
|
||||
icon="mdi-information-outline"
|
||||
:title="$gettext('Display address book information')"
|
||||
class="ml-2"
|
||||
@click="showAddressBookInfo = true"
|
||||
/>
|
||||
<v-btn
|
||||
v-if="!addressBook.last_sync"
|
||||
icon="mdi-sync"
|
||||
color="success"
|
||||
:title="$gettext('Synchronize contacts with address book')"
|
||||
@click="syncToAddressBook"
|
||||
/>
|
||||
</template>
|
||||
<v-chip
|
||||
v-if="$route.params.category"
|
||||
class="my-2 mx-4"
|
||||
@@ -75,13 +80,22 @@
|
||||
@close="closeCategoriesForm"
|
||||
/>
|
||||
</v-dialog>
|
||||
<v-dialog v-model="showAddressBookInfo" persistent max-width="800px">
|
||||
<AddressBookInfo
|
||||
:address-book-url="addressBook.url"
|
||||
@close="showAddressBookInfo = false"
|
||||
/>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
import { useBusStore } from '@/stores'
|
||||
import contactsApi from '@/api/contacts'
|
||||
import parametersApi from '@/api/parameters'
|
||||
import AddressBookInfo from './AddressBookInfo.vue'
|
||||
import ContactForm from './ContactForm.vue'
|
||||
import ContactCategoriesForm from './ContactCategoriesForm.vue'
|
||||
import MenuItems from '@/components/tools/MenuItems'
|
||||
@@ -90,12 +104,16 @@ const emit = defineEmits(['editCategory'])
|
||||
|
||||
const route = useRoute()
|
||||
const { $gettext } = useGettext()
|
||||
const { displayNotification } = useBusStore()
|
||||
|
||||
const addressBook = ref({})
|
||||
const contacts = ref([])
|
||||
const search = ref('')
|
||||
const selectedContact = ref(null)
|
||||
const showAddressBookInfo = ref(false)
|
||||
const showForm = ref(false)
|
||||
const showCategoriesForm = ref(false)
|
||||
const preferences = ref({})
|
||||
|
||||
const headers = [
|
||||
{ key: 'display_name', title: $gettext('Display name') },
|
||||
@@ -123,6 +141,12 @@ const contactActions = [
|
||||
},
|
||||
]
|
||||
|
||||
let intervalId
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (intervalId) clearInterval(intervalId)
|
||||
})
|
||||
|
||||
function closeForm() {
|
||||
showForm.value = false
|
||||
selectedContact.value = null
|
||||
@@ -152,7 +176,7 @@ function editCategories(contact) {
|
||||
|
||||
function closeCategoriesForm() {
|
||||
showCategoriesForm.value = false
|
||||
selectedContact.value = false
|
||||
selectedContact.value = null
|
||||
}
|
||||
|
||||
function deleteContact(contact) {
|
||||
@@ -167,8 +191,29 @@ function fetchContacts() {
|
||||
})
|
||||
}
|
||||
|
||||
function syncToAddressBook() {
|
||||
contactsApi.synchronizeToAddressBook().then(() => {
|
||||
displayNotification({ msg: $gettext('Synchronization started') })
|
||||
})
|
||||
}
|
||||
|
||||
contactsApi.getDefaultAddressBook().then((resp) => {
|
||||
addressBook.value = resp.data
|
||||
})
|
||||
parametersApi.getUserApplication('contacts').then((resp) => {
|
||||
preferences.value = resp.data.params
|
||||
})
|
||||
fetchContacts()
|
||||
|
||||
watch(preferences, (value) => {
|
||||
if (value) {
|
||||
intervalId = setInterval(
|
||||
contactsApi.synchronizeFromAddressBook,
|
||||
value.sync_frequency * 1000
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => route.params.category,
|
||||
() => {
|
||||
|
||||
@@ -31,6 +31,8 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
errors: {
|
||||
type: Object,
|
||||
|
||||
@@ -61,13 +61,23 @@
|
||||
</v-list-item>
|
||||
</template>
|
||||
<template v-for="subitem in item.children" :key="subitem.text">
|
||||
<v-list-item
|
||||
v-if="displayMenuItem(subitem)"
|
||||
:to="subitem.to"
|
||||
link
|
||||
:title="subitem.text"
|
||||
:value="subitem"
|
||||
></v-list-item>
|
||||
<template v-if="displayMenuItem(subitem)">
|
||||
<v-list-item
|
||||
v-if="subitem.action"
|
||||
:title="subitem.text"
|
||||
:value="subitem"
|
||||
:prepend-icon="subitem.icon"
|
||||
@click="subitem.action"
|
||||
></v-list-item>
|
||||
<v-list-item
|
||||
v-else
|
||||
:to="subitem.to"
|
||||
link
|
||||
:title="subitem.text"
|
||||
:value="subitem"
|
||||
:prepend-icon="subitem.icon"
|
||||
></v-list-item>
|
||||
</template>
|
||||
</template>
|
||||
</v-list-group>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="top-menu">
|
||||
<div v-if="user" class="top-menu">
|
||||
<v-menu activator="parent" location="bottom">
|
||||
<template #activator="{ props }">
|
||||
<v-btn icon="mdi-apps" flat class="mr-4" v-bind="props"></v-btn>
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
<template>
|
||||
<v-tabs v-model="active" bg-color="white">
|
||||
<v-tab
|
||||
v-for="(element, index) in displayableElements"
|
||||
:key="index"
|
||||
color="primary"
|
||||
:class="tab_error.includes(index) ? 'error_tab' : ''"
|
||||
>
|
||||
{{ element.label }}
|
||||
</v-tab>
|
||||
</v-tabs>
|
||||
<v-tabs-window v-model="active">
|
||||
<v-tabs-window-item
|
||||
v-for="element in displayableElements"
|
||||
:key="element.label"
|
||||
>
|
||||
<v-container fluid class="pa-0">
|
||||
<v-card>
|
||||
<v-card-text>
|
||||
<template
|
||||
v-for="param in displayableParams(element.parameters)"
|
||||
:key="param.name"
|
||||
>
|
||||
<div class="my-4">
|
||||
<template v-if="param.widget === 'SeparatorField'">
|
||||
<h2>{{ param.label }}</h2>
|
||||
</template>
|
||||
<template v-else-if="param.widget === 'BooleanField'">
|
||||
<v-switch
|
||||
v-if="param.widget === 'BooleanField'"
|
||||
v-model="parameters[param.name]"
|
||||
:label="param.label"
|
||||
:hint="param.help_text"
|
||||
:error="formErrors[param.name] !== undefined"
|
||||
color="primary"
|
||||
density="compact"
|
||||
:error-messages="formErrors[param.name]"
|
||||
persistent-hint
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<label class="m-label">{{ param.label }}</label>
|
||||
<v-select
|
||||
v-if="param.widget === 'ChoiceField'"
|
||||
v-model="parameters[param.name]"
|
||||
:items="param.choices"
|
||||
item-title="text"
|
||||
:hint="param.help_text"
|
||||
persistent-hint
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
/>
|
||||
<v-text-field
|
||||
v-else
|
||||
v-model="parameters[param.name]"
|
||||
:hint="param.help_text"
|
||||
persistent-hint
|
||||
:error="formErrors[param.name] !== undefined"
|
||||
:error-messages="formErrors[param.name]"
|
||||
:type="
|
||||
param.widget === 'PasswordField' ? 'password' : 'text'
|
||||
"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-container>
|
||||
</v-tabs-window-item>
|
||||
</v-tabs-window>
|
||||
<v-btn
|
||||
icon="mdi-content-save"
|
||||
class="ma-5"
|
||||
position="fixed"
|
||||
location="bottom right"
|
||||
color="green"
|
||||
size="large"
|
||||
@click="save"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
import { useBusStore } from '@/stores'
|
||||
|
||||
const props = defineProps({
|
||||
app: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
structure: {
|
||||
type: Array,
|
||||
default: null,
|
||||
},
|
||||
values: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
saveFunction: {
|
||||
type: Function,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
const emit = defineEmits(['success'])
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
const { displayNotification } = useBusStore()
|
||||
|
||||
const displayableElements = computed(() =>
|
||||
props.structure.filter((element) => display(element))
|
||||
)
|
||||
|
||||
const active = ref(0)
|
||||
const formErrors = ref({})
|
||||
const parameters = ref({})
|
||||
const tab_error = ref([])
|
||||
|
||||
watch(
|
||||
() => props.values,
|
||||
(newValue) => {
|
||||
if (newValue) {
|
||||
parameters.value = { ...newValue }
|
||||
}
|
||||
}
|
||||
)
|
||||
watch(parameters, () => {
|
||||
tab_error.value = []
|
||||
})
|
||||
|
||||
function display(element) {
|
||||
if (element.display === '') {
|
||||
return true
|
||||
}
|
||||
let result = true
|
||||
element.display.split('&').forEach((rule) => {
|
||||
let [field, value] = rule.split('=')
|
||||
if (value === 'true' || value === 'false') {
|
||||
value = Boolean(value)
|
||||
}
|
||||
result &= parameters.value[field] === value
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
function displayableParams(params) {
|
||||
return params.filter((param) => display(param))
|
||||
}
|
||||
|
||||
async function save() {
|
||||
formErrors.value = {}
|
||||
tab_error.value = []
|
||||
try {
|
||||
await props.saveFunction(props.app, parameters.value)
|
||||
displayNotification({
|
||||
msg: $gettext('Parameters updated'),
|
||||
})
|
||||
emit('success', parameters.value)
|
||||
} catch (error) {
|
||||
displayNotification({
|
||||
msg: $gettext('Could not save parameters'),
|
||||
type: 'error',
|
||||
})
|
||||
formErrors.value = error.response.data
|
||||
Object.keys(formErrors.value).forEach((element) => {
|
||||
for (let i = 0; i < props.structure.length; i++) {
|
||||
const isIn =
|
||||
props.structure[i].parameters.filter(
|
||||
(param) => param.name === element
|
||||
).length > 0
|
||||
if (isIn) {
|
||||
tab_error.value.push(i)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.error_tab {
|
||||
color: #ff5252 !important;
|
||||
}
|
||||
</style>
|
||||
@@ -1,21 +1,32 @@
|
||||
<template>
|
||||
<ConnectedLayout color="grey" :menu-items="getUserSettingsMenuItems()" />
|
||||
<ConnectedLayout color="grey" :menu-items="userSettingsMenuItems" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
import { useAuthStore } from '@/stores'
|
||||
import ConnectedLayout from '@/layouts/connected/ConnectedLayout.vue'
|
||||
import parametersApi from '@/api/parameters'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const { $gettext } = useGettext()
|
||||
|
||||
const applications = ref([])
|
||||
|
||||
const authUser = computed(() => authStore.authUser)
|
||||
|
||||
function getUserSettingsMenuItems() {
|
||||
const userSettingsMenuItems = computed(() => {
|
||||
const result = []
|
||||
// Temporary solution...
|
||||
const appIcons = {
|
||||
contacts: 'mdi-contacts-outline',
|
||||
}
|
||||
|
||||
result.push({
|
||||
text: $gettext('Settings'),
|
||||
to: { name: 'AccountSettings' },
|
||||
icon: 'mdi-cog',
|
||||
})
|
||||
if (authUser.value.mailbox) {
|
||||
result.push({
|
||||
text: $gettext('Filters'),
|
||||
@@ -23,11 +34,17 @@ function getUserSettingsMenuItems() {
|
||||
icon: 'mdi-filter',
|
||||
})
|
||||
}
|
||||
result.push({
|
||||
text: $gettext('Settings'),
|
||||
to: { name: 'AccountSettings' },
|
||||
icon: 'mdi-cog',
|
||||
})
|
||||
for (const app of applications.value) {
|
||||
result.push({
|
||||
icon: appIcons[app.name],
|
||||
text: app.label,
|
||||
to: { name: 'AccountParametersEdit', params: { app: app.name } },
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
})
|
||||
|
||||
parametersApi.getUserApplications().then((resp) => {
|
||||
applications.value = resp.data
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
<template>
|
||||
<v-main>
|
||||
<v-container fluid>
|
||||
<v-container fluid style="height: 100%">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="fade">
|
||||
<div v-show="true">
|
||||
<component :is="Component" />
|
||||
</div>
|
||||
<component :is="Component" />
|
||||
</transition>
|
||||
</router-view>
|
||||
</v-container>
|
||||
|
||||
@@ -9,6 +9,7 @@ import vuetify from './vuetify'
|
||||
import router from '../router'
|
||||
import gettext from './gettext'
|
||||
import filters from './filters'
|
||||
import permissions from './permissions'
|
||||
import pinia from '@/stores'
|
||||
import VueApexCharts from 'vue3-apexcharts'
|
||||
|
||||
@@ -19,5 +20,6 @@ export function registerPlugins(app) {
|
||||
.use(pinia)
|
||||
.use(router)
|
||||
.use(filters)
|
||||
.use(permissions)
|
||||
.use(VueApexCharts)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useAuthStore } from '@/stores'
|
||||
|
||||
export default {
|
||||
install: (app) => {
|
||||
const authStore = useAuthStore()
|
||||
|
||||
app.directive('can', (el, binding) => {
|
||||
const permission = binding.value
|
||||
if (authStore.authUser) {
|
||||
if (
|
||||
authUser.authUser.role !== 'SuperAdmins' &&
|
||||
authStore.authUser.permissions.indexOf(permission) === -1
|
||||
) {
|
||||
el.style.display = 'none'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
app.config.globalProperties.$can = function (permission) {
|
||||
if (!authStore.authUser) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
authStore.authUser.role === 'SuperAdmins' ||
|
||||
authStore.authUser.permissions.indexOf(permission) !== -1
|
||||
) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -24,7 +24,11 @@ function samePassword(value, confirmation) {
|
||||
|
||||
export default {
|
||||
required: (value) =>
|
||||
(value !== '' && value != null) || $gettext('Field is required'),
|
||||
(value !== '' &&
|
||||
value != null &&
|
||||
value !== undefined &&
|
||||
(!Array.isArray(value) || value.length)) ||
|
||||
$gettext('Field is required'),
|
||||
email: (value) => validateEmail(value),
|
||||
emailOrNull: (value) => value != null || value !== '' || validateEmail(value),
|
||||
minLength: (len) => (value) =>
|
||||
|
||||
@@ -250,6 +250,14 @@ const routes = [
|
||||
requiresMailbox: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'parameters/:app',
|
||||
name: 'AccountParametersEdit',
|
||||
component: () => import('@/views/account/ParametersView.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: ':tab?',
|
||||
name: 'AccountSettings',
|
||||
@@ -291,6 +299,22 @@ const routes = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'calendars',
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'CalendarView',
|
||||
component: () => import('@/views/calendars/CalendarView.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<div>
|
||||
<v-toolbar flat>
|
||||
<v-toolbar-title>{{ title }}</v-toolbar-title>
|
||||
</v-toolbar>
|
||||
|
||||
<ParametersForm
|
||||
:app="$route.params.app"
|
||||
:structure="structure"
|
||||
:values="parameters"
|
||||
:save-function="parametersApi.saveUserApplication"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
import { useRoute } from 'vue-router'
|
||||
import ParametersForm from '@/components/tools/ParametersForm'
|
||||
import parametersApi from '@/api/parameters'
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
const route = useRoute()
|
||||
|
||||
const label = ref('')
|
||||
const structure = ref([])
|
||||
const parameters = ref({})
|
||||
|
||||
const title = computed(() => $gettext('Settings: ' + label.value))
|
||||
|
||||
function loadParams(app) {
|
||||
parametersApi.getUserApplicationStructure(app).then((response) => {
|
||||
structure.value = response.data
|
||||
})
|
||||
parametersApi.getUserApplication(app).then((response) => {
|
||||
parameters.value = response.data.params
|
||||
label.value = response.data.label
|
||||
})
|
||||
}
|
||||
|
||||
loadParams(route.params.app)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.v-toolbar {
|
||||
background-color: #f7f8fa !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.v-window__container {
|
||||
width: 100% !important;
|
||||
}
|
||||
</style>
|
||||
@@ -4,188 +4,57 @@
|
||||
<v-toolbar-title>{{ title }}</v-toolbar-title>
|
||||
</v-toolbar>
|
||||
|
||||
<v-tabs v-model="active" bg-color="white">
|
||||
<v-tab
|
||||
v-for="(element, index) in displayableElements"
|
||||
:key="index"
|
||||
color="primary"
|
||||
:class="tab_error.includes(index) ? 'error_tab' : ''"
|
||||
>
|
||||
{{ element.label }}
|
||||
</v-tab>
|
||||
</v-tabs>
|
||||
<v-tabs-window v-model="active">
|
||||
<v-tabs-window-item
|
||||
v-for="element in displayableElements"
|
||||
:key="element.label"
|
||||
>
|
||||
<v-container fluid class="pa-0">
|
||||
<v-card>
|
||||
<v-card-text>
|
||||
<template
|
||||
v-for="param in displayableParams(element.parameters)"
|
||||
:key="param.name"
|
||||
>
|
||||
<div class="my-4">
|
||||
<template v-if="param.widget === 'SeparatorField'">
|
||||
<h2>{{ param.label }}</h2>
|
||||
</template>
|
||||
<template v-else-if="param.widget === 'BooleanField'">
|
||||
<v-switch
|
||||
v-if="param.widget === 'BooleanField'"
|
||||
v-model="parameters[param.name]"
|
||||
:label="param.label"
|
||||
:hint="param.help_text"
|
||||
:error="formErrors[param.name] !== undefined"
|
||||
color="primary"
|
||||
density="compact"
|
||||
:error-messages="formErrors[param.name]"
|
||||
persistent-hint
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<label class="m-label">{{ param.label }}</label>
|
||||
<v-select
|
||||
v-if="param.widget === 'ChoiceField'"
|
||||
v-model="parameters[param.name]"
|
||||
:items="param.choices"
|
||||
item-title="text"
|
||||
:hint="param.help_text"
|
||||
persistent-hint
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
/>
|
||||
<v-text-field
|
||||
v-else
|
||||
v-model="parameters[param.name]"
|
||||
:hint="param.help_text"
|
||||
persistent-hint
|
||||
:error="formErrors[param.name] !== undefined"
|
||||
:error-messages="formErrors[param.name]"
|
||||
:type="
|
||||
param.widget === 'PasswordField' ? 'password' : 'text'
|
||||
"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-container>
|
||||
</v-tabs-window-item>
|
||||
</v-tabs-window>
|
||||
<v-btn
|
||||
icon="mdi-content-save"
|
||||
class="ma-5"
|
||||
position="fixed"
|
||||
location="bottom right"
|
||||
color="green"
|
||||
size="large"
|
||||
@click="save"
|
||||
<ParametersForm
|
||||
:app="$route.params.app"
|
||||
:structure="structure"
|
||||
:values="parameters"
|
||||
:save-function="parametersApi.saveGlobalApplication"
|
||||
@success="onSuccess"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="js">
|
||||
<script setup>
|
||||
import { useBusStore } from '@/stores'
|
||||
import parametersApi from '@/api/parameters'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useGlobalStore, useParametersStore } from '@/stores'
|
||||
import ParametersForm from '@/components/tools/ParametersForm'
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
const busStore = useBusStore()
|
||||
const globalStore = useGlobalStore()
|
||||
const route = useRoute()
|
||||
const parametersStore = useParametersStore()
|
||||
|
||||
const active = ref(0)
|
||||
const structure = ref([])
|
||||
const parameters = ref({})
|
||||
const formErrors = ref({})
|
||||
const label = ref('')
|
||||
const tab_error = ref([])
|
||||
|
||||
const displayableElements = computed(() =>
|
||||
structure.value.filter((element) => display(element))
|
||||
)
|
||||
|
||||
const title = computed(() => $gettext('Settings: ' + label.value))
|
||||
|
||||
function loadParams(app) {
|
||||
parametersApi.getApplicationStructure(app).then((response) => {
|
||||
parametersApi.getGlobalApplicationStructure(app).then((response) => {
|
||||
structure.value = response.data
|
||||
})
|
||||
parametersApi.getApplication(app).then((response) => {
|
||||
parametersApi.getGlobalApplication(app).then((response) => {
|
||||
parameters.value = response.data.params
|
||||
label.value = response.data.label
|
||||
})
|
||||
}
|
||||
|
||||
function display(element) {
|
||||
if (element.display === '') {
|
||||
return true
|
||||
function onSuccess(newValues) {
|
||||
globalStore.fetchNotifications()
|
||||
if (route.params.app === 'imap_migration') {
|
||||
parametersStore.imapMigrationEnabled = newValues.enabled_imapmigration
|
||||
}
|
||||
let result = true
|
||||
element.display.split('&').forEach((rule) => {
|
||||
let [field, value] = rule.split('=')
|
||||
if (value === 'true' || value === 'false') {
|
||||
value = Boolean(value)
|
||||
}
|
||||
result &= parameters.value[field] === value
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
function displayableParams(params) {
|
||||
return params.filter((param) => display(param))
|
||||
}
|
||||
|
||||
async function save() {
|
||||
formErrors.value = {}
|
||||
tab_error.value = []
|
||||
parametersApi
|
||||
.saveApplication(route.params.app, parameters.value)
|
||||
.then(() => {
|
||||
busStore.displayNotification({
|
||||
msg: $gettext('Parameters updated'),
|
||||
})
|
||||
globalStore.fetchNotifications()
|
||||
if (route.params.app === 'imap_migration') {
|
||||
parametersStore.imapMigrationEnabled =
|
||||
parameters.value.enabled_imapmigration
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
busStore.displayNotification({
|
||||
msg: $gettext('Could not save parameters'),
|
||||
type: 'error',
|
||||
})
|
||||
formErrors.value = error.response.data
|
||||
Object.keys(formErrors.value).forEach((element) => {
|
||||
for (let i = 0; i < structure.value.length; i++) {
|
||||
const isIn =
|
||||
structure.value[i].parameters.filter(
|
||||
(param) => param.name === element
|
||||
).length > 0
|
||||
if (isIn) {
|
||||
tab_error.value.push(i)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
watch(route, (toRoute) => {
|
||||
loadParams(toRoute.params.app)
|
||||
})
|
||||
|
||||
watch(parameters, () => {
|
||||
tab_error.value = []
|
||||
})
|
||||
loadParams(route.params.app)
|
||||
</script>
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ const account = computed(() => {
|
||||
})
|
||||
const limitsConfig = ref({})
|
||||
|
||||
parametersApi.getApplication('limits').then((resp) => {
|
||||
parametersApi.getGlobalApplication('limits').then((resp) => {
|
||||
limitsConfig.value = resp.data
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
<template>
|
||||
<div class="text-h5 ml-4 mb-6">
|
||||
{{ $gettext('Calendar') }}
|
||||
</div>
|
||||
<FullCalendar ref="calendarRef" :options="calendarOptions" />
|
||||
|
||||
<v-dialog v-model="showInformation" persistent max-width="800px">
|
||||
<CalendarDetail :calendar="selectedCalendar" @close="closeInformation" />
|
||||
</v-dialog>
|
||||
<v-dialog v-model="showCalendarForm" persistent max-width="800px">
|
||||
<CalendarForm
|
||||
:initial-calendar="selectedCalendar"
|
||||
@color-changed="refreshCalendarEvents"
|
||||
@close="closeCalendarForm"
|
||||
/>
|
||||
</v-dialog>
|
||||
<v-dialog v-model="showEventForm" persistent max-width="800px">
|
||||
<EventForm
|
||||
:info="selectInfo"
|
||||
:event="selectedEvent"
|
||||
@close="closeEventForm"
|
||||
/>
|
||||
</v-dialog>
|
||||
<v-dialog v-model="showAccessRulesForm" persistent max-width="800px">
|
||||
<CalendarAccessRulesForm
|
||||
v-if="selectedCalendar"
|
||||
:calendar-pk="selectedCalendar.pk"
|
||||
@close="closeAccessRulesForm"
|
||||
/>
|
||||
</v-dialog>
|
||||
<v-dialog v-model="showImportEventsForm" persistent max-width="800px">
|
||||
<ImportEventsForm
|
||||
v-if="selectedCalendar"
|
||||
:calendar="selectedCalendar"
|
||||
@events-imported="refreshCalendarEvents"
|
||||
@close="closeImportEventsForm"
|
||||
/>
|
||||
</v-dialog>
|
||||
<ConfirmDialog ref="confirm" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
import { useLayoutStore } from '@/stores'
|
||||
import { useBusStore } from '@/stores'
|
||||
import { DateTime } from 'luxon'
|
||||
import api from '@/api/calendars'
|
||||
import CalendarAccessRulesForm from '@/components/calendars/CalendarAccessRulesForm'
|
||||
import CalendarForm from '@/components/calendars/CalendarForm'
|
||||
import CalendarDetail from '@/components/calendars/CalendarDetail'
|
||||
import ConfirmDialog from '@/components/tools/ConfirmDialog'
|
||||
import EventForm from '@/components/calendars/EventForm'
|
||||
import ImportEventsForm from '@/components/calendars/ImportEventsForm'
|
||||
import FullCalendar from '@fullcalendar/vue3'
|
||||
import dayGridPlugin from '@fullcalendar/daygrid'
|
||||
import interactionPlugin from '@fullcalendar/interaction'
|
||||
import timeGridPlugin from '@fullcalendar/timegrid'
|
||||
|
||||
const { $gettext, current } = useGettext()
|
||||
const layoutStore = useLayoutStore()
|
||||
const busStore = useBusStore()
|
||||
|
||||
const calendarRef = ref()
|
||||
const confirm = ref()
|
||||
const selectInfo = ref()
|
||||
const selectedCalendar = ref(null)
|
||||
const selectedEvent = ref(null)
|
||||
const userCalendars = ref([])
|
||||
const showAccessRulesForm = ref(false)
|
||||
const showCalendarForm = ref(false)
|
||||
const showInformation = ref(false)
|
||||
const showEventForm = ref(false)
|
||||
const showImportEventsForm = ref(false)
|
||||
|
||||
const leftMenuItems = computed(() => {
|
||||
const result = [
|
||||
{
|
||||
icon: 'mdi-plus',
|
||||
text: $gettext('New calendar'),
|
||||
action: openCalendarForm,
|
||||
},
|
||||
{
|
||||
text: $gettext('My calendars'),
|
||||
subheader: true,
|
||||
},
|
||||
]
|
||||
for (const calendar of userCalendars.value) {
|
||||
result.push({
|
||||
text: calendar.name,
|
||||
children: [
|
||||
{
|
||||
text: $gettext('Information'),
|
||||
icon: 'mdi-information-outline',
|
||||
action: () => openInformation(calendar),
|
||||
},
|
||||
{
|
||||
text: $gettext('Edit'),
|
||||
icon: 'mdi-pencil',
|
||||
action: () => editCalendar(calendar),
|
||||
},
|
||||
{
|
||||
text: $gettext('Access rules'),
|
||||
icon: 'mdi-filter-outline',
|
||||
action: () => openAccessRulesForm(calendar),
|
||||
},
|
||||
{
|
||||
text: $gettext('Delete'),
|
||||
icon: 'mdi-trash-can-outline',
|
||||
action: () => deleteCalendar(calendar.pk),
|
||||
},
|
||||
{
|
||||
text: $gettext('Import'),
|
||||
icon: 'mdi-calendar-import-outline',
|
||||
action: () => openImportEventsForm(calendar),
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
async function fetchUserCalendarEvents(
|
||||
calendarPk,
|
||||
info,
|
||||
successCallback,
|
||||
failureCallback
|
||||
) {
|
||||
try {
|
||||
const resp = await api.getUserCalendarEvents(calendarPk, {
|
||||
start: info.startStr,
|
||||
end: info.endStr,
|
||||
})
|
||||
successCallback(resp.data)
|
||||
} catch (err) {
|
||||
failureCallback(err)
|
||||
}
|
||||
}
|
||||
|
||||
function openCalendarForm() {
|
||||
showCalendarForm.value = true
|
||||
}
|
||||
|
||||
function closeCalendarForm() {
|
||||
selectedCalendar.value = null
|
||||
showCalendarForm.value = false
|
||||
fetchUserCalendars()
|
||||
}
|
||||
|
||||
function openInformation(calendar) {
|
||||
selectedCalendar.value = calendar
|
||||
showInformation.value = true
|
||||
}
|
||||
|
||||
function closeInformation() {
|
||||
selectedCalendar.value = null
|
||||
showInformation.value = false
|
||||
}
|
||||
|
||||
function openAccessRulesForm(calendar) {
|
||||
selectedCalendar.value = calendar
|
||||
showAccessRulesForm.value = true
|
||||
}
|
||||
|
||||
function closeAccessRulesForm() {
|
||||
selectedCalendar.value = null
|
||||
showAccessRulesForm.value = false
|
||||
}
|
||||
|
||||
function openImportEventsForm(calendar) {
|
||||
selectedCalendar.value = calendar
|
||||
showImportEventsForm.value = true
|
||||
}
|
||||
|
||||
function closeImportEventsForm() {
|
||||
selectedCalendar.value = null
|
||||
showImportEventsForm.value = false
|
||||
}
|
||||
|
||||
function fetchUserCalendars() {
|
||||
api.getUserCalendars().then((resp) => {
|
||||
userCalendars.value = resp.data
|
||||
})
|
||||
}
|
||||
|
||||
function refreshCalendarEvents(calendar) {
|
||||
const fullc = calendarRef.value.getApi()
|
||||
const evtSource = fullc.getEventSourceById(`user-${calendar.pk}`)
|
||||
evtSource.refetch()
|
||||
}
|
||||
|
||||
function selectCallback(info) {
|
||||
selectInfo.value = info
|
||||
showEventForm.value = true
|
||||
}
|
||||
|
||||
async function eventClickCallback(info) {
|
||||
const resp = await api.getUserEvent(
|
||||
info.event.extendedProps.calendar.pk,
|
||||
info.event.id
|
||||
)
|
||||
selectedEvent.value = resp.data
|
||||
showEventForm.value = true
|
||||
}
|
||||
|
||||
function updateEventDates(calEvent) {
|
||||
var data = {
|
||||
start: calEvent.start,
|
||||
end: calEvent.end,
|
||||
}
|
||||
const evtCalendar = calEvent.extendedProps.calendar
|
||||
if (calEvent.allDay) {
|
||||
const end = DateTime.fromJSDate(data.start)
|
||||
end.plus({ days: 1 })
|
||||
data.start.setHours(0, 0, 0)
|
||||
data.end = end
|
||||
data.allDay = calEvent.allDay
|
||||
} else if (!calEvent.end) {
|
||||
const end = DateTime.fromJSDate(data.start)
|
||||
end.plus({ hours: 1 })
|
||||
data.end = end
|
||||
}
|
||||
api.patchUserEvent(evtCalendar.pk, calEvent.id, data).then((response) => {
|
||||
busStore.displayNotification({ msg: $gettext('Event updated') })
|
||||
})
|
||||
}
|
||||
function eventDropCallback(info) {
|
||||
updateEventDates(info.event)
|
||||
}
|
||||
function eventResizeCallback(info) {
|
||||
updateEventDates(info.event)
|
||||
}
|
||||
|
||||
function closeEventForm() {
|
||||
showEventForm.value = false
|
||||
selectedEvent.value = null
|
||||
}
|
||||
|
||||
function editCalendar(calendar) {
|
||||
selectedCalendar.value = calendar
|
||||
showCalendarForm.value = true
|
||||
}
|
||||
|
||||
async function deleteCalendar(calendarPk) {
|
||||
const result = await confirm.value.open(
|
||||
$gettext('Warning'),
|
||||
$gettext('Do you really want to delete this calendar?'),
|
||||
{ color: 'warning' }
|
||||
)
|
||||
if (!result) {
|
||||
return
|
||||
}
|
||||
await api.deleteUserCalendar(calendarPk)
|
||||
const fullc = calendarRef.value.getApi()
|
||||
const evtSource = fullc.getEventSourceById(`user-${calendar.pk}`)
|
||||
evtSource.remove()
|
||||
busStore.displayNotification({ msg: $gettext('Calendar deleted') })
|
||||
}
|
||||
|
||||
const calendarOptions = {
|
||||
plugins: [timeGridPlugin, dayGridPlugin, interactionPlugin],
|
||||
headerToolbar: {
|
||||
left: 'prev,next today',
|
||||
center: 'title',
|
||||
right: 'dayGridMonth,timeGridWeek,timeGridDay',
|
||||
},
|
||||
scrollTime: '09:00:00',
|
||||
height: '100%',
|
||||
firstDay: 1,
|
||||
initialView: 'timeGridWeek',
|
||||
locale: current,
|
||||
selectable: true,
|
||||
selectMirror: true,
|
||||
editable: true,
|
||||
dayMaxEventRows: true,
|
||||
businessHours: {
|
||||
daysOfWeek: [1, 2, 3, 4, 5],
|
||||
startTime: '09:00',
|
||||
endTime: '18:00',
|
||||
},
|
||||
select: selectCallback,
|
||||
eventClick: eventClickCallback,
|
||||
eventDrop: eventDropCallback,
|
||||
eventResize: eventResizeCallback,
|
||||
}
|
||||
|
||||
watch(
|
||||
leftMenuItems,
|
||||
(value) => {
|
||||
layoutStore.setLeftMenuItems(value)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(userCalendars, (value) => {
|
||||
const fullc = calendarRef.value.getApi()
|
||||
if (!fullc) {
|
||||
return
|
||||
}
|
||||
for (const calendar of value) {
|
||||
const evtSource = fullc.getEventSourceById(`user-${calendar.pk}`)
|
||||
if (!evtSource) {
|
||||
fullc.addEventSource({
|
||||
id: `user-${calendar.pk}`,
|
||||
events: fetchUserCalendarEvents.bind(this, calendar.pk),
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
fetchUserCalendars()
|
||||
})
|
||||
</script>
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
default_app_config = "modoboa.calendars.apps.CalendarsConfig"
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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 <allannkorh@yahoo.fr>, 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 <tonio@ngyn.org>\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 ""
|
||||
@@ -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 <miris@kunago.com>, 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 <miris@kunago.com>\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"
|
||||
@@ -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 <arvedui@posteo.de>, 2014
|
||||
# Patrick Koetter <p@state-of-mind.de>, 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 <arvedui@posteo.de>\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"
|
||||
@@ -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 <EMAIL@ADDRESS>, 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 <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\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 ""
|
||||
@@ -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 <ea2809@gmail.com>, 2016
|
||||
# Evilham <github@evilham.com>, 2017
|
||||
# Luis Fajardo López <luis@fajardolopez.com>, 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 <tonio@ngyn.org>\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 ""
|
||||
@@ -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 <tonio@ngyn.org>, 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 <tonio@ngyn.org>\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)"
|
||||
@@ -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 <lallo@artiemestieri.tn.it>, 2013
|
||||
# Rocco <mors10@hotmail.com>, 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 <tonio@ngyn.org>\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 ""
|
||||
@@ -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 <buddha@cryptolab.net>, 2014
|
||||
# TuxBrother <vrijwilligerswerk@wij.rs>, 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 <vrijwilligerswerk@wij.rs>\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"
|
||||
@@ -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 <paulino@michelazzo.com.br>, 2014-2015
|
||||
# Rafael Barretto Alves <ch3yd@hotmail.com>, 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 <paulino@michelazzo.com.br>\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"
|
||||
@@ -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 <dduponchelle@gmail.com>, 2013
|
||||
# lusitan <lusitan@hushmail.com>, 2013
|
||||
# Mike C. <miguelmclara@gmail.com>, 2012
|
||||
# Mike C. <miguelmclara@gmail.com>, 2013-2014
|
||||
# Sandra Ribeiro <sandrarib@gmail.com>, 2012-2013
|
||||
# Sandra Ribeiro <sandrarib@gmail.com>, 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. <miguelmclara@gmail.com>\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"
|
||||
@@ -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 <andrei@flashsoft.ro>, 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 <andrei@flashsoft.ro>\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 ""
|
||||
@@ -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 <tonio@ngyn.org>\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 "Фильтр удален"
|
||||
@@ -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 <martin.jens.persson@gmail.com>, 2014
|
||||
# Olle Gustafsson <olle@dalnix.se>, 2013
|
||||
# Olle Gustafsson <olle@dalnix.se>, 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 <olle@dalnix.se>\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"
|
||||
@@ -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 <akong@aspa.idv.tw>, 2018
|
||||
# Radium <ztl8702@126.com>, 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 <akong@aspa.idv.tw>\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 ""
|
||||
@@ -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)
|
||||
@@ -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")]),
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
]
|
||||
@@ -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),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
)
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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()
|
||||
|
||||
@@ -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"])
|
||||
@@ -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 """<script>
|
||||
# $(document).ready(function () {
|
||||
# new Poller('%s', {
|
||||
# interval: %d * 1000
|
||||
# });
|
||||
# });
|
||||
# </script>
|
||||
# """ % (reverse("api:addressbook-sync-from-cdav"),
|
||||
# user.parameters.get_value("sync_frequency"))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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, '<div id="app">')
|
||||
|
||||
|
||||
class AddressBookViewSetTestCase(TestDataMixin, ModoAPITestCase):
|
||||
"""Address book ViewSet tests."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"])
|
||||
|
||||
+49
-18
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -77,6 +77,7 @@ dependencies = [
|
||||
# Sieve
|
||||
"sievelib>=1.4.1",
|
||||
# User
|
||||
"drf-nested-routers",
|
||||
"vobject",
|
||||
"caldav==1.3.9"
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user