Added calendars plugin and improved used part

This commit is contained in:
Antoine Nguyen
2025-02-14 13:41:10 +01:00
parent 1b47553b9c
commit 30a9414180
102 changed files with 6274 additions and 423 deletions
+4
View File
@@ -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
+13
View File
@@ -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
+1 -1
View File
@@ -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
+14
View File
@@ -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
+159
View File
@@ -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 = *
+135
View File
@@ -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
+5
View File
@@ -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",
+58
View File
@@ -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/')
},
}
+9
View File
@@ -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 })
},
+9
View File
@@ -0,0 +1,9 @@
import repository from './repository'
const resource = 'mailboxes'
export default {
getAll() {
return repository.get(`${resource}/`)
},
}
+21 -8
View File
@@ -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,
+17 -7
View File
@@ -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 -1
View File
@@ -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>
+27 -10
View File
@@ -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>
+2 -2
View File
@@ -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>
+2
View File
@@ -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)
}
+32
View File
@@ -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
}
},
}
+5 -1
View File
@@ -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) =>
+24
View File
@@ -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>
+14 -145
View File
@@ -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>
+34
View File
@@ -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"
+4
View File
@@ -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
+19 -1
View File
@@ -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."""
+1
View File
@@ -0,0 +1 @@
default_app_config = "modoboa.calendars.apps.CalendarsConfig"
+92
View File
@@ -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,
)
+16
View File
@@ -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()
+41
View File
@@ -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)
+163
View File
@@ -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
+34
View File
@@ -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)
+79
View File
@@ -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),
),
]
+89
View File
@@ -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)
+167
View File
@@ -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")
)
+244
View File
@@ -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")
+42
View File
@@ -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
+583
View File
@@ -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)
+29
View File
@@ -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
+273
View File
@@ -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
)
+54
View File
@@ -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 -9
View File
@@ -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()
-59
View File
@@ -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"])
-47
View File
@@ -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"))
+3 -2
View File
@@ -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):
+1 -1
View File
@@ -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(
+27 -1
View File
@@ -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"])
-28
View File
@@ -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
+1 -3
View File
@@ -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,
)
+3 -31
View File
@@ -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."""
+18 -3
View File
@@ -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 -1
View File
@@ -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
+48 -10
View File
@@ -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
View File
@@ -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()
+1
View File
@@ -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")
+4
View File
@@ -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
+4
View File
@@ -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
+3 -1
View File
@@ -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
+1
View File
@@ -77,6 +77,7 @@ dependencies = [
# Sieve
"sievelib>=1.4.1",
# User
"drf-nested-routers",
"vobject",
"caldav==1.3.9"
]
+4
View File
@@ -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