mirror of
https://github.com/jwinterm/modoboa.git
synced 2026-10-21 06:18:45 +02:00
Added contacts plugin into core.
No more module federation at the moment, it does not seem very stable yet.
This commit is contained in:
+6
-6
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -7,4 +7,4 @@ RUN apk add --no-cache --update gettext \
|
||||
|
||||
WORKDIR $HOME
|
||||
|
||||
RUN yarn install
|
||||
RUN yarn install --dev
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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>
|
||||
@@ -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') {
|
||||
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
default_app_config = "modoboa.contacts.apps.ModoboaContactsConfig"
|
||||
@@ -0,0 +1,8 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ModoboaContactsConfig(AppConfig):
|
||||
name = "modoboa.contacts"
|
||||
|
||||
def ready(self):
|
||||
from . import handlers
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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"])
|
||||
@@ -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"))
|
||||
@@ -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"]
|
||||
@@ -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"))
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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/"
|
||||
}
|
||||
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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")
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -102,8 +102,7 @@ MODOBOA_APPS = (
|
||||
"modoboa.postfix_autoreply",
|
||||
"modoboa.sievefilters",
|
||||
# Modoboa extensions here.
|
||||
"modoboa_user",
|
||||
"modoboa_user.contacts",
|
||||
"modoboa.contacts",
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user