Added contacts plugin into core.

No more module federation at the moment, it does not seem very stable
yet.
This commit is contained in:
Antoine Nguyen
2025-02-14 13:39:08 +01:00
parent e997bdd971
commit 8980f4c4c4
71 changed files with 4432 additions and 707 deletions
+6 -6
View File
@@ -12,12 +12,12 @@ services:
DB: "SQLITE"
DEBUG: "True"
REDIS_HOST: redis
PYTHONPATH: "..:/modoboa_user"
PYTHONPATH: ".."
PYTHONUNBUFFERED: 1
command: >
sh -c "cd test_project; python3 manage.py rqworker dkim modoboa"
volumes:
- .:/code
- ../modoboa-user:/modoboa_user
api:
container_name: modoboa_api
@@ -33,7 +33,8 @@ services:
DB: "SQLITE"
DEBUG: "True"
REDIS_HOST: redis
PYTHONPATH: "..:/modoboa_user"
PYTHONPATH: ".."
PYTHONUNBUFFERED: 1
command: >
sh -c "cd test_project
python3 manage.py migrate
@@ -42,7 +43,6 @@ services:
python3 manage.py runserver_plus 0.0.0.0:8000 --cert-file cert.crt"
volumes:
- .:/code
- ../modoboa-user:/modoboa_user
api-unsecured:
container_name: modoboa_api_unsecured
@@ -58,13 +58,13 @@ services:
DB: "SQLITE"
DEBUG: "True"
REDIS_HOST: redis
PYTHONPATH: "..:/modoboa_user"
PYTHONPATH: ".."
PYTHONUNBUFFERED: 1
command: >
sh -c "cd test_project
python3 manage.py runserver_plus 0.0.0.0:8001"
volumes:
- .:/code
- ../modoboa-user:/modoboa_user
front:
container_name: modoboa_front
-1
View File
@@ -13,7 +13,6 @@ RUN apk add --update openssl librrd python3-dev libffi-dev gcc g++ musl-dev libx
RUN python3 -m venv $VIRTUAL_ENV
WORKDIR /tmp
COPY requirements.txt /tmp
COPY user-requirements.txt /tmp
COPY test-requirements.txt /tmp
COPY dev-requirements.txt /tmp
RUN python -m pip install -U pip
+1 -1
View File
@@ -7,4 +7,4 @@ RUN apk add --no-cache --update gettext \
WORKDIR $HOME
RUN yarn install
RUN yarn install --dev
-1
View File
@@ -34,7 +34,6 @@
"vuetify": "^3.6.12"
},
"devDependencies": {
"@module-federation/vite": "^1.1.3",
"@vitejs/plugin-basic-ssl": "^1.1.0",
"@vitejs/plugin-vue": "^5.0.5",
"eslint": "^9.6.0",
+28
View File
@@ -0,0 +1,28 @@
import repository from './repository'
export default {
getContacts(params) {
return repository.get('/contacts/', { params })
},
createContact(data) {
return repository.post('/contacts/', data)
},
updateContact(contactId, data) {
return repository.patch(`/contacts/${contactId}/`, data)
},
deleteContact(contactId) {
return repository.delete(`/contacts/${contactId}/`)
},
getCategories() {
return repository.get('/categories/')
},
addCategory(data) {
return repository.post('/categories/', data)
},
updateCategory(categoryId, data) {
return repository.patch(`/categories/${categoryId}/`, data)
},
deleteCategory(categoryId) {
return repository.delete(`/categories/${categoryId}/`)
},
}
@@ -0,0 +1,106 @@
<template>
<v-card>
<v-form ref="formRef" @submit.prevent="submit">
<v-card-title>
<span class="headline">{{ title }}</span>
</v-card-title>
<v-card-text>
<v-text-field
v-model="form.name"
:label="$gettext('Name')"
variant="outlined"
density="compact"
:rules="[rules.required]"
/>
</v-card-text>
<v-card-actions>
<v-btn v-if="category" color="red" @click="deleteCategory">
{{ $gettext('Delete') }}
</v-btn>
<v-spacer></v-spacer>
<v-btn :loading="working" @click="close">
{{ $gettext('Close') }}
</v-btn>
<v-btn color="primary" type="submit" :loading="working">
{{ submitLabel }}
</v-btn>
</v-card-actions>
</v-form>
</v-card>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useGettext } from 'vue3-gettext'
import contactsApi from '@/api/contacts'
import rules from '@/plugins/rules'
const props = defineProps({
category: {
type: Object,
default: null,
},
})
const emit = defineEmits(['close', 'added', 'updated'])
const router = useRouter()
const { $gettext } = useGettext()
const form = ref({})
const formRef = ref()
const working = ref(false)
const title = computed(() => {
return props.category ? $gettext('Edit category') : $gettext('Add category')
})
const submitLabel = computed(() => {
return props.category ? $gettext('Update') : $gettext('Add')
})
function close() {
form.value = {}
emit('close')
}
async function submit() {
const { valid } = await formRef.value.validate()
if (!valid) {
return
}
working.value = true
try {
let resp
if (props.category) {
resp = await contactsApi.updateCategory(props.category.pk, form.value)
emit('updated', resp.data)
} else {
resp = await contactsApi.addCategory(form.value)
emit('added', resp.data)
}
emit('close')
} catch (err) {
console.log(err)
} finally {
working.value = false
}
}
function deleteCategory() {
contactsApi.deleteCategory(props.category.pk).then(() => {
close()
router.push({ name: 'ContactList' })
})
}
watch(
() => props.category,
(value) => {
if (value) {
form.value = JSON.parse(JSON.stringify(value))
} else {
form.value = {}
}
},
{ immediate: true }
)
</script>
@@ -0,0 +1,78 @@
<template>
<v-card>
<v-form ref="formRef" @submit.prevent="submit">
<v-card-title>
<span class="headline">{{ $gettext('Categories') }}</span>
</v-card-title>
<v-card-text>
<v-checkbox
v-for="category in categories"
:key="category.pk"
v-model="selectedCategories"
:label="category.name"
hide-details
:value="category.pk"
color="primary"
/>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn :loading="working" @click="close">
{{ $gettext('Close') }}
</v-btn>
<v-btn color="primary" type="submit" :loading="working">
{{ $gettext('Update') }}
</v-btn>
</v-card-actions>
</v-form>
</v-card>
</template>
<script setup>
import { ref, watch } from 'vue'
import contactsApi from '@/api/contacts'
const props = defineProps({
contact: {
type: Object,
default: null,
},
})
const emit = defineEmits(['close'])
const categories = ref([])
const selectedCategories = ref([])
const working = ref(false)
function close() {
emit('close', 'updated')
}
async function submit() {
const data = { categories: selectedCategories.value }
working.value = true
try {
await contactsApi.updateContact(props.contact.pk, data)
emit('updated')
close()
} finally {
working.value = false
}
}
watch(
() => props.contact,
(value) => {
if (value) {
selectedCategories.value = value.categories
} else {
selectedCategories.value = []
}
},
{ immediate: true }
)
contactsApi.getCategories().then((resp) => {
categories.value = resp.data
})
</script>
@@ -0,0 +1,254 @@
<template>
<v-card>
<v-form ref="formRef" @submit.prevent="submit">
<v-card-title>
<span class="headline">{{ title }}</span>
</v-card-title>
<v-card-text>
<v-row no-gutters>
<v-col cols="6">
<v-text-field
v-model="form.first_name"
:label="$gettext('First name')"
variant="outlined"
prepend-icon="mdi-account"
density="compact"
:error-messages="formErrors.first_name"
/>
</v-col>
<v-col cols="6" class="pl-4">
<v-text-field
v-model="form.last_name"
:label="$gettext('Last name')"
variant="outlined"
density="compact"
:error-messages="formErrors.last_name"
/>
</v-col>
<v-col cols="12">
<v-text-field
v-model="form.display_name"
:label="$gettext('Display name')"
variant="outlined"
prepend-icon="toto"
density="compact"
:error-messages="formErrors.display_name"
/>
</v-col>
<v-col cols="6">
<v-text-field
v-model="form.company"
:label="$gettext('Company')"
variant="outlined"
prepend-icon="mdi-office-building"
density="compact"
/>
</v-col>
<v-col cols="6" class="pl-4">
<v-text-field
v-model="form.position"
:label="$gettext('Position')"
variant="outlined"
density="compact"
/>
</v-col>
<EmailField
v-for="(email, index) in form.emails"
:key="`email-${index}`"
v-model="form.emails[index]"
:index="index"
@add="addEmailField"
@delete="deleteEmailField"
/>
<PhoneNumberField
v-for="(phone, index) in form.phone_numbers"
:key="`phone-${index}`"
v-model="form.phone_numbers[index]"
:index="index"
@add="addPhoneNumberField"
@delete="deletePhoneNumberField"
/>
<template v-if="showExtraFields">
<v-col cols="6">
<v-text-field
v-model="form.birth_date"
:label="$gettext('Birth date')"
type="date"
prepend-icon="mdi-calendar"
variant="outlined"
density="compact"
/>
</v-col>
<v-col cols="12">
<v-textarea
v-model="form.address"
:label="$gettext('Address')"
prepend-icon="mdi-location"
variant="outlined"
density="compact"
rows="2"
/>
</v-col>
<v-col cols="6">
<v-text-field
v-model="form.zipcode"
:label="$gettext('Zip code')"
prepend-icon="toto"
variant="outlined"
density="compact"
/>
</v-col>
<v-col cols="6" class="pl-4">
<v-text-field
v-model="form.city"
:label="$gettext('City')"
variant="outlined"
density="compact"
/>
</v-col>
<v-col cols="6">
<v-text-field
v-model="form.country"
:label="$gettext('Country')"
prepend-icon="toto"
variant="outlined"
density="compact"
/>
</v-col>
<v-col cols="6" class="pl-4">
<v-text-field
v-model="form.state"
:label="$gettext('State/Province')"
variant="outlined"
density="compact"
/>
</v-col>
<v-col cols="12">
<v-textarea
v-model="form.note"
:label="$gettext('Note')"
prepend-icon="mdi-file"
variant="outlined"
density="compact"
rows="2"
/>
</v-col>
</template>
</v-row>
</v-card-text>
<v-card-actions>
<v-btn v-if="!showExtraFields" @click="showExtraFields = true">
{{ $gettext('More') }}
</v-btn>
<v-btn v-else @click="showExtraFields = false">
{{ $gettext('Less') }}
</v-btn>
<v-spacer></v-spacer>
<v-btn :loading="working" @click="emit('close')">
{{ $gettext('Close') }}
</v-btn>
<v-btn color="primary" type="submit" :loading="working">
{{ submitLabel }}
</v-btn>
</v-card-actions>
</v-form>
</v-card>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { useGettext } from 'vue3-gettext'
import contactsApi from '@/api/contacts'
import EmailField from './EmailField.vue'
import PhoneNumberField from './PhoneNumberField.vue'
const emit = defineEmits(['close', 'added', 'updated'])
const props = defineProps({
contact: {
type: Object,
required: false,
},
})
const { $gettext } = useGettext()
const form = ref(initialForm())
const showExtraFields = ref(false)
const formRef = ref()
const formErrors = ref({})
const working = ref(false)
const title = computed(() => {
return props.contact ? $gettext('Edit contact') : $gettext('New contact')
})
const submitLabel = computed(() => {
return props.contact ? $gettext('Update') : $gettext('Add')
})
function initialForm() {
return {
emails: [{}],
phone_numbers: [{}],
}
}
function addEmailField() {
form.value.emails.push({})
}
function deleteEmailField(index) {
form.value.emails.splice(index, 1)
}
function addPhoneNumberField() {
form.value.phone_numbers.push({})
}
function deletePhoneNumberField(index) {
form.value.phone_numbers.splice(index, 1)
}
async function submit() {
const { valid } = await formRef.value.validate()
if (!valid) {
return
}
working.value = true
const data = JSON.parse(JSON.stringify(form.value))
if (!Object.keys(data.phone_numbers[0]).length) {
data.phone_numbers.splice(0, 1)
}
try {
let resp
if (props.contact) {
resp = await contactsApi.updateContact(props.contact.pk, data)
emit('updated', resp.data)
} else {
resp = await contactsApi.createContact(data)
emit('added', resp.data)
}
emit('close')
} catch (err) {
if (err.response?.status === 400) {
formErrors.value = err.response.data
}
} finally {
working.value = false
}
}
watch(
() => props.contact,
(value) => {
if (value) {
form.value = JSON.parse(JSON.stringify(value))
if (!form.value.phone_numbers.length) {
form.value.phone_numbers = [{}]
}
} else {
form.value = initialForm()
}
},
{ immediate: true }
)
</script>
@@ -0,0 +1,178 @@
<template>
<v-card class="mt-6">
<v-data-table :headers="headers" :items="contacts">
<template #top>
<v-toolbar color="white" flat>
<v-text-field
v-model="search"
prepend-inner-icon="mdi-magnify"
:placeholder="$gettext('Search')"
variant="outlined"
single-line
flat
hide-details
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-chip
v-if="$route.params.category"
class="my-2 mx-4"
label
size="small"
color="green"
prepend-icon="mdi-label"
@click="editCategory"
>
{{ $route.params.category }}
</v-chip>
<v-spacer></v-spacer>
</v-toolbar>
</template>
<template #[`item.emails`]="{ item }">
{{ displayEmails(item.emails) }}
</template>
<template #[`item.phone_numbers`]="{ item }">
{{ displayPhoneNumbers(item.phone_numbers) }}
</template>
<template #[`item.actions`]="{ item }">
<div class="text-right">
<v-menu offset-y>
<template #activator="{ props }">
<v-btn icon="mdi-dots-horizontal" variant="text" v-bind="props">
</v-btn>
</template>
<MenuItems :items="contactActions" :obj="item" />
</v-menu>
</div>
</template>
</v-data-table>
</v-card>
<v-dialog v-model="showForm" persistent max-width="800px">
<ContactForm
:contact="selectedContact"
@close="closeForm"
@added="fetchContacts"
@updated="fetchContacts"
/>
</v-dialog>
<v-dialog v-model="showCategoriesForm" persistent max-width="800px">
<ContactCategoriesForm
:contact="selectedContact"
@updated="fetchContacts"
@close="closeCategoriesForm"
/>
</v-dialog>
</template>
<script setup>
import { ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useGettext } from 'vue3-gettext'
import contactsApi from '@/api/contacts'
import ContactForm from './ContactForm.vue'
import ContactCategoriesForm from './ContactCategoriesForm.vue'
import MenuItems from '@/components/tools/MenuItems'
const emit = defineEmits(['editCategory'])
const route = useRoute()
const { $gettext } = useGettext()
const contacts = ref([])
const search = ref('')
const selectedContact = ref(null)
const showForm = ref(false)
const showCategoriesForm = ref(false)
const headers = [
{ key: 'display_name', title: $gettext('Display name') },
{ key: 'emails', title: $gettext('E-mail') },
{ key: 'phone_numbers', title: $gettext('Phone') },
{ key: 'actions', title: $gettext('Actions'), align: 'end' },
]
const contactActions = [
{
label: $gettext('Categories'),
icon: 'mdi-label',
onClick: editCategories,
},
{
label: $gettext('Edit'),
icon: 'mdi-circle-edit-outline',
onClick: editContact,
},
{
label: $gettext('Delete'),
icon: 'mdi-delete-outline',
onClick: deleteContact,
color: 'red',
},
]
function closeForm() {
showForm.value = false
selectedContact.value = null
}
function displayEmails(emails) {
return emails.map((email) => email.address).join(', ')
}
function displayPhoneNumbers(phoneNumbers) {
return phoneNumbers.map((phoneNumber) => phoneNumber.number).join(', ')
}
function editContact(contact) {
selectedContact.value = contact
showForm.value = true
}
function editCategory() {
emit('editCategory')
}
function editCategories(contact) {
selectedContact.value = contact
showCategoriesForm.value = true
}
function closeCategoriesForm() {
showCategoriesForm.value = false
selectedContact.value = false
}
function deleteContact(contact) {
contactsApi.deleteContact(contact.pk).then(() => {
fetchContacts()
})
}
function fetchContacts() {
contactsApi.getContacts(route.params).then((resp) => {
contacts.value = resp.data
})
}
fetchContacts()
watch(
() => route.params.category,
() => {
fetchContacts()
}
)
</script>
@@ -0,0 +1,65 @@
<template>
<v-col cols="6">
<v-text-field
v-model="email.address"
:label="$gettext('Address')"
variant="outlined"
prepend-icon="mdi-email"
density="compact"
:rules="[rules.required]"
/>
</v-col>
<v-col cols="4" class="pl-4">
<v-select
v-model="email.type"
:items="types"
density="compact"
variant="outlined"
:rules="[rules.required]"
/>
</v-col>
<v-col cols="2" class="pl-4">
<v-btn icon="mdi-plus" size="x-small" flat @click="$emit('add')" />
<v-btn
v-if="index"
icon="mdi-trash-can"
color="red"
size="x-small"
flat
variant="text"
@click="$emit('delete', index)"
/>
</v-col>
</template>
<script setup>
import { computed } from 'vue'
import rules from '@/plugins/rules'
const props = defineProps({
modelValue: {
type: Object,
default: null,
},
errors: {
type: Object,
default: null,
},
index: {
type: Number,
required: true,
},
})
const emit = defineEmits(['add', 'delete', 'update:modelValue'])
const email = computed({
get() {
return props.modelValue
},
set(value) {
emit('update:modelValue', value)
},
})
const types = ['home', 'work', 'other']
</script>
@@ -0,0 +1,60 @@
<template>
<v-col cols="6">
<v-text-field
v-model="phone.number"
:label="$gettext('Phone number')"
variant="outlined"
prepend-icon="mdi-phone"
density="compact"
/>
</v-col>
<v-col cols="4" class="pl-4">
<v-select
v-model="phone.type"
:items="types"
density="compact"
variant="outlined"
/>
</v-col>
<v-col cols="2" class="pl-4">
<v-btn icon="mdi-plus" size="x-small" flat @click="$emit('add')" />
<v-btn
v-if="index"
icon="mdi-trash-can"
color="red"
size="x-small"
flat
variant="text"
@click="$emit('delete', index)"
/>
</v-col>
</template>
<script setup>
const props = defineProps({
errors: {
type: Object,
default: null,
},
index: {
type: Number,
required: true,
},
modelValue: {
type: Object,
default: null,
},
})
const emit = defineEmits(['add', 'delete', 'update:modelValue'])
const phone = computed({
get() {
return props.modelValue
},
set(value) {
emit('update:modelValue', value)
},
})
const types = ['cell', 'fax', 'home', 'main', 'pager', 'work', 'other']
</script>
@@ -1,3 +0,0 @@
<template>
<v-btn icon="mdi-apps" flat> </v-btn>
</template>
+6 -10
View File
@@ -3,7 +3,7 @@
v-model="drawer"
:rail="rail"
permanent
:color="mainColor"
:color="color"
app
>
<div class="d-flex align-center">
@@ -77,11 +77,15 @@
</template>
<script setup>
import { useRoute, useRouter } from 'vue-router'
import { useRouter } from 'vue-router'
import { ref, computed, onMounted } from 'vue'
import { useGlobalStore, useAuthStore } from '@/stores'
const props = defineProps({
color: {
type: String,
default: 'primary',
},
menuItems: {
type: Array,
required: false,
@@ -95,7 +99,6 @@ const props = defineProps({
const globalStore = useGlobalStore()
const authStore = useAuthStore()
const route = useRoute()
const router = useRouter()
const rail = ref(false)
@@ -104,13 +107,6 @@ const drawer = ref(true)
const authUser = computed(() => authStore.authUser)
const isAuthenticated = computed(() => authStore.isAuthenticated)
const mainColor = computed(() => {
if (route.meta.layout === 'account') {
return 'grey'
}
return 'primary'
})
const displayInformationBell = computed(
() =>
globalStore.notifications.length !== undefined &&
+3 -23
View File
@@ -11,20 +11,6 @@
<v-list-item :title="user.username" />
</div>
<v-divider></v-divider>
<template v-if="remote">
<v-list-item href="https://localhost:3000/account">
<template #prepend>
<v-icon icon="mdi-account-circle-outline"></v-icon>
</template>
<v-list-item-title>Account</v-list-item-title>
</v-list-item>
<v-list-item @click="logout">
<template #prepend>
<v-icon icon="mdi-logout"></v-icon>
</template>
<v-list-item-title>Logout</v-list-item-title>
</v-list-item>
</template>
</v-list>
</v-card>
</v-menu>
@@ -33,26 +19,20 @@
<script setup>
import { computed } from 'vue'
import { getActivePinia } from 'pinia'
const props = defineProps({
user: {
type: Object,
default: null,
},
remote: {
type: Boolean,
default: false,
},
})
const userInitials = computed(() => {
if (!props.user) {
return ''
}
return props.user.first_name && props.user.last_name
? `${props.user.first_name[0].toUpperCase()}${props.user.last_name[0].toUpperCase()}`
: props.user.username.slice(0, 2).toUpperCase()
})
async function logout() {
getActivePinia()._s.forEach(async (store) => await store.$reset())
}
</script>
@@ -0,0 +1,33 @@
<template>
<ConnectedLayout color="grey" :menu-items="getUserSettingsMenuItems()" />
</template>
<script setup>
import { computed } from 'vue'
import { useGettext } from 'vue3-gettext'
import { useAuthStore } from '@/stores'
import ConnectedLayout from '@/layouts/connected/ConnectedLayout.vue'
const authStore = useAuthStore()
const { $gettext } = useGettext()
const authUser = computed(() => authStore.authUser)
function getUserSettingsMenuItems() {
const result = []
if (authUser.value.mailbox) {
result.push({
text: $gettext('Filters'),
to: { name: 'AccountFilters' },
icon: 'mdi-filter',
})
}
result.push({
text: $gettext('Settings'),
to: { name: 'AccountSettings' },
icon: 'mdi-cog',
})
return result
}
</script>
+119
View File
@@ -0,0 +1,119 @@
<template>
<ConnectedLayout :menu-items="adminMenuItems" />
</template>
<script setup>
import { computed, onMounted } from 'vue'
import { useGettext } from 'vue3-gettext'
import { useAuthStore, useParametersStore } from '@/stores'
import parametersApi from '@/api/parameters'
import ConnectedLayout from '@/layouts/connected/ConnectedLayout.vue'
const { $gettext } = useGettext()
const authStore = useAuthStore()
const parametersStore = useParametersStore()
const authUser = computed(() => authStore.authUser)
const imapMigrationEnabled = computed(
() => parametersStore.imapMigrationEnabled
)
const settings = []
const adminMenuItems = [
{
text: $gettext('Dashboard'),
to: { name: 'Dashboard' },
exact: true,
icon: 'mdi-view-dashboard-outline',
},
{
text: $gettext('Domains'),
to: { name: 'DomainList' },
icon: 'mdi-domain',
roles: ['DomainAdmins', 'Resellers', 'SuperAdmins'],
},
{
text: $gettext('Identities'),
to: { name: 'Identities' },
icon: 'mdi-account',
roles: ['DomainAdmins', 'Resellers', 'SuperAdmins'],
},
{
text: $gettext('Alarms'),
to: { name: 'Alarms' },
icon: 'mdi-bell',
},
{
icon: 'mdi-history',
text: $gettext('Monitoring'),
roles: ['SuperAdmins', 'Resellers', 'DomainAdmins'],
children: [
{
text: $gettext('Statistics'),
to: { name: 'Statistics' },
roles: ['SuperAdmins'],
},
{
text: $gettext('Audit trail'),
to: { name: 'AuditTrail' },
roles: ['SuperAdmins'],
},
{
text: $gettext('Messages'),
to: { name: 'MessageLog' },
roles: ['DomainAdmins', 'Resellers', 'SuperAdmins'],
},
],
},
{
icon: 'mdi-email-sync-outline',
text: $gettext('IMAP Migration'),
roles: ['SuperAdmins', 'Resellers'],
condition: () => imapMigrationEnabled.value,
children: [
{
text: $gettext('Email providers'),
to: { name: 'ProvidersList' },
roles: ['SuperAdmins', 'Resellers'],
},
{
text: $gettext('Migrations'),
to: { name: 'MigrationsList' },
roles: ['Resellers', 'SuperAdmins'],
},
],
},
{
icon: 'mdi-cog',
text: $gettext('Settings'),
children: settings,
roles: ['SuperAdmins'],
},
{
icon: 'mdi-information',
text: $gettext('Information'),
roles: ['SuperAdmins'],
to: { name: 'Information' },
withBell: true,
},
]
onMounted(() => {
if (authUser.value.role === 'SuperAdmins') {
parametersApi.getApplications().then((response) => {
response.data.forEach((item) => {
settings.push({
text: item.label,
to: { name: 'ParametersEdit', params: { app: item.name } },
})
})
})
if (parametersStore.imapMigrationEnabled === null) {
parametersApi.getApplication('imap_migration').then((response) => {
parametersStore.imapMigrationEnabled =
response.data.params.enabled_imapmigration
})
}
}
})
</script>
@@ -1,6 +1,6 @@
<template>
<v-app>
<NavBar :menu-items="menuItems" />
<NavBar :color="color" :menu-items="menuItems" />
<TopMenu :user="authUser" />
<ConnectedView />
<v-snackbar
@@ -20,153 +20,34 @@
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { ref, computed } from 'vue'
import { useGettext } from 'vue3-gettext'
import { useAuthStore, useBusStore, useParametersStore } from '@/stores'
import parametersApi from '@/api/parameters'
import { useAuthStore, useBusStore } from '@/stores'
import ConnectedView from './ConnectedView.vue'
import NavBar from '@/components/shared/NavBar.vue'
import TopMenu from '@/components/shared/TopMenu.vue'
const authStore = useAuthStore()
const busStore = useBusStore()
const parametersStore = useParametersStore()
const { $gettext } = useGettext()
const route = useRoute()
const props = defineProps({
color: {
type: String,
default: 'primary',
},
menuItems: {
type: Array,
default: null,
},
})
const authUser = computed(() => authStore.authUser)
const notificationColor = computed(() => busStore.notificationColor)
const notification = computed(() => busStore.notification)
const snackbar = ref(false)
const imapMigrationEnabled = computed(
() => parametersStore.imapMigrationEnabled
)
const notificationTimeout = 2000
const settings = []
const adminMenuItems = [
{
text: $gettext('Dashboard'),
to: { name: 'Dashboard' },
exact: true,
icon: 'mdi-view-dashboard-outline',
},
{
text: $gettext('Domains'),
to: { name: 'DomainList' },
icon: 'mdi-domain',
roles: ['DomainAdmins', 'Resellers', 'SuperAdmins'],
},
{
text: $gettext('Identities'),
to: { name: 'Identities' },
icon: 'mdi-account',
roles: ['DomainAdmins', 'Resellers', 'SuperAdmins'],
},
{
text: $gettext('Alarms'),
to: { name: 'Alarms' },
icon: 'mdi-bell',
},
{
icon: 'mdi-history',
text: $gettext('Monitoring'),
roles: ['SuperAdmins', 'Resellers', 'DomainAdmins'],
children: [
{
text: $gettext('Statistics'),
to: { name: 'Statistics' },
roles: ['SuperAdmins'],
},
{
text: $gettext('Audit trail'),
to: { name: 'AuditTrail' },
roles: ['SuperAdmins'],
},
{
text: $gettext('Messages'),
to: { name: 'MessageLog' },
roles: ['DomainAdmins', 'Resellers', 'SuperAdmins'],
},
],
},
{
icon: 'mdi-email-sync-outline',
text: $gettext('IMAP Migration'),
roles: ['SuperAdmins', 'Resellers'],
condition: () => imapMigrationEnabled.value,
children: [
{
text: $gettext('Email providers'),
to: { name: 'ProvidersList' },
roles: ['SuperAdmins', 'Resellers'],
},
{
text: $gettext('Migrations'),
to: { name: 'MigrationsList' },
roles: ['Resellers', 'SuperAdmins'],
},
],
},
{
icon: 'mdi-cog',
text: $gettext('Settings'),
children: settings,
roles: ['SuperAdmins'],
},
{
icon: 'mdi-information',
text: $gettext('Information'),
roles: ['SuperAdmins'],
to: { name: 'Information' },
withBell: true,
},
]
function getUserSettingsMenuItems() {
const result = []
if (authUser.value.mailbox) {
result.push({
text: $gettext('Filters'),
to: { name: 'AccountFilters' },
icon: 'mdi-filter',
})
}
result.push({
text: $gettext('Settings'),
to: { name: 'AccountSettings' },
icon: 'mdi-cog',
})
return result
}
const menuItems = computed(() => {
if (route.meta.layout === 'account') {
return getUserSettingsMenuItems()
}
return adminMenuItems
})
onMounted(() => {
if (authUser.value.role === 'SuperAdmins') {
parametersApi.getApplications().then((response) => {
response.data.forEach((item) => {
settings.push({
text: item.label,
to: { name: 'ParametersEdit', params: { app: item.name } },
})
})
})
if (parametersStore.imapMigrationEnabled === null) {
parametersApi.getApplication('imap_migration').then((response) => {
parametersStore.imapMigrationEnabled =
response.data.params.enabled_imapmigration
})
}
}
})
busStore.$onAction(({ name, after }) => {
if (name === 'displayNotification') {
+10
View File
@@ -0,0 +1,10 @@
<template>
<ConnectedLayout :menu-items="layoutStore.leftMenuItems" />
</template>
<script setup>
import { useLayoutStore } from '@/stores'
import ConnectedLayout from '@/layouts/connected/ConnectedLayout.vue'
const layoutStore = useLayoutStore()
</script>
+38 -5
View File
@@ -16,12 +16,15 @@ const routes = [
path: 'logged',
name: 'LoginCallback',
component: () => import('@/views/login/LoginCallbackView.vue'),
props: {
redirectUrl: '/admin',
},
},
],
},
{
path: '/admin',
component: () => import('@/layouts/connected/ConnectedLayout.vue'),
component: () => import('@/layouts/admin/AdminLayout.vue'),
children: [
{
path: '',
@@ -233,9 +236,8 @@ const routes = [
},
{
path: '/account',
component: () => import('@/layouts/connected/ConnectedLayout.vue'),
component: () => import('@/layouts/account/AccountLayout.vue'),
meta: {
layout: 'account',
requiresAuth: true,
},
children: [
@@ -244,7 +246,6 @@ const routes = [
name: 'AccountFilters',
component: () => import('@/views/account/FiltersView.vue'),
meta: {
layout: 'account',
requiresAuth: true,
requiresMailbox: true,
},
@@ -254,12 +255,44 @@ const routes = [
name: 'AccountSettings',
component: () => import('@/views/account/SettingsView.vue'),
meta: {
layout: 'account',
requiresAuth: true,
},
},
],
},
{
path: '/user',
component: () => import('@/layouts/user/UserLayout.vue'),
meta: {
requiresAuth: true,
},
children: [
{
path: '',
name: 'UserDashboard',
component: () => import('@/views/DashboardView.vue'),
meta: {
requiresAuth: true,
},
},
{
path: 'contacts',
meta: {
requiresAuth: true,
},
children: [
{
path: ':category?',
name: 'ContactList',
component: () => import('@/views/contacts/AddressBook.vue'),
meta: {
requiresAuth: true,
},
},
],
},
],
},
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
+2 -2
View File
@@ -130,7 +130,7 @@ export const useAuthStore = defineStore('auth', () => {
}
}
async function completeLogin() {
async function completeLogin(redirectUrl) {
try {
const user = await manager.signinRedirectCallback()
isAuthenticated.value = true
@@ -140,7 +140,7 @@ export const useAuthStore = defineStore('auth', () => {
window.location.href = previousPage
} else {
// Redirect to a default page if the previous page is not available
router.push({ name: 'Dashboard' })
router.push(redirectUrl)
}
return user
} catch (error) {
+20
View File
@@ -0,0 +1,20 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
export const useContactsStore = defineStore('contacts', () => {
const currentCategory = ref(null)
function setCurrentCategory(category) {
currentCategory.value = category
}
async function $reset() {
currentCategory.value = null
}
return {
currentCategory,
setCurrentCategory,
$reset,
}
})
+5 -1
View File
@@ -14,5 +14,9 @@ export const useGlobalStore = defineStore('global', () => {
return notifications.value.find((item) => item.id === id)
}
return { notifications, fetchNotifications, getNotificationById }
async function $reset() {
notifications.value = []
}
return { notifications, fetchNotifications, getNotificationById, $reset }
})
+2
View File
@@ -3,11 +3,13 @@ import { createPinia } from 'pinia'
export * from './global.store'
export * from './auth.store'
export * from './bus.store'
export * from './layout.store'
export * from './domains.store'
export * from './accounts.store'
export * from './aliases.store'
export * from './identities.store'
export * from './parameters.store'
export * from './providers.store'
export * from './contacts.store'
export default createPinia()
+20
View File
@@ -0,0 +1,20 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
export const useLayoutStore = defineStore('layout', () => {
const leftMenuItems = ref([])
function setLeftMenuItems(items) {
leftMenuItems.value = items
}
async function $reset() {
leftMenuItems.value = []
}
return {
leftMenuItems,
setLeftMenuItems,
$reset,
}
})
@@ -0,0 +1,89 @@
<template>
<div class="text-h5 ml-4">
{{ $gettext('Contacts') }}
</div>
<ContactList @edit-category="openCategoryForm" />
<v-dialog v-model="showCategoryForm" persistent max-width="800px">
<CategoryForm
:category="contactsStore.currentCategory"
@close="closeForm"
@added="fetchCategories"
@updated="fetchCategories"
/>
</v-dialog>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useGettext } from 'vue3-gettext'
import { useContactsStore, useLayoutStore } from '@/stores'
import contactsApi from '@/api/contacts'
import ContactList from '@/components/contacts/ContactList.vue'
import CategoryForm from '@/components/contacts/CategoryForm.vue'
const router = useRouter()
const { $gettext } = useGettext()
const contactsStore = useContactsStore()
const layoutStore = useLayoutStore()
const categories = ref([])
const showCategoryForm = ref(false)
const leftMenuItems = computed(() => {
const result = [
{
icon: 'mdi-contacts',
text: $gettext('Contacts'),
to: { name: 'ContactList' },
},
{
text: $gettext('Categories'),
subheader: true,
},
]
for (const category of categories.value) {
result.push({
icon: 'mdi-label',
text: category.name,
action: () => filterByCategory(category),
})
}
result.push({
icon: 'mdi-plus',
text: $gettext('Add a category'),
action: openCategoryForm,
})
return result
})
function openCategoryForm() {
showCategoryForm.value = true
}
function closeForm() {
showCategoryForm.value = false
}
function filterByCategory(category) {
contactsStore.setCurrentCategory(category)
router.push({
name: 'ContactList',
params: {
category: category.name,
},
})
}
function fetchCategories() {
contactsApi.getCategories().then((resp) => {
categories.value = resp.data
})
}
fetchCategories()
watch(leftMenuItems, (value) => {
layoutStore.setLeftMenuItems(value)
})
</script>
@@ -2,10 +2,14 @@
import { onMounted } from 'vue'
import { useAuthStore } from '@/stores'
const props = defineProps({
redirectUrl: String,
})
const authStore = useAuthStore()
onMounted(() => {
authStore.completeLogin()
authStore.completeLogin(props.redirectUrl)
})
</script>
+5 -15
View File
@@ -6,7 +6,6 @@ import basicSsl from '@vitejs/plugin-basic-ssl'
// Utilities
import { defineConfig } from 'vite'
import { federation } from '@module-federation/vite'
import { fileURLToPath, URL } from 'node:url'
// https://vitejs.dev/config/
@@ -33,20 +32,6 @@ export default defineConfig({
],
},
}),
federation({
name: 'coretools',
filename: 'remoteEntry.js',
exposes: {
'./api-repository': './src/api/repository.js',
'./auth-store': './src/stores/auth.store.js',
'./auth-callback': './src/views/login/LoginCallbackView.vue',
'./navbar': './src/components/shared/NavBar.vue',
'./top-menu': './src/components/shared/TopMenu.vue',
'./menu-items': './src/components/tools/MenuItems.vue',
'./form-rules': './src/plugins/rules.js',
},
shared: ['vue', 'pinia', 'vue-router', 'vue3-gettext'],
}),
],
base: process.env.NODE_ENV === 'production' ? '/new-admin/' : '/',
define: { 'process.env': {}, __VUE_OPTIONS_API__: false },
@@ -68,6 +53,11 @@ export default defineConfig({
},
},
https: true,
cors: {
origin: '*',
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
allowedHeaders: ['X-Requested-With', 'content-type', 'Authorization'],
},
},
build: {
emptyOutDir: true,
+241 -503
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
default_app_config = "modoboa.contacts.apps.ModoboaContactsConfig"
+8
View File
@@ -0,0 +1,8 @@
from django.apps import AppConfig
class ModoboaContactsConfig(AppConfig):
name = "modoboa.contacts"
def ready(self):
from . import handlers
+25
View File
@@ -0,0 +1,25 @@
"""Contacts constants."""
from django.utils.translation import gettext_lazy
EMAIL_TYPES = (
("home", gettext_lazy("Home")),
("work", gettext_lazy("Work")),
("other", gettext_lazy("Other")),
)
PHONE_TYPES = (
("home", gettext_lazy("Home")),
("work", gettext_lazy("Work")),
("other", gettext_lazy("Other")),
("main", gettext_lazy("Main")),
("cell", gettext_lazy("Cellular")),
("fax", gettext_lazy("Fax")),
("pager", gettext_lazy("Pager")),
)
CDAV_TO_MODEL_FIELDS_MAP = {
"org": "company",
"title": "position",
"note": "note",
}
+80
View File
@@ -0,0 +1,80 @@
"""Contacts factories."""
import factory
from modoboa.admin import factories as admin_factories
from . import models
class AddressBookFactory(factory.django.DjangoModelFactory):
"""Address book factory."""
class Meta:
model = models.AddressBook
user = factory.SubFactory(admin_factories.UserFactory)
name = "Contacts"
_path = "contacts"
class CategoryFactory(factory.django.DjangoModelFactory):
"""Category factory."""
class Meta:
model = models.Category
class EmailAddressFactory(factory.django.DjangoModelFactory):
"""Email address factory."""
class Meta:
model = models.EmailAddress
type = "home"
class PhoneNumberFactory(factory.django.DjangoModelFactory):
"""Phone number factory."""
class Meta:
model = models.PhoneNumber
type = "home"
class ContactFactory(factory.django.DjangoModelFactory):
"""Contact factory."""
class Meta:
model = models.Contact
first_name = "Homer"
last_name = "Simpson"
display_name = factory.LazyAttribute(
lambda c: "{}{}".format(c.first_name, c.last_name)
)
@factory.post_generation
def categories(self, create, extracted, **dummy_kwargs):
"""Add categories to contact."""
if not create or not extracted:
return
for item in extracted:
self.categories.add(item)
@factory.post_generation
def emails(self, create, extracted, **dummy_kwargs):
"""Add emails to contact."""
if not create or not extracted:
return
for item in extracted:
EmailAddressFactory(contact=self, address=item)
@factory.post_generation
def phone_numbers(self, create, extracted, **dummy_kwargs):
"""Add phone numbers to contact."""
if not create or not extracted:
return
for item in extracted:
PhoneNumberFactory(contact=self, number=item)
+59
View File
@@ -0,0 +1,59 @@
"""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"])
+65
View File
@@ -0,0 +1,65 @@
"""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."""
if not created:
return
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"))
+35
View File
@@ -0,0 +1,35 @@
import csv
from modoboa_contacts.importer.backends.outlook import OutlookBackend
BACKENDS = [
OutlookBackend,
]
def get_import_backend(fp, delimiter: str = ";", name: str = "auto"):
reader = csv.DictReader(fp, delimiter=delimiter, skipinitialspace=True)
columns = reader.fieldnames
rows = reader
for backend in BACKENDS:
if name == "auto":
if backend.detect_from_columns(columns):
return backend, rows
elif name == backend.name:
return backend, rows
raise RuntimeError("Failed to detect backend to use")
def import_csv_file(
addressbook,
backend_name: str,
csv_filename: str,
delimiter: str,
carddav_password: str = None,
):
with open(csv_filename) as fp:
backend, rows = get_import_backend(fp, delimiter, backend_name)
backend(addressbook).proceed(rows, carddav_password)
@@ -0,0 +1,58 @@
from modoboa_contacts import models, tasks
class ImporterBackend:
"""Base class of all importer backends."""
name: str = None
field_names: dict = {}
def __init__(self, addressbook: models.AddressBook):
self.addressbook = addressbook
@classmethod
def detect_from_columns(cls, columns: list) -> bool:
raise NotImplementedError
def get_email(self, values: dict):
return None
def get_phone_number(self, values: dict):
return None
def import_contact(self, row) -> models.Contact:
contact = models.Contact(addressbook=self.addressbook)
for local_name, row_name in self.field_names.items():
method_name = f"get_{local_name}"
if hasattr(self, method_name):
value = getattr(self, method_name)(row)
else:
value = row[row_name]
setattr(contact, local_name, value)
contact.save()
if self.get_email(row):
models.EmailAddress.objects.create(
contact=contact, address=self.get_email(row), type="work"
)
if self.get_phone_number(row):
models.PhoneNumber.objects.create(
contact=contact, number=self.get_phone_number(row), type="work"
)
return contact
def proceed(self, rows: list, carddav_password: str = None):
for row in rows:
contact = self.import_contact(row)
if carddav_password:
# FIXME: refactor CDAV tasks to allow connection from
# credentials and not only request
clt = tasks.get_cdav_client(
self.addressbook,
self.addressbook.user.email,
carddav_password,
True,
)
path, etag = clt.upload_new_card(contact.uid, contact.to_vcard())
contact.etag = etag
contact.save(update_fields=["etag"])
@@ -0,0 +1,53 @@
from . import ImporterBackend
OUTLOOK_COLUMNS = [
"First Name",
"Middle Name",
"Last Name",
"Company",
"E-mail Address",
"Business Phone",
"Business Street",
"Business Street 2",
"Business City",
"Business State",
"Business Postal Code",
]
class OutlookBackend(ImporterBackend):
"""Outlook contact importer backend."""
name = "outlook"
field_names = {
"first_name": "",
"last_name": "Last Name",
"company": "Company",
"address": "",
"city": "Business City",
"zipcode": "Business Postal Code",
"state": "Business State",
}
@classmethod
def detect_from_columns(cls, columns):
return set(OUTLOOK_COLUMNS).issubset(columns)
def get_first_name(self, values: dict) -> str:
result = values["First Name"]
if values["Middle Name"]:
result += f" {values['Middle Name']}"
return result
def get_address(self, values: dict) -> str:
result = values["Business Street"]
if values["Business Street 2"]:
result += f" {values['Business Street 2']}"
return result
def get_email(self, values: dict) -> str:
return values["E-mail Address"]
def get_phone_number(self, values: dict) -> str:
return values["Business Phone"]
View File
+423
View File
@@ -0,0 +1,423 @@
# vim: set ts=4 sw=4 expandtab sts=4:
# Copyright (c) 2011-2014 Christian Geier & contributors
# Copyright (c) 2018 Antoine Nguyen <tonio@ngyn.org>
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
"""
contains the class PyCardDAv and some associated functions and definitions
"""
from collections import namedtuple
import logging
from six.moves import urllib
from caldav import elements
import lxml.etree as ET
import requests
class UploadFailed(Exception):
"""uploading the card failed"""
pass
class NoWriteSupport(Exception):
"""write support has not been enabled"""
pass
nsmap = {
"D": "DAV:",
"CARD": "urn:ietf:params:xml:ns:carddav",
}
def ns(prefix, tag=None):
name = "{%s}" % nsmap[prefix]
if tag is not None:
name = "%s%s" % (name, tag)
return name
class Mkcol(elements.base.BaseElement):
tag = ns("CARD", "mkcol")
class AddressBook(elements.base.BaseElement):
tag = ns("CARD", "addressbook")
class AddressBookQuery(elements.base.BaseElement):
tag = ns("CARD", "addressbook-query")
class AddressData(elements.base.BaseElement):
tag = ns("CARD", "address-data")
class Etag(elements.base.BaseElement):
tag = ns("D", "getetag")
class SyncToken(elements.base.BaseElement):
tag = ns("D", "sync-token")
class SyncLevel(elements.base.BaseElement):
tag = ns("D", "sync-level")
class SyncCollectionQuery(elements.base.BaseElement):
tag = ns("D", "sync-collection")
class PyCardDAV(object):
"""class for interacting with a CardDAV server
Since PyCardDAV relies heavily on Requests [1] its SSL verification is also
shared by PyCardDAV [2]. For now, only the *verify* keyword is exposed
through PyCardDAV.
[1] http://docs.python-requests.org/
[2] http://docs.python-requests.org/en/latest/user/advanced/
raises:
requests.exceptions.SSLError
requests.exceptions.ConnectionError
more requests.exceptions depending on the actual error
Exception (shame on me)
"""
def __init__(
self,
resource,
debug="",
user="",
passwd="",
verify=True,
write_support=False,
auth="basic",
):
# shutup urllib3
urllog = logging.getLogger("requests.packages.urllib3.connectionpool")
urllog.setLevel(logging.CRITICAL)
urllog = logging.getLogger("urllib3.connectionpool")
urllog.setLevel(logging.CRITICAL)
# activate pyopenssl if available
try:
import urllib3.contrib.pyopenssl
except ImportError:
pass
else:
urllib3.contrib.pyopenssl.inject_into_urllib3()
split_url = urllib.parse.urlparse(resource)
url_tuple = namedtuple("url", "resource base path")
self.url = url_tuple(
resource, split_url.scheme + "://" + split_url.netloc, split_url.path
)
self.debug = debug
self.session = requests.session()
self.write_support = write_support
self._settings = {"verify": verify}
if auth == "basic":
self._settings["auth"] = (
user,
passwd,
)
if auth == "digest":
from requests.auth import HTTPDigestAuth
self._settings["auth"] = HTTPDigestAuth(user, passwd)
self._default_headers = {"User-Agent": "pyCardDAV"}
headers = self.headers
headers["Depth"] = "1"
response = self.session.request(
"OPTIONS", self.url.resource, headers=headers, **self._settings
)
response.raise_for_status() # raises error on not 2XX HTTP status code
if "addressbook" not in response.headers.get("DAV", ""):
raise Exception("URL is not a CardDAV resource")
@property
def verify(self):
"""gets verify from settings dict"""
return self._settings["verify"]
@verify.setter
def verify(self, verify):
"""set verify"""
self._settings["verify"] = verify
@property
def headers(self):
"""returns the headers"""
return dict(self._default_headers)
def _check_write_support(self):
"""checks if user really wants his data destroyed"""
if not self.write_support:
raise NoWriteSupport
def create_abook(self):
"""Create a new address book."""
self._check_write_support()
data = Mkcol() + [
elements.dav.Set()
+ [
elements.dav.Prop()
+ [
elements.dav.ResourceType()
+ elements.dav.Collection()
+ AddressBook()
]
]
]
body = ET.tostring(data.xmlelement(), encoding="utf-8", xml_declaration=True)
response = self.session.request(
"MKCOL",
self.url.resource,
data=body,
headers=self.headers,
**self._settings,
)
response.raise_for_status()
def get_abook(self):
"""does the propfind and processes what it returns
:rtype: list of hrefs to vcards
"""
xml = self._get_xml_props()
abook = self._process_xml_props(xml)
return abook
def get_vcard(self, href):
"""
pulls vcard from server
:returns: vcard
:rtype: string
"""
response = self.session.get(
self.url.base + href, headers=self.headers, **self._settings
)
response.raise_for_status()
return response.content
def get_sync_token(self):
"""Retrieve the current sync token."""
headers = self.headers
headers["Depth"] = "0"
data = elements.dav.Propfind() + [elements.dav.Prop() + SyncToken()]
body = ET.tostring(data.xmlelement(), encoding="utf-8", xml_declaration=True)
response = self.session.request(
"PROPFIND", self.url.resource, data=body, headers=headers, **self._settings
)
response.raise_for_status()
abook = self._process_xml_props(response.content)
return abook["sync-token"]
def sync_vcards(self, sync_token=None):
"""Synchronize vcards using webdav sync."""
data = (
SyncCollectionQuery()
+ [elements.dav.Prop() + Etag()]
+ SyncToken(value=sync_token)
+ SyncLevel(value="1")
)
body = ET.tostring(data.xmlelement(), encoding="utf-8", xml_declaration=True)
response = self.session.request(
"REPORT",
self.url.resource,
data=body,
headers=self.headers,
**self._settings,
)
response.raise_for_status()
return self.__process_report_result(response.content)
def update_vcard(self, card, uid, etag):
"""
pushes changed vcard to the server
card: vcard as unicode string
etag: str or None, if this is set to a string, card is only updated if
remote etag matches. If etag = None the update is forced anyway
"""
# TODO what happens if etag does not match?
self._check_write_support()
remotepath = str(self.url.resource + "/" + uid)
headers = self.headers
headers["content-type"] = "text/vcard"
if etag is not None:
headers["If-Match"] = etag
response = self.session.put(
remotepath, data=card, headers=headers, **self._settings
)
if not response.ok:
response.raise_for_status()
# Now retrieve the new etag
data = AddressBookQuery() + [elements.dav.Prop() + Etag()]
body = ET.tostring(data.xmlelement(), encoding="utf-8", xml_declaration=True)
response = self.session.request(
"REPORT", remotepath, data=body, headers=self.headers, **self._settings
)
response.raise_for_status()
return self.__process_report_result(response.content)
def delete_vcard(self, uid, etag):
"""deletes vcard from server
deletes the resource at href if etag matches,
if etag=None delete anyway
:param uid: UID of card to be deleted
:type uid: str()
:param etag: etag of that card, if None card is always deleted
:type href: str()
:returns: nothing
"""
# TODO: what happens if etag does not match, url does not exist etc ?
self._check_write_support()
remotepath = str(self.url.resource + "/" + uid)
headers = self.headers
headers["content-type"] = "text/vcard"
if etag is not None:
headers["If-Match"] = etag
response = self.session.delete(remotepath, headers=headers, **self._settings)
response.raise_for_status()
def upload_new_card(self, uid, card):
"""
upload new card to the server
:param card: vcard to be uploaded
:type card: unicode
:rtype: tuple of string (path of the vcard on the server) and etag of
new card (string or None)
"""
self._check_write_support()
card = card.encode("utf-8")
remotepath = str(self.url.resource + "/" + uid)
headers = self.headers
headers["content-type"] = "text/vcard" # TODO perhaps this should
# be set to the value this carddav server uses itself
headers["If-None-Match"] = "*"
response = requests.put(
remotepath, data=card, headers=headers, **self._settings
)
if response.ok:
parsed_url = urllib.parse.urlparse(remotepath)
etag = response.headers.get("etag", "")
return (parsed_url.path, etag)
response.raise_for_status()
def __process_report_result(self, xml):
"""Parse REPORT query result and return a dict."""
result = {"cards": []}
ns = "{DAV:}"
element = ET.XML(xml)
for item in element.iterchildren():
if item.tag != ns + "response":
result["token"] = item.text
continue
card = {}
for d in item.iterdescendants():
if d.tag == ns + "href":
card["href"] = d.text
elif d.tag == ns + "status":
card["status"] = d.text
elif d.tag == ns + "getetag":
card["etag"] = d.text
result["cards"].append(card)
return result
def _get_xml_props(self):
"""PROPFIND method
gets the xml file with all vcard hrefs
:rtype: str() (an xml file)
"""
headers = self.headers
headers["Depth"] = "0"
response = self.session.request(
"PROPFIND", self.url.resource, headers=headers, **self._settings
)
response.raise_for_status()
return response.content
@classmethod
def _process_xml_props(cls, xml):
"""processes the xml from PROPFIND, listing all vcard hrefs
:param xml: the xml file
:type xml: str()
:rtype: dict() key: href, value: etag
"""
namespace = "{DAV:}"
element = ET.XML(xml)
abook = dict()
for response in element.iterchildren():
if response.tag != namespace + "response":
continue
href = ""
etag = ""
insert = False
for refprop in response.iterchildren():
if refprop.tag == namespace + "href":
href = refprop.text
for prop in refprop.iterchildren():
for props in prop.iterchildren():
# different servers give different getcontenttypes:
# e.g.:
# "text/vcard"
# "text/x-vcard"
# "text/x-vcard; charset=utf-8"
# "text/directory;profile=vCard"
# "text/directory"
# "text/vcard; charset=utf-8" CalendarServer
if (
props.tag == namespace + "getcontenttype"
and props.text.split(";")[0].strip()
in ["text/vcard", "text/x-vcard"]
):
insert = True
if (
props.tag == namespace + "resourcetype"
and namespace + "collection"
in [c.tag for c in props.iterchildren()]
):
insert = False
break
if props.tag == namespace + "getetag":
etag = props.text
break
if props.tag == namespace + "sync-token":
abook["sync-token"] = props.text
if insert:
abook[href] = etag
return abook
@@ -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.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
# Translators:
# Irriep Nala Novram <allannkorh@yahoo.fr>, 2019
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2018-10-12 11:02+0200\n"
"PO-Revision-Date: 2018-10-12 09:06+0000\n"
"Last-Translator: Irriep Nala Novram <allannkorh@yahoo.fr>, 2019\n"
"Language-Team: Breton (https://www.transifex.com/tonio/teams/13749/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"
#: constants.py:6 constants.py:12
msgid "Home"
msgstr "Ti"
#: constants.py:7 constants.py:13
msgid "Work"
msgstr "Labour"
#: constants.py:8 constants.py:14
msgid "Other"
msgstr "Traoù all"
#: constants.py:15
msgid "Main"
msgstr "Pennañ"
#: constants.py:16
msgid "Cellular"
msgstr "Kelligel"
#: constants.py:17
msgid "Fax"
msgstr "Faks"
#: constants.py:18
msgid "Pager"
msgstr "Pajenner"
#: forms.py:15
msgid "Synchronization"
msgstr "Sinkroneladur"
#: forms.py:19
msgid "Synchonize address book using CardDAV"
msgstr "Sinkronelaat ho levr chomlec'hioù gant CardDAV"
#: forms.py:21
msgid ""
"Choose to synchronize or not your address book using CardDAV. You will be "
"able to access your contacts from the outside."
msgstr ""
"Dibabit sinkronelaat ho levr chomlec'hioù pe chom hep en ober gant CardDAV. "
"Posupl e vo deoc'h tizhout ho tarempredoù adalek an diavaez."
#: forms.py:28
msgid "Synchronization frequency"
msgstr "Frekañs sinkroneladur"
#: forms.py:30
msgid "Interval in seconds between 2 synchronization requests"
msgstr "Padelezh en eilennoù etre 2 reked sinkroneladur"
#: forms.py:42
msgid "Minimum allowed value is 60s"
msgstr "Talvoudegezh bihanañ aotreet a zo 60eil"
#: handlers.py:24 modo_extension.py:17 modo_extension.py:25
#: templates/modoboa_contacts/index.html:6
msgid "Contacts"
msgstr "Darempredoù"
#: handlers.py:41
msgid "Your address book must be synced"
msgstr "Ho levr chomlec'hioù a rank bezañ sinkronelaet"
#: models.py:36
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:19
msgid "Address book"
msgstr "Levr chomlec'hioù"
#: serializers.py:87
msgid "Name or display name required"
msgstr "Anv pe anv da ziskouez rekiset"
@@ -0,0 +1,93 @@
# 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: 2018-10-12 11:02+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"
#: constants.py:6 constants.py:12
msgid "Home"
msgstr ""
#: constants.py:7 constants.py:13
msgid "Work"
msgstr ""
#: constants.py:8 constants.py:14
msgid "Other"
msgstr ""
#: constants.py:15
msgid "Main"
msgstr ""
#: constants.py:16
msgid "Cellular"
msgstr ""
#: constants.py:17
msgid "Fax"
msgstr ""
#: constants.py:18
msgid "Pager"
msgstr ""
#: forms.py:15
msgid "Synchronization"
msgstr ""
#: forms.py:19
msgid "Synchonize address book using CardDAV"
msgstr ""
#: forms.py:21
msgid ""
"Choose to synchronize or not your address book using CardDAV. You will be "
"able to access your contacts from the outside."
msgstr ""
#: forms.py:28
msgid "Synchronization frequency"
msgstr ""
#: forms.py:30
msgid "Interval in seconds between 2 synchronization requests"
msgstr ""
#: forms.py:42
msgid "Minimum allowed value is 60s"
msgstr ""
#: handlers.py:24 modo_extension.py:17 modo_extension.py:25
#: templates/modoboa_contacts/index.html:6
msgid "Contacts"
msgstr ""
#: handlers.py:41
msgid "Your address book must be synced"
msgstr ""
#: models.py:36
msgid "Server location is not set, please fix it."
msgstr ""
#: modo_extension.py:19
msgid "Address book"
msgstr ""
#: serializers.py:87
msgid "Name or display name required"
msgstr ""
@@ -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.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
# Translators:
# Luis Fajardo López <luis@fajardolopez.com>, 2018
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2018-10-12 11:02+0200\n"
"PO-Revision-Date: 2018-10-12 09:06+0000\n"
"Last-Translator: Luis Fajardo López <luis@fajardolopez.com>, 2018\n"
"Language-Team: Spanish (https://www.transifex.com/tonio/teams/13749/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"
#: constants.py:6 constants.py:12
msgid "Home"
msgstr "Casa"
#: constants.py:7 constants.py:13
msgid "Work"
msgstr "Trabajo"
#: constants.py:8 constants.py:14
msgid "Other"
msgstr "Otro"
#: constants.py:15
msgid "Main"
msgstr "Principal"
#: constants.py:16
msgid "Cellular"
msgstr "Móvil"
#: constants.py:17
msgid "Fax"
msgstr "Fax"
#: constants.py:18
msgid "Pager"
msgstr "Localizador"
#: forms.py:15
msgid "Synchronization"
msgstr "Sincronización"
#: forms.py:19
msgid "Synchonize address book using CardDAV"
msgstr "Sincronizar libro de direcciones usando CardDAV"
#: forms.py:21
msgid ""
"Choose to synchronize or not your address book using CardDAV. You will be "
"able to access your contacts from the outside."
msgstr ""
"Seleccione para sincronizar o no su libro de direcciones usando CardDAV. "
"Podrá acceder a sus contactos remotamente."
#: forms.py:28
msgid "Synchronization frequency"
msgstr "Frecuencia de sincronización"
#: forms.py:30
msgid "Interval in seconds between 2 synchronization requests"
msgstr "Intervalo en segundos entre 2 peticiones de sincronización"
#: forms.py:42
msgid "Minimum allowed value is 60s"
msgstr "El valor mínimo autorizado es 60s"
#: handlers.py:24 modo_extension.py:17 modo_extension.py:25
#: templates/modoboa_contacts/index.html:6
msgid "Contacts"
msgstr "Contactos"
#: handlers.py:41
msgid "Your address book must be synced"
msgstr "Su libro de direcciones tiene que ser sincronizado"
#: models.py:36
msgid "Server location is not set, please fix it."
msgstr "No se ha configurado la dirección del servidor, por favor corríjalo."
#: modo_extension.py:19
msgid "Address book"
msgstr "Libro de direcciones"
#: serializers.py:87
msgid "Name or display name required"
msgstr "Se requiere el nombre o el nombre a mostrar"
@@ -0,0 +1,95 @@
# 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.
#
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2018-10-12 11:02+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"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
#: constants.py:6 constants.py:12
msgid "Home"
msgstr "Personnel"
#: constants.py:7 constants.py:13
msgid "Work"
msgstr "Professionnel"
#: constants.py:8 constants.py:14
msgid "Other"
msgstr "Autre"
#: constants.py:15
msgid "Main"
msgstr "Principal"
#: constants.py:16
msgid "Cellular"
msgstr "Mobile"
#: constants.py:17
msgid "Fax"
msgstr "Fax"
#: constants.py:18
msgid "Pager"
msgstr "Pager"
#: forms.py:15
msgid "Synchronization"
msgstr "Synchronisation"
#: forms.py:19
msgid "Synchonize address book using CardDAV"
msgstr "Synchroniser le carnet d'adresse via CardDAV"
#: forms.py:21
msgid ""
"Choose to synchronize or not your address book using CardDAV. You will be "
"able to access your contacts from the outside."
msgstr ""
"Choisissez de synchroniser ou pas votre carnet d'adresse via CardDAV. Vous "
"pourrez accéder à vos contacts depuis l'extérieur."
#: forms.py:28
msgid "Synchronization frequency"
msgstr "Fréquence de synchronisation"
#: forms.py:30
msgid "Interval in seconds between 2 synchronization requests"
msgstr "Intervalle en secondes entre 2 requêtes de synchronisation"
#: forms.py:42
msgid "Minimum allowed value is 60s"
msgstr "La valeur minimale autorisée est 60s"
#: handlers.py:24 modo_extension.py:17 modo_extension.py:25
#: templates/modoboa_contacts/index.html:6
msgid "Contacts"
msgstr "Contacts"
#: handlers.py:41
msgid "Your address book must be synced"
msgstr "Votre carnet d'adresse doit être synchronisé"
#: models.py:36
msgid "Server location is not set, please fix it."
msgstr "L'adresse du serveur n'est pas définie, merci de le faire."
#: modo_extension.py:19
msgid "Address book"
msgstr "Carnet d'adresse"
#: serializers.py:87
msgid "Name or display name required"
msgstr "Nom ou nom affiché requis"
@@ -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.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
# Translators:
# marcotrevisan <mrctrevisan@yahoo.it>, 2018
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2018-10-12 11:02+0200\n"
"PO-Revision-Date: 2018-10-12 09:06+0000\n"
"Last-Translator: marcotrevisan <mrctrevisan@yahoo.it>, 2018\n"
"Language-Team: Italian (https://www.transifex.com/tonio/teams/13749/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"
#: constants.py:6 constants.py:12
msgid "Home"
msgstr "Casa"
#: constants.py:7 constants.py:13
msgid "Work"
msgstr "Lavoro"
#: constants.py:8 constants.py:14
msgid "Other"
msgstr "Altro"
#: constants.py:15
msgid "Main"
msgstr "Principale"
#: constants.py:16
msgid "Cellular"
msgstr "Cellulare"
#: constants.py:17
msgid "Fax"
msgstr "Fax"
#: constants.py:18
msgid "Pager"
msgstr "Cercapersone"
#: forms.py:15
msgid "Synchronization"
msgstr "Sincronizzazione"
#: forms.py:19
msgid "Synchonize address book using CardDAV"
msgstr "Sincronizza la rubrica via CardDAV"
#: forms.py:21
msgid ""
"Choose to synchronize or not your address book using CardDAV. You will be "
"able to access your contacts from the outside."
msgstr ""
"Scegli se sincronizzare o no la tua rubrica via CardDAV. Ti permetterà di "
"accedere ai tuoi contatti da applicazioni esterne."
#: forms.py:28
msgid "Synchronization frequency"
msgstr "Frequenza di sincronizzazione"
#: forms.py:30
msgid "Interval in seconds between 2 synchronization requests"
msgstr "Intervallo in secondi tra 2 richieste di sincronizzazione"
#: forms.py:42
msgid "Minimum allowed value is 60s"
msgstr "Il minimo valore permesso è 60s"
#: handlers.py:24 modo_extension.py:17 modo_extension.py:25
#: templates/modoboa_contacts/index.html:6
msgid "Contacts"
msgstr "Contatti"
#: handlers.py:41
msgid "Your address book must be synced"
msgstr "La tua rubrica deve essere sincronizzata"
#: models.py:36
msgid "Server location is not set, please fix it."
msgstr "Posizione del server non impostata, si prega di correggere."
#: modo_extension.py:19
msgid "Address book"
msgstr "Rubrica"
#: serializers.py:87
msgid "Name or display name required"
msgstr "Nome, o nome visualizzato, richiesto"
@@ -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: 2018-10-12 11:02+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: Wojtek Gajda <vac@milowice.net>, 2018\n"
"Language-Team: Polish (Poland) (https://www.transifex.com/tonio/teams/13749/pl_PL/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: pl_PL\n"
"Plural-Forms: nplurals=4; plural=(n==1 ? 0 : (n%10>=2 && n%10<=4) && (n%100<12 || n%100>14) ? 1 : n!=1 && (n%10>=0 && n%10<=1) || (n%10>=5 && n%10<=9) || (n%100>=12 && n%100<=14) ? 2 : 3);\n"
#: constants.py:6 constants.py:12
msgid "Home"
msgstr "Dom"
#: constants.py:7 constants.py:13
msgid "Work"
msgstr "Praca"
#: constants.py:8 constants.py:14
msgid "Other"
msgstr "Inny"
#: constants.py:15
msgid "Main"
msgstr "Główny"
#: constants.py:16
msgid "Cellular"
msgstr "Komórkowy"
#: constants.py:17
msgid "Fax"
msgstr "Fax"
#: constants.py:18
msgid "Pager"
msgstr "Pager"
#: forms.py:15
msgid "Synchronization"
msgstr "Synchronizacja"
#: forms.py:19
msgid "Synchonize address book using CardDAV"
msgstr "Synchronizuj książkę adresową używając CardDAV"
#: forms.py:21
msgid ""
"Choose to synchronize or not your address book using CardDAV. You will be "
"able to access your contacts from the outside."
msgstr ""
"Wybierz czy synchronizować lub nie Twoją książkę adresową używając CardDAV. "
"Zyskasz możliwość dostępu do Twoich kontaktów z zewnątrz."
#: forms.py:28
msgid "Synchronization frequency"
msgstr "Częstotliwość synchronizacji"
#: forms.py:30
msgid "Interval in seconds between 2 synchronization requests"
msgstr "Odstęp pomiędzy synchronizacjami, w sekundach"
#: forms.py:42
msgid "Minimum allowed value is 60s"
msgstr "Dopuszczalna minimalna wartość to 60"
#: handlers.py:24 modo_extension.py:17 modo_extension.py:25
#: templates/modoboa_contacts/index.html:6
msgid "Contacts"
msgstr "Kontakty"
#: handlers.py:41
msgid "Your address book must be synced"
msgstr "Twoja książka adresowa musi być zsynchronizowana"
#: models.py:36
msgid "Server location is not set, please fix it."
msgstr "Lokalizacja serwera nie jest ustawiona, należy to poprawić."
#: modo_extension.py:19
msgid "Address book"
msgstr "Książka adresowa"
#: serializers.py:87
msgid "Name or display name required"
msgstr "Nazwa albo wyświetlana nazwa jest wymagana"
@@ -0,0 +1,59 @@
"""Management command to import contacts from a CSV file."""
from django.core.management.base import BaseCommand, CommandError
from modoboa_contacts import models
from modoboa_contacts.importer import import_csv_file
class Command(BaseCommand):
"""Management command to import contacts."""
help = "Import contacts from a CSV file"
def add_arguments(self, parser):
parser.add_argument(
"--delimiter", type=str, default=",", help="Delimiter used in CSV file"
)
parser.add_argument(
"--carddav-password",
type=str,
default=None,
help=(
"Password associated to email. If provided, imported "
"contacts will be synced to CardDAV servert too"
),
)
parser.add_argument(
"--backend",
type=str,
default="auto",
help=(
"Specify import backend to use. Defaults to 'auto', "
"meaning the script will try to guess which one to use"
),
)
parser.add_argument(
"email", type=str, help="Email address to import contacts for"
)
parser.add_argument("file", type=str, help="Path of the CSV file to import")
def handle(self, *args, **options):
addressbook = models.AddressBook.objects.filter(
user__email=options["email"]
).first()
if not addressbook:
raise CommandError(
"Address Book for email '%s' not found" % options["email"]
)
try:
import_csv_file(
addressbook,
options["backend"],
options["file"],
options["delimiter"],
options.get("carddav_password"),
)
except RuntimeError as err:
raise CommandError(err)
self.stdout.write(self.style.SUCCESS("File was imported successfuly"))
+152
View File
@@ -0,0 +1,152 @@
# -*- coding: utf-8 -*-
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="Category",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=50)),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
],
options={"db_table": "modoboa_contacts_category"},
),
migrations.CreateModel(
name="Contact",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("first_name", models.CharField(blank=True, max_length=30)),
("last_name", models.CharField(blank=True, max_length=30)),
("display_name", models.CharField(blank=True, max_length=60)),
("birth_date", models.DateField(null=True)),
("company", models.CharField(blank=True, max_length=100)),
("position", models.CharField(blank=True, max_length=200)),
("address", models.CharField(blank=True, max_length=200)),
("zipcode", models.CharField(blank=True, max_length=15)),
("city", models.CharField(blank=True, max_length=100)),
("country", models.CharField(blank=True, max_length=100)),
("state", models.CharField(blank=True, max_length=100)),
("note", models.TextField(blank=True)),
(
"categories",
models.ManyToManyField(blank=True, to="contacts.Category"),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
],
options={"db_table": "modoboa_contacts_contact"},
),
migrations.CreateModel(
name="EmailAddress",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("address", models.EmailField(max_length=254)),
(
"type",
models.CharField(
choices=[
(b"home", "Home"),
(b"work", "Work"),
(b"other", "Other"),
],
max_length=20,
),
),
(
"contact",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="emails",
to="contacts.Contact",
),
),
],
options={"db_table": "modoboa_contacts_emailaddress"},
),
migrations.CreateModel(
name="PhoneNumber",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("number", models.CharField(max_length=40)),
(
"type",
models.CharField(
choices=[
(b"home", "Home"),
(b"work", "Work"),
(b"other", "Other"),
(b"main", "Main"),
(b"cellular", "Cellular"),
(b"fax", "Fax"),
(b"pager", "Pager"),
],
max_length=20,
),
),
(
"contact",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="phone_numbers",
to="contacts.Contact",
),
),
],
options={"db_table": "modoboa_contacts_phonenumber"},
),
]
@@ -0,0 +1,37 @@
# -*- coding: utf-8 -*-
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("contacts", "0001_initial"),
]
operations = [
migrations.AlterField(
model_name="emailaddress",
name="type",
field=models.CharField(
choices=[("home", "Home"), ("work", "Work"), ("other", "Other")],
max_length=20,
),
),
migrations.AlterField(
model_name="phonenumber",
name="type",
field=models.CharField(
choices=[
("home", "Home"),
("work", "Work"),
("other", "Other"),
("main", "Main"),
("cellular", "Cellular"),
("fax", "Fax"),
("pager", "Pager"),
],
max_length=20,
),
),
]
@@ -0,0 +1,63 @@
# -*- coding: utf-8 -*-
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("contacts", "0002_auto_20180124_2311"),
]
operations = [
migrations.CreateModel(
name="AddressBook",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=50)),
("sync_token", models.TextField(blank=True)),
("last_sync", models.DateTimeField(null=True)),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
("_path", models.TextField()),
],
options={"db_table": "modoboa_contacts_addressbook"},
),
migrations.AddField(
model_name="contact",
name="uid",
field=models.CharField(
max_length=100, null=True, unique=True, db_index=True
),
),
migrations.AddField(
model_name="contact",
name="etag",
field=models.TextField(blank=True),
),
migrations.AddField(
model_name="contact",
name="addressbook",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="contacts.AddressBook",
),
),
]
@@ -0,0 +1,30 @@
# -*- coding: utf-8 -*-
import uuid
from django.db import migrations
def create_address_books(apps, schema_editor):
"""Create address books for every account."""
User = apps.get_model("core", "User")
AddressBook = apps.get_model("contacts", "AddressBook")
for user in User.objects.filter(mailbox__isnull=False):
abook = AddressBook.objects.create(name="Contacts", user=user, _path="contacts")
for contact in user.contact_set.all():
contact.addressbook = abook
contact.uid = "{}.vcf".format(uuid.uuid4())
contact.save()
def backward(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
("contacts", "0003_auto_20181005_1415"),
]
operations = [migrations.RunPython(create_address_books, backward)]
@@ -0,0 +1,25 @@
# -*- coding: utf-8 -*-
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("contacts", "0004_auto_20181005_1415"),
]
operations = [
migrations.RemoveField(
model_name="contact",
name="user",
),
migrations.AlterField(
model_name="contact",
name="addressbook",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="contacts.AddressBook"
),
),
]
@@ -0,0 +1,29 @@
# Generated by Django 3.2.16 on 2023-02-11 16:06
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("contacts", "0005_auto_20181005_1445"),
]
operations = [
migrations.AlterField(
model_name="phonenumber",
name="type",
field=models.CharField(
choices=[
("home", "Home"),
("work", "Work"),
("other", "Other"),
("main", "Main"),
("cell", "Cellular"),
("fax", "Fax"),
("pager", "Pager"),
],
max_length=20,
),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 4.2.5 on 2023-12-06 15:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("contacts", "0006_alter_phonenumber_type"),
]
operations = [
migrations.AlterField(
model_name="contact",
name="address",
field=models.TextField(blank=True),
),
]
+117
View File
@@ -0,0 +1,117 @@
"""Mocks to test the contacts plugin."""
import httmock
@httmock.urlmatch(method="OPTIONS")
def options_mock(url, request):
"""Simulate options request."""
return {"status_code": 200, "headers": {"DAV": "addressbook"}}
@httmock.urlmatch(method="MKCOL")
def mkcol_mock(url, request):
"""Simulate collection creation."""
return {"status_code": 201}
@httmock.urlmatch(method="DELETE")
def delete_mock(url, request):
"""Simulate a DELETE request."""
return {"status_code": 204}
@httmock.urlmatch(method="PUT")
def put_mock(url, request):
"""Simulate a PUT request."""
return {"status_code": 200, "headers": {"etag": '"12345"'}}
@httmock.urlmatch(method="PROPFIND")
def propfind_mock(url, request):
"""Simulate a PROPFIND request."""
content = b"""
<d:multistatus xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/">
<d:response>
<d:href>/radicale/user@test.com/contacts/</d:href>
<d:propstat>
<d:prop>
<d:sync-token>http://modoboa.org/ns/sync-token/3145</d:sync-token>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
</d:multistatus>
"""
return {"status_code": 200, "content": content}
@httmock.urlmatch(method="REPORT")
def report_mock(url, request):
"""Simulate a REPORT request."""
if url.path.endswith(".vcf"):
content = b"""
<d:multistatus xmlns:d="DAV:">
<d:response>
<d:href>/radicale/user@test.com/contacts/newcard.vcf</d:href>
<d:propstat>
<d:prop>
<d:getetag>"33441-34321"</d:getetag>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
</d:multistatus>
"""
else:
content = b"""
<d:multistatus xmlns:d="DAV:">
<d:response>
<d:href>/radicale/user@test.com/contacts/newcard.vcf</d:href>
<d:propstat>
<d:prop>
<d:getetag>"33441-34321"</d:getetag>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
<d:response>
<d:href>/radicale/user@test.com/contacts/updatedcard.vcf</d:href>
<d:propstat>
<d:prop>
<d:getetag>"33541-34696"</d:getetag>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
<d:response>
<d:href>/radicale/user@test.com/contacts/deletedcard.vcf</d:href>
<d:status>HTTP/1.1 404 Not Found</d:status>
</d:response>
<d:sync-token>http://modoboa.org/ns/sync/5001</d:sync-token>
</d:multistatus>
"""
return {"status_code": 200, "content": content}
@httmock.urlmatch(method="GET")
def get_mock(url, request):
"""Simulate a GET request."""
uid = url.path.split("/")[-1]
content = """
BEGIN:VCARD
VERSION:3.0
UID:{}
N:Gump;Forrest
FN:Forrest Gump
ORG:Bubba Gump Shrimp Co.
TITLE:Shrimp Man
TEL;TYPE=WORK;VOICE:(111) 555-1212
TEL;TYPE=HOME;VOICE:(404) 555-1212
ADR;TYPE=HOME:;;42 Plantation St.;Baytown;LA;30314;United States of America
EMAIL;TYPE=PREF,INTERNET:forrestgump@example.com
END:VCARD
""".format(
uid
)
return {"status_code": 200, "content": content}
+196
View File
@@ -0,0 +1,196 @@
"""Contacts models."""
import os
import uuid
from dateutil.parser import parse
import vobject
from django.db import models
from django.utils.translation import gettext as _
from modoboa.lib import exceptions as lib_exceptions
from modoboa.parameters import tools as param_tools
from . import constants
from .. import __version__
class AddressBook(models.Model):
"""An address book."""
name = models.CharField(max_length=50)
sync_token = models.TextField(blank=True)
last_sync = models.DateTimeField(null=True)
user = models.ForeignKey("core.User", on_delete=models.CASCADE)
_path = models.TextField()
class Meta:
db_table = "modoboa_contacts_addressbook"
@property
def url(self):
server_location = param_tools.get_global_parameter(
"server_location", app="modoboa_radicale"
)
if not server_location:
raise lib_exceptions.InternalError(
_("Server location is not set, please fix it.")
)
return os.path.join(server_location, self.user.username, self._path)
class Category(models.Model):
"""A category for contacts."""
user = models.ForeignKey("core.User", on_delete=models.CASCADE)
name = models.CharField(max_length=50)
class Meta:
db_table = "modoboa_contacts_category"
class Contact(models.Model):
"""A contact."""
addressbook = models.ForeignKey(AddressBook, on_delete=models.CASCADE)
uid = models.CharField(max_length=100, unique=True, null=True, db_index=True)
# Can't define an index on etag field because of MySQL...
etag = models.TextField(blank=True)
first_name = models.CharField(max_length=30, blank=True)
last_name = models.CharField(max_length=30, blank=True)
display_name = models.CharField(max_length=60, blank=True)
birth_date = models.DateField(null=True)
company = models.CharField(max_length=100, blank=True)
position = models.CharField(max_length=200, blank=True)
address = models.TextField(blank=True)
zipcode = models.CharField(max_length=15, blank=True)
city = models.CharField(max_length=100, blank=True)
country = models.CharField(max_length=100, blank=True)
state = models.CharField(max_length=100, blank=True)
note = models.TextField(blank=True)
categories = models.ManyToManyField(Category, blank=True)
class Meta:
db_table = "modoboa_contacts_contact"
def __init__(self, *args, **kwargs):
"""Set uid for new object."""
super().__init__(*args, **kwargs)
if not self.pk:
self.uid = "{}.vcf".format(uuid.uuid4())
@property
def url(self):
return "{}/{}.vcf".format(self.addressbook.url, self.uid)
def to_vcard(self):
"""Convert this contact to a vCard."""
card = vobject.vCard()
card.add("prodid").value = "-//Modoboa//Contacts plugin {}//EN".format(
__version__
)
card.add("uid").value = self.uid
card.add("n").value = vobject.vcard.Name(
family=self.last_name, given=self.first_name
)
card.add("fn").value = self.display_name
card.add("org").value = [self.company]
card.add("title").value = self.position
card.add("adr").value = vobject.vcard.Address(
street=self.address,
city=self.city,
code=self.zipcode,
country=self.country,
region=self.state,
)
if self.birth_date:
card.add("bday").value = self.birth_date.isoformat()
card.add("note").value = self.note
for email in EmailAddress.objects.filter(contact=self):
attr = card.add("email")
attr.value = email.address
attr.type_param = email.type
for phone in PhoneNumber.objects.filter(contact=self):
attr = card.add("tel")
attr.value = phone.number
attr.type_param = phone.type
return card.serialize()
def update_from_vcard(self, content):
"""Update this contact according to given vcard."""
vcard = vobject.readOne(content)
self.uid = vcard.uid.value
name = getattr(vcard, "n", None)
if name:
self.first_name = name.value.given
self.last_name = name.value.family
address = getattr(vcard, "adr", None)
if address:
self.address = address.value.street
self.zipcode = address.value.code
self.city = address.value.city
self.state = address.value.region
self.country = address.value.country
birth_date = getattr(vcard, "bday", None)
if birth_date:
self.birth_date = parse(birth_date.value)
for cfield, mfield in constants.CDAV_TO_MODEL_FIELDS_MAP.items():
value = getattr(vcard, cfield, None)
if value:
if isinstance(value.value, list):
setattr(self, mfield, value.value[0])
else:
setattr(self, mfield, value.value)
self.save()
email_list = getattr(vcard, "email_list", [])
EmailAddress.objects.filter(contact=self).delete()
to_create = []
for email in email_list:
addr = EmailAddress(contact=self, address=email.value.lower())
if hasattr(email, "type_param"):
addr.type = email.type_param.lower()
to_create.append(addr)
EmailAddress.objects.bulk_create(to_create)
PhoneNumber.objects.filter(contact=self).delete()
to_create = []
phone_list = getattr(vcard, "tel_list", [])
for tel in phone_list:
pnum = PhoneNumber(contact=self, number=tel.value.lower())
if hasattr(tel, "type_param"):
pnum.type = tel.type_param.lower()
to_create.append(pnum)
PhoneNumber.objects.bulk_create(to_create)
class EmailAddress(models.Model):
"""An email address."""
contact = models.ForeignKey(
Contact, related_name="emails", on_delete=models.CASCADE
)
address = models.EmailField()
type = models.CharField(max_length=20, choices=constants.EMAIL_TYPES)
class Meta:
db_table = "modoboa_contacts_emailaddress"
class PhoneNumber(models.Model):
"""A phone number."""
contact = models.ForeignKey(
Contact, related_name="phone_numbers", on_delete=models.CASCADE
)
number = models.CharField(max_length=40)
type = models.CharField(max_length=20, choices=constants.PHONE_TYPES)
class Meta:
db_table = "modoboa_contacts_phonenumber"
def __str__(self):
return "{}: {}".format(self.type, self.number)
+220
View File
@@ -0,0 +1,220 @@
"""Contacts serializers."""
from django.utils.translation import gettext as _
from rest_framework import serializers
from . import models
from . import tasks
class AddressBookSerializer(serializers.ModelSerializer):
"""Address book serializer."""
class Meta:
model = models.AddressBook
fields = ("pk", "name", "url")
class EmailAddressSerializer(serializers.ModelSerializer):
"""Email address serializer."""
class Meta:
model = models.EmailAddress
fields = ("pk", "address", "type")
class EmailAddressWithNameSerializer(serializers.ModelSerializer):
"""Email address + contact name serializer."""
display_name = serializers.SerializerMethodField()
class Meta:
model = models.EmailAddress
fields = ("pk", "address", "type", "display_name")
def get_display_name(self, obj):
"""Return display name."""
if obj.contact.display_name:
return obj.contact.display_name
return "{} {}".format(obj.contact.first_name, obj.contact.last_name)
class PhoneNumberSerializer(serializers.ModelSerializer):
"""Phone number serializer."""
class Meta:
model = models.PhoneNumber
fields = ("pk", "number", "type")
class CategorySerializer(serializers.ModelSerializer):
"""Serializer for Category."""
class Meta:
model = models.Category
fields = ("pk", "name")
def create(self, validated_data):
"""Use current user."""
user = self.context["request"].user
return models.Category.objects.create(user=user, **validated_data)
class ContactSerializer(serializers.ModelSerializer):
"""Contact serializer."""
emails = EmailAddressSerializer(many=True)
phone_numbers = PhoneNumberSerializer(many=True, required=False)
class Meta:
model = models.Contact
fields = (
"pk",
"first_name",
"last_name",
"categories",
"emails",
"phone_numbers",
"company",
"position",
"address",
"zipcode",
"city",
"country",
"state",
"note",
"birth_date",
"display_name",
)
def validate(self, data):
"""Make sure display name or first/last names are set."""
condition = (
not data.get("first_name")
and not data.get("last_name")
and not data.get("display_name")
)
if not self.partial:
if condition:
msg = _("Name or display name required")
raise serializers.ValidationError(
{"first_name": msg, "last_name": msg, "display_name": msg}
)
if not data.get("display_name"):
data["display_name"] = data.get("first_name", "")
if data["display_name"]:
data["display_name"] += " "
data["display_name"] += data.get("last_name", "")
return data
def create(self, validated_data):
"""Use current user."""
request = self.context["request"]
addressbook = request.user.addressbook_set.first()
categories = validated_data.pop("categories", [])
emails = validated_data.pop("emails")
phone_numbers = validated_data.pop("phone_numbers", [])
contact = models.Contact.objects.create(
addressbook=addressbook, **validated_data
)
to_create = []
for email in emails:
to_create.append(models.EmailAddress(contact=contact, **email))
models.EmailAddress.objects.bulk_create(to_create)
to_create = []
for phone_number in phone_numbers:
to_create.append(models.PhoneNumber(contact=contact, **phone_number))
if to_create:
models.PhoneNumber.objects.bulk_create(to_create)
if categories:
for category in categories:
contact.categories.add(category)
condition = addressbook.last_sync and addressbook.user.parameters.get_value(
"enable_carddav_sync"
)
if condition:
tasks.push_contact_to_cdav(request, contact)
return contact
def update_emails(self, instance, emails):
"""Update instance emails."""
local_addresses = []
local_objects = []
for email in instance.emails.all():
local_addresses.append(email.address)
local_objects.append(email)
to_create = []
for email in emails:
if email["address"] not in local_addresses:
to_create.append(models.EmailAddress(contact=instance, **email))
continue
index = local_addresses.index(email["address"])
local_email = local_objects[index]
condition = (
local_email.type != email["type"]
or local_email.address != email["address"]
)
if condition:
local_email.type = email["type"]
local_email.address = email["address"]
local_email.save()
local_addresses.pop(index)
local_objects.pop(index)
models.EmailAddress.objects.filter(
pk__in=[email.pk for email in local_objects]
).delete()
models.EmailAddress.objects.bulk_create(to_create)
def update_phone_numbers(self, instance, phone_numbers):
"""Update instance phone numbers."""
local_phones = []
local_objects = []
for phone in instance.phone_numbers.all():
local_phones.append(phone.number)
local_objects.append(phone)
to_create = []
for phone in phone_numbers:
if phone["number"] not in local_phones:
to_create.append(models.PhoneNumber(contact=instance, **phone))
continue
index = local_phones.index(phone["number"])
local_phone = local_objects[index]
condition = (
local_phone.type != phone["type"]
or local_phone.number != phone["number"]
)
if condition:
local_phone.type = phone["type"]
local_phone.number = phone["number"]
local_phone.save()
local_phones.pop(index)
local_objects.pop(index)
instance.phone_numbers.filter(
pk__in=[phone.pk for phone in local_objects]
).delete()
models.PhoneNumber.objects.bulk_create(to_create)
def update(self, instance, validated_data):
"""Update contact."""
emails = validated_data.pop("emails", None)
phone_numbers = validated_data.pop("phone_numbers", None)
categories = validated_data.pop("categories", [])
for key, value in validated_data.items():
setattr(instance, key, value)
instance.categories.set(categories)
instance.save()
if emails is not None:
self.update_emails(instance, emails)
if phone_numbers is not None:
self.update_phone_numbers(instance, phone_numbers)
condition = (
instance.addressbook.last_sync
and instance.addressbook.user.parameters.get_value("enable_carddav_sync")
)
if condition:
tasks.update_contact_cdav(self.context["request"], instance)
return instance
+28
View File
@@ -0,0 +1,28 @@
"""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
@@ -0,0 +1,6 @@
{
"status": "compile",
"assets": {},
"chunks": {},
"publicPath": "/sitestatic/"
}
+106
View File
@@ -0,0 +1,106 @@
"""Async tasks."""
from django.utils import timezone
from modoboa.lib import cryptutils
from .lib import carddav
from . import models
def get_cdav_client(addressbook, user: str, passwd: str, write_support=False):
"""Instantiate a new CardDAV client."""
return carddav.PyCardDAV(
addressbook.url, user=user, passwd=passwd, write_support=write_support
)
def get_cdav_client_from_request(request, addressbook, *args, **kwargs):
"""Create a connection from a Request object."""
return get_cdav_client(
addressbook,
request.user.username,
passwd=cryptutils.decrypt(request.session["password"]),
**kwargs,
)
def create_cdav_addressbook(addressbook, password):
"""Create CardDAV address book."""
clt = carddav.PyCardDAV(
addressbook.url,
user=addressbook.user.username,
passwd=password,
write_support=True,
)
clt.create_abook()
def push_addressbook_to_carddav(request, addressbook):
"""Push every addressbook item to carddav collection.
Use only once.
"""
clt = get_cdav_client_from_request(request, addressbook, write_support=True)
for contact in addressbook.contact_set.all():
href, etag = clt.upload_new_card(contact.uid, contact.to_vcard())
contact.etag = etag
contact.save(update_fields=["etag"])
addressbook.last_sync = timezone.now()
addressbook.sync_token = clt.get_sync_token()
addressbook.save(update_fields=["last_sync", "sync_token"])
def sync_addressbook_from_cdav(request, addressbook):
"""Fetch changes from CardDAV server."""
clt = get_cdav_client_from_request(request, addressbook)
changes = clt.sync_vcards(addressbook.sync_token)
if not len(changes["cards"]):
return
for card in changes["cards"]:
# UID sometimes embded .vcf extension, sometimes not...
long_uid = card["href"].split("/")[-1]
short_uid = long_uid.split(".")[0]
if "200" in card["status"]:
content = clt.get_vcard(card["href"]).decode()
contact = models.Contact.objects.filter(
uid__in=[long_uid, short_uid]
).first()
if not contact:
contact = models.Contact(addressbook=addressbook)
if contact.etag != card["etag"]:
contact.etag = card["etag"]
contact.update_from_vcard(content)
elif "404" in card["status"]:
models.Contact.objects.filter(uid__in=[long_uid, short_uid]).delete()
addressbook.last_sync = timezone.now()
addressbook.sync_token = changes["token"]
addressbook.save(update_fields=["last_sync", "sync_token"])
def push_contact_to_cdav(request, contact):
"""Upload new contact to cdav collection."""
clt = get_cdav_client_from_request(request, contact.addressbook, write_support=True)
path, etag = clt.upload_new_card(contact.uid, contact.to_vcard())
contact.etag = etag
contact.save(update_fields=["etag"])
def update_contact_cdav(request, contact):
"""Update existing contact."""
clt = get_cdav_client_from_request(request, contact.addressbook, write_support=True)
uid = contact.uid
if not uid.endswith(".vcf"):
uid += ".vcf"
result = clt.update_vcard(contact.to_vcard(), uid, contact.etag)
contact.etag = result["cards"][0]["etag"]
contact.save(update_fields=["etag"])
def delete_contact_cdav(request, contact):
"""Delete a contact."""
clt = get_cdav_client_from_request(request, contact.addressbook, write_support=True)
uid = contact.uid
if not uid.endswith(".vcf"):
uid += ".vcf"
clt.delete_vcard(uid, contact.etag)
@@ -0,0 +1,30 @@
{% extends "fluid.html" %}
{% load i18n lib_tags static %}
{% load render_bundle from webpack_loader %}
{% block pagetitle %}{% trans "Contacts" %}{% endblock %}
{% block extra_css %}
{% if not debug %}
{% render_bundle 'app' 'css' 'CONTACTS' %}
{% endif %}
{% endblock %}
{% block extra_js %}
{% if sync_enabled %}
<script>
window.userProfile = {
abookSynced: {% if abook_synced %}true{% else %}false{% endif %}
};
</script>
{% endif %}
{% render_bundle 'app' 'js' 'CONTACTS' %}
{% endblock %}
{% block container_content %}
<div id="app">
</div>
{% endblock %}
@@ -0,0 +1,2 @@
"First Name","Middle Name","Last Name","Company","E-mail Address","Business Phone","Business Street","Business Street 2","Business City","Business State","Business Postal Code"
Toto,Tata,Titi,Company,toto@titi.com,12345678,Street 1,Street 2,City,State,France
1 First Name Middle Name Last Name Company E-mail Address Business Phone Business Street Business Street 2 Business City Business State Business Postal Code
2 Toto Tata Titi Company toto@titi.com 12345678 Street 1 Street 2 City State France
@@ -0,0 +1,2 @@
"First Name","Middle Name","Last Name","Company","E-mail Address","Business Phone","Business Street","Business Street 2","City","Business State","Business Postal Code"
Toto,Tata,Titi,Company,toto@titi.com,12345678,Street 1,Street 2,City,State,France
1 First Name Middle Name Last Name Company E-mail Address Business Phone Business Street Business Street 2 City Business State Business Postal Code
2 Toto Tata Titi Company toto@titi.com 12345678 Street 1 Street 2 City State France
+399
View File
@@ -0,0 +1,399 @@
# coding: utf-8
"""Contacts backend tests."""
import os
import httmock
from django import forms
from django.core import management
from django.urls import reverse
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."""
@classmethod
def setUpTestData(cls):
"""Create test data."""
super(TestDataMixin, cls).setUpTestData()
admin_factories.populate_database()
cls.user = core_models.User.objects.get(username="user@test.com")
cls.addressbook = cls.user.addressbook_set.first()
cls.category = factories.CategoryFactory(user=cls.user, name="Family")
cls.contact = factories.ContactFactory(
addressbook=cls.addressbook,
emails=["homer@simpson.com"],
phone_numbers=["01234567889"],
)
factories.ContactFactory(
addressbook=cls.addressbook,
first_name="Marge",
emails=["marge@simpson.com"],
categories=[cls.category],
)
factories.ContactFactory(
addressbook=cls.addressbook, first_name="Bart", emails=["bart@simpson.com"]
)
def setUp(self):
"""Initiate test context."""
self.client.force_login(self.user)
self.set_global_parameter(
"server_location", "http://example.test/radicale/", app="modoboa_radicale"
)
def enable_cdav_sync(self):
"""Enable sync. for user."""
url = reverse("core:user_preferences")
with httmock.HTTMock(mocks.options_mock, mocks.mkcol_mock):
response = self.client.post(
url,
{
"modoboa_contacts-enable_carddav_sync": True,
"modoboa_contacts-sync_frequency": 300,
},
)
self.assertEqual(response.status_code, 200)
class ViewsTestCase(TestDataMixin, ModoTestCase):
"""Check views."""
def test_user_settings(self):
"""Check that remote collection creation request is sent."""
# 1. Addressbook with contacts must be synced manually
data = {"username": self.user.username, "password": "toto"}
self.client.post(reverse("core:login"), data)
self.enable_cdav_sync()
self.addressbook.refresh_from_db()
self.assertIs(self.addressbook.last_sync, None)
# 2. Addressbook with no contacts can be considered synced
user = core_models.User.objects.get(username="user@test2.com")
abook = user.addressbook_set.first()
data = {"username": user.username, "password": "toto"}
self.client.post(reverse("core:login"), data)
abook.refresh_from_db()
self.assertIs(abook.last_sync, None)
# Now enable sync.
self.enable_cdav_sync()
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."""
def test_default(self):
"""Test default endpoint."""
response = self.client.get(reverse("api:addressbook-default"))
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["name"], "Contacts")
def test_sync_to_cdav(self):
"""Test sync to CardDAV endpoint."""
data = {"username": self.user.username, "password": "toto"}
response = self.client.post(reverse("core:login"), data)
self.enable_cdav_sync()
with httmock.HTTMock(
mocks.options_mock, mocks.mkcol_mock, mocks.put_mock, mocks.propfind_mock
):
response = self.client.get(reverse("api:addressbook-sync-to-cdav"))
self.assertEqual(response.status_code, 200)
abook = self.user.addressbook_set.first()
self.assertIsNot(abook.sync_token, None)
contact = abook.contact_set.first()
self.assertIsNot(contact.etag, None)
def test_sync_from_cdav(self):
"""Test sync from CardDAV endpoint."""
data = {"username": self.user.username, "password": "toto"}
response = self.client.post(reverse("core:login"), data)
self.enable_cdav_sync()
self.user.addressbook_set.update(last_sync=timezone.now())
with httmock.HTTMock(mocks.options_mock, mocks.report_mock, mocks.get_mock):
response = self.client.get(reverse("api:addressbook-sync-from-cdav"))
self.assertEqual(response.status_code, 200)
class CategoryViewSetTestCase(TestDataMixin, ModoAPITestCase):
"""Category ViewSet tests."""
def test_get_categories(self):
"""Check category list endpoint."""
url = reverse("api:category-list")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.data), 1)
def test_create_category(self):
"""Create a new category."""
url = reverse("api:category-list")
data = {"name": "Amitiés"}
response = self.client.post(url, data, format="json")
self.assertEqual(response.status_code, 201)
def test_update_category(self):
"""Update a category."""
url = reverse("api:category-detail", args=[self.category.pk])
data = {"name": "Test"}
response = self.client.put(url, data, format="json")
self.assertEqual(response.status_code, 200)
self.category.refresh_from_db()
self.assertEqual(self.category.name, "Test")
def test_delete_category(self):
"""Try to delete a category."""
url = reverse("api:category-detail", args=[self.category.pk])
response = self.client.delete(url)
self.assertEqual(response.status_code, 204)
with self.assertRaises(models.Category.DoesNotExist):
self.category.refresh_from_db()
class ContactViewSetTestCase(TestDataMixin, ModoAPITestCase):
"""Contact ViewSet tests."""
def test_contact_list(self):
"""Check contact list endpoint."""
url = reverse("api:contact-list")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.data), 3)
response = self.client.get("{}?search=homer".format(url))
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.data), 1)
def test_filter_by_category(self):
"""Check filtering."""
url = reverse("api:contact-list")
response = self.client.get("{}?category={}".format(url, self.category.name))
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.data), 1)
def test_create_contact(self):
"""Create a new contact."""
data = {"username": self.user.username, "password": "toto"}
response = self.client.post(reverse("core:login"), data)
self.enable_cdav_sync()
self.user.addressbook_set.update(last_sync=timezone.now())
data = {
"first_name": "Magie",
"last_name": "Simpson",
"emails": [{"address": "magie@simpson.com", "type": "home"}],
"phone_numbers": [{"number": "+33123456789", "type": "home"}],
}
url = reverse("api:contact-list")
with httmock.HTTMock(mocks.options_mock, mocks.put_mock):
response = self.client.post(url, data, format="json")
self.assertEqual(response.status_code, 201)
contact = models.Contact.objects.get(pk=response.data["pk"])
self.assertEqual(contact.emails.first().address, "magie@simpson.com")
self.assertEqual(
contact.phone_numbers.first().number,
response.data["phone_numbers"][0]["number"],
)
self.assertEqual(contact.display_name, "Magie Simpson")
self.assertIsNot(contact.etag, None)
def test_create_contact_quick(self):
"""Create a contact with minimal information."""
data = {"emails": [{"address": "magie@simpson.com", "type": "home"}]}
url = reverse("api:contact-list")
response = self.client.post(url, data, format="json")
self.assertEqual(response.status_code, 400)
self.assertEqual(
response.data["display_name"][0], "Name or display name required"
)
data["display_name"] = "Magie Simpson"
response = self.client.post(url, data, format="json")
self.assertEqual(response.status_code, 201)
def test_create_contact_with_category(self):
"""Create a new contact with a category."""
data = {
"first_name": "Magie",
"last_name": "Simpson",
"emails": [{"address": "magie@simpson.com", "type": "home"}],
"categories": [self.category.pk],
}
url = reverse("api:contact-list")
response = self.client.post(url, data, format="json")
self.assertEqual(response.status_code, 201)
contact = models.Contact.objects.get(pk=response.data["pk"])
self.assertEqual(contact.categories.first(), self.category)
def test_update_contact(self):
"""Update existing contact."""
data = {"username": self.user.username, "password": "toto"}
self.client.post(reverse("core:login"), data)
self.enable_cdav_sync()
self.user.addressbook_set.update(last_sync=timezone.now())
url = reverse("api:contact-detail", args=[self.contact.pk])
email_pk = self.contact.emails.first().pk
data = {
"first_name": "Homer 1",
"last_name": "Simpson",
"emails": [
{"address": "duff@simpson.com", "type": "work"},
{"address": "homer@simpson.com", "type": "other"},
],
"phone_numbers": [
{"number": "+33123456789", "type": "home"},
{"number": "01234567889", "type": "work"},
],
"categories": [self.category.pk],
}
with httmock.HTTMock(mocks.options_mock, mocks.put_mock, mocks.report_mock):
response = self.client.put(url, data, format="json")
self.assertEqual(response.status_code, 200)
self.contact.refresh_from_db()
self.assertEqual(self.contact.first_name, "Homer 1")
self.assertEqual(self.contact.emails.count(), 2)
self.assertEqual(models.EmailAddress.objects.get(pk=email_pk).type, "other")
self.assertEqual(self.contact.phone_numbers.count(), 2)
self.assertEqual(self.contact.categories.first(), self.category)
self.assertIsNot(self.contact.etag, None)
data["emails"].pop(1)
data["phone_numbers"].pop(1)
with httmock.HTTMock(mocks.options_mock, mocks.put_mock, mocks.report_mock):
response = self.client.put(url, data, format="json")
self.assertEqual(response.status_code, 200)
self.assertEqual(self.contact.emails.count(), 1)
self.assertEqual(self.contact.phone_numbers.count(), 1)
def test_delete_contact(self):
"""Try to delete a contact."""
data = {"username": self.user.username, "password": "toto"}
response = self.client.post(reverse("core:login"), data)
self.enable_cdav_sync()
url = reverse("api:contact-detail", args=[self.contact.pk])
with httmock.HTTMock(mocks.options_mock, mocks.delete_mock):
response = self.client.delete(url)
self.assertEqual(response.status_code, 204)
with self.assertRaises(models.Contact.DoesNotExist):
self.contact.refresh_from_db()
class EmailAddressViewSetTestCase(TestDataMixin, ModoAPITestCase):
"""EmailAddressViewSet tests."""
def test_emails_list(self):
"""Check list endpoint."""
url = reverse("api:emailaddress-list")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.data), 3)
response = self.client.get("{}?search=homer".format(url))
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.data), 1)
response = self.client.get("{}?search=Marge".format(url))
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.data), 1)
response = self.client.get("{}?search=Simpson".format(url))
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.data), 3)
class ImportTestCase(TestDataMixin, ModoTestCase):
def setUp(self):
super().setUp()
self.path = os.path.join(
os.path.abspath(os.path.dirname(__file__)), "test_data/outlook_export.csv"
)
self.wrong_path = os.path.join(
os.path.abspath(os.path.dirname(__file__)), "test_data/unknown_export.csv"
)
def test_import_wrong_addressbook(self):
with self.assertRaises(management.base.CommandError) as ctx:
management.call_command("import_contacts", "error@test.com", self.path)
self.assertEqual(
str(ctx.exception), "Address Book for email 'error@test.com' not found"
)
def test_import_unknown_backend(self):
with self.assertRaises(management.base.CommandError) as ctx:
management.call_command("import_contacts", "user@test.com", self.wrong_path)
self.assertEqual(str(ctx.exception), "Failed to detect backend to use")
def test_import_from_outlook_auto(self):
management.call_command("import_contacts", "user@test.com", self.path)
address = models.EmailAddress.objects.get(address="toto@titi.com")
phone = models.PhoneNumber.objects.get(number="12345678")
self.assertEqual(address.contact.first_name, "Toto Tata")
self.assertEqual(address.contact.addressbook.user.email, "user@test.com")
self.assertEqual(address.contact.address, "Street 1 Street 2")
def test_import_from_outlook(self):
management.call_command(
"import_contacts",
"user@test.com",
self.path,
backend="outlook",
)
address = models.EmailAddress.objects.get(address="toto@titi.com")
phone = models.PhoneNumber.objects.get(number="12345678")
self.assertEqual(address.contact.first_name, "Toto Tata")
self.assertEqual(address.contact.addressbook.user.email, "user@test.com")
self.assertEqual(address.contact.address, "Street 1 Street 2")
def test_import_and_carddav_sync(self):
with httmock.HTTMock(mocks.options_mock, mocks.put_mock):
management.call_command(
"import_contacts",
"user@test.com",
self.path,
carddav_password="Toto1234",
)
address = models.EmailAddress.objects.get(address="toto@titi.com")
phone = models.PhoneNumber.objects.get(number="12345678")
self.assertEqual(address.contact.first_name, "Toto Tata")
self.assertEqual(address.contact.addressbook.user.email, "user@test.com")
self.assertEqual(address.contact.address, "Street 1 Street 2")
+14
View File
@@ -0,0 +1,14 @@
"""Contacts API urls."""
from rest_framework import routers
from modoboa.contacts import viewsets
router = routers.SimpleRouter()
router.register(r"address-books", viewsets.AddressBookViewSet, basename="addressbook")
router.register(r"categories", viewsets.CategoryViewSet, basename="category")
router.register(r"contacts", viewsets.ContactViewSet, basename="contact")
router.register(r"emails", viewsets.EmailAddressViewSet, basename="emailaddress")
urlpatterns = router.urls
+112
View File
@@ -0,0 +1,112 @@
"""Contacts viewsets."""
import django_filters.rest_framework
from rest_framework.decorators import action
from rest_framework import filters, response, viewsets
from rest_framework.permissions import IsAuthenticated
from . import models
from . import serializers
from . import tasks
class AddressBookViewSet(viewsets.GenericViewSet):
"""Address book viewset."""
permission_classes = (IsAuthenticated,)
serializer_class = serializers.AddressBookSerializer
@action(methods=["get"], detail=False)
def default(self, request):
"""Return default user address book."""
abook = request.user.addressbook_set.first()
if not abook:
return response.Response(status_code=404)
serializer = serializers.AddressBookSerializer(abook)
return response.Response(serializer.data)
@action(methods=["get"], detail=False)
def sync_to_cdav(self, request):
"""Synchronize address book with CardDAV collection."""
abook = request.user.addressbook_set.first()
if request.user.parameters.get_value("enable_carddav_sync", app="contacts"):
tasks.push_addressbook_to_carddav(request, abook)
return response.Response({})
@action(methods=["get"], detail=False)
def sync_from_cdav(self, request):
"""Synchronize from CardDAV address book."""
abook = request.user.addressbook_set.first()
if not abook.last_sync:
return response.Response()
if request.user.parameters.get_value("enable_carddav_sync", app="contacts"):
tasks.sync_addressbook_from_cdav(request, abook)
return response.Response({})
class CategoryViewSet(viewsets.ModelViewSet):
"""Category ViewSet."""
permission_classes = [IsAuthenticated]
serializer_class = serializers.CategorySerializer
def get_queryset(self):
"""Filter based on current user."""
qset = models.Category.objects.filter(user=self.request.user)
return qset.select_related("user")
class ContactFilter(django_filters.rest_framework.FilterSet):
"""Filter for Contact."""
category = django_filters.CharFilter(field_name="categories__name")
class Meta:
model = models.Contact
fields = ["categories"]
class ContactViewSet(viewsets.ModelViewSet):
"""Contact ViewSet."""
filter_backends = [
filters.SearchFilter,
django_filters.rest_framework.DjangoFilterBackend,
]
filterset_class = ContactFilter
permission_classes = [IsAuthenticated]
search_fields = ("^first_name", "^last_name", "^emails__address")
serializer_class = serializers.ContactSerializer
def get_queryset(self):
"""Filter based on current user."""
qset = models.Contact.objects.filter(addressbook__user=self.request.user)
return qset.prefetch_related("categories", "emails", "phone_numbers")
def perform_destroy(self, instance):
"""Also remove cdav contact."""
if self.request.user.parameters.get_value(
"enable_carddav_sync", app="contacts"
):
tasks.delete_contact_cdav(self.request, instance)
instance.delete()
class EmailAddressViewSet(viewsets.ReadOnlyModelViewSet):
"""EmailAddress viewset."""
filter_backends = [filters.SearchFilter]
permission_classes = [IsAuthenticated]
search_fields = (
"^address",
"^contact__display_name",
"^contact__first_name",
"^contact__last_name",
)
serializer_class = serializers.EmailAddressWithNameSerializer
def get_queryset(self):
"""Filter based on current user."""
return models.EmailAddress.objects.filter(
contact__addressbook__user=self.request.user
)
+9
View File
@@ -150,6 +150,15 @@ class AccountViewSet(core_v1_viewsets.AccountViewSet):
"url": "/admin",
}
)
apps += [
{
"name": "contacts",
"label": _("Contacts"),
"icon": "mdi-contacts",
"description": _("Address book"),
"url": "/user/contacts",
}
]
apps += exts_pool.get_available_apps()
serializer = serializers.ModoboaApplicationSerializer(apps, many=True)
return response.Response(serializer.data)
+1
View File
@@ -20,6 +20,7 @@ urlpatterns = [
path("", include("modoboa.pdfcredentials.api.v2.urls")),
path("", include("modoboa.postfix_autoreply.api.v2.urls")),
path("", include("modoboa.sievefilters.api.v2.urls")),
path("", include("modoboa.contacts.urls")),
]
urlpatterns += exts_pool.get_urls(category="api")
+1 -2
View File
@@ -102,8 +102,7 @@ MODOBOA_APPS = (
"modoboa.postfix_autoreply",
"modoboa.sievefilters",
# Modoboa extensions here.
"modoboa_user",
"modoboa_user.contacts",
"modoboa.contacts",
)
try: