Skip to content

Commit 47ecd03

Browse files
committed
Added management command to import contacts from CSV
1 parent f6fd455 commit 47ecd03

File tree

12 files changed

+233
-7
lines changed

12 files changed

+233
-7
lines changed

frontend/src/components/ContactForm.vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@
7171
<label class="col-sm-1 control-label" for="address">
7272
<span class="fa fa-map-marker"></span></label>
7373
<div class="col-sm-11">
74-
<input v-model="contact.address" type="text" id="address" name="address" class="form-control" :placeholder="addressPlaceholder">
74+
<textarea v-model="contact.address" id="address" name="address" class="form-control" :placeholder="addressPlaceholder"></textarea>
7575
<span v-if="formErrors['address']" class="help-block">{{ formErrors['address'][0] }}</span>
7676
</div>
7777
</div>
@@ -96,7 +96,7 @@
9696
</div>
9797
</div>
9898
<div class="form-group">
99-
<label class="col-sm-1 control-label" for="address">
99+
<label class="col-sm-1 control-label" for="note">
100100
<span class="fa fa-sticky-note"></span></label>
101101
<div class="col-sm-11">
102102
<textarea v-model="contact.note" id="note" name="note" class="form-control" :placeholder="notePlaceholder"></textarea>

modoboa_contacts/importer/__init__.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import csv
2+
3+
from modoboa_contacts.importer.backends.outlook import OutlookBackend
4+
5+
6+
BACKENDS = [
7+
OutlookBackend,
8+
]
9+
10+
11+
def detect_import_backend(fp, delimiter: str = ";"):
12+
reader = csv.DictReader(
13+
fp,
14+
delimiter=delimiter,
15+
skipinitialspace=True
16+
)
17+
columns = reader.fieldnames
18+
rows = reader
19+
20+
for backend in BACKENDS:
21+
if backend.detect_from_columns(columns):
22+
return backend, rows
23+
24+
raise RuntimeError("Failed to detect backend to use")
25+
26+
27+
def import_csv_file(addressbook, csv_filename: str, delimiter: str):
28+
with open(csv_filename) as fp:
29+
backend, rows = detect_import_backend(fp, delimiter)
30+
backend(addressbook).proceed(rows)
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
from modoboa_contacts import models
2+
3+
4+
class ImporterBackend:
5+
"""Base class of all importer backends."""
6+
7+
name: str = None
8+
9+
field_names: dict = {}
10+
11+
def __init__(self, addressbook: models.AddressBook):
12+
self.addressbook = addressbook
13+
14+
@classmethod
15+
def detect_from_columns(cls, columns: list) -> bool:
16+
raise NotImplementedError
17+
18+
def get_email(self, values: dict):
19+
return None
20+
21+
def get_phone_number(self, values: dict):
22+
return None
23+
24+
def import_contact(self, row) -> models.Contact:
25+
contact = models.Contact(addressbook=self.addressbook)
26+
for local_name, row_name in self.field_names.items():
27+
method_name = f"get_{local_name}"
28+
if hasattr(self, method_name):
29+
value = getattr(self, method_name)(row)
30+
else:
31+
value = row[row_name]
32+
setattr(contact, local_name, value)
33+
contact.save()
34+
if self.get_email(row):
35+
models.EmailAddress.objects.create(
36+
contact=contact, address=self.get_email(row), type="work"
37+
)
38+
if self.get_phone_number(row):
39+
models.PhoneNumber.objects.create(
40+
contact=contact, number=self.get_phone_number(row), type="work"
41+
)
42+
return contact
43+
44+
def proceed(self, rows: list):
45+
for row in rows:
46+
self.import_contact(row)
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
from . import ImporterBackend
2+
3+
4+
OUTLOOK_COLUMNS = [
5+
"First Name",
6+
"Middle Name",
7+
"Last Name",
8+
"Company",
9+
"E-mail Address",
10+
"Business Phone",
11+
"Business Street",
12+
"Business Street 2",
13+
"Business City",
14+
"Business State",
15+
"Business Postal Code"
16+
]
17+
18+
19+
class OutlookBackend(ImporterBackend):
20+
"""Outlook contact importer backend."""
21+
22+
name = "outlook"
23+
field_names = {
24+
"first_name": "",
25+
"last_name": "Last Name",
26+
"company": "Company",
27+
"address": "",
28+
"city": "Business City",
29+
"zipcode": "Business Postal Code",
30+
"state": "Business State",
31+
}
32+
33+
@classmethod
34+
def detect_from_columns(cls, columns):
35+
return columns == OUTLOOK_COLUMNS
36+
37+
def get_first_name(self, values: dict) -> str:
38+
result = values["First Name"]
39+
if values["Middle Name"]:
40+
result += f" {values['Middle Name']}"
41+
return result
42+
43+
def get_address(self, values: dict) -> str:
44+
result = values["Business Street"]
45+
if values["Business Street 2"]:
46+
result += f" {values['Business Street 2']}"
47+
return result
48+
49+
def get_email(self, values: dict) -> str:
50+
return values["E-mail Address"]
51+
52+
def get_phone_number(self, values: dict) -> str:
53+
return values["Business Phone"]

modoboa_contacts/management/__init__.py

Whitespace-only changes.

modoboa_contacts/management/commands/__init__.py

Whitespace-only changes.
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
"""Management command to import contacts from a CSV file."""
2+
3+
from django.core.management.base import BaseCommand, CommandError
4+
5+
from modoboa_contacts import models
6+
from modoboa_contacts.importer import import_csv_file
7+
8+
9+
class Command(BaseCommand):
10+
"""Management command to import contacts."""
11+
12+
help = "Import contacts from a CSV file"
13+
14+
def add_arguments(self, parser):
15+
parser.add_argument(
16+
"--delimiter", type=str, default=",",
17+
help="Delimiter used in CSV file"
18+
)
19+
parser.add_argument(
20+
"email", type=str,
21+
help="Email address to import contacts for"
22+
)
23+
parser.add_argument(
24+
"file", type=str,
25+
help="Path of the CSV file to import"
26+
)
27+
28+
def handle(self, *args, **options):
29+
addressbook = (
30+
models.AddressBook.objects.filter(
31+
user__email=options["email"]).first()
32+
)
33+
if not addressbook:
34+
raise CommandError(
35+
"Address Book for email '%s' not found" % options["email"]
36+
)
37+
try:
38+
import_csv_file(addressbook, options["file"], options["delimiter"])
39+
except RuntimeError as err:
40+
raise CommandError(err)
41+
self.stdout.write(
42+
self.style.SUCCESS("File was imported successfuly")
43+
)
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Generated by Django 4.2.5 on 2023-12-06 15:04
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('modoboa_contacts', '0006_alter_phonenumber_type'),
10+
]
11+
12+
operations = [
13+
migrations.AlterField(
14+
model_name='contact',
15+
name='address',
16+
field=models.TextField(blank=True),
17+
),
18+
]

modoboa_contacts/models.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ class Contact(models.Model):
5858
company = models.CharField(max_length=100, blank=True)
5959
position = models.CharField(max_length=200, blank=True)
6060

61-
address = models.CharField(max_length=200, blank=True)
61+
address = models.TextField(blank=True)
6262
zipcode = models.CharField(max_length=15, blank=True)
6363
city = models.CharField(max_length=100, blank=True)
6464
country = models.CharField(max_length=100, blank=True)
@@ -70,7 +70,7 @@ class Contact(models.Model):
7070

7171
def __init__(self, *args, **kwargs):
7272
"""Set uid for new object."""
73-
super(Contact, self).__init__(*args, **kwargs)
73+
super().__init__(*args, **kwargs)
7474
if not self.pk:
7575
self.uid = "{}.vcf".format(uuid.uuid4())
7676

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
"First Name","Middle Name","Last Name","Company","E-mail Address","Business Phone","Business Street","Business Street 2","Business City","Business State","Business Postal Code"
2+
Toto,Tata,Titi,Company,toto@titi.com,12345678,Street 1,Street 2,City,State,France

modoboa_contacts/tests.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
# coding: utf-8
22
"""Contacts backend tests."""
33

4+
import os
5+
46
import httmock
57

68
from django import forms
9+
from django.core import management
10+
711
from django.urls import reverse
812
from django.utils import timezone
913

@@ -36,7 +40,7 @@ class RadicaleParametersForm(param_forms.AdminParametersForm):
3640
param_tools.registry.add("global", RadicaleParametersForm, "Radicale")
3741

3842

39-
class TestDataMixin(object):
43+
class TestDataMixin:
4044
"""Create some data."""
4145

4246
@classmethod
@@ -342,3 +346,33 @@ def test_emails_list(self):
342346
response = self.client.get("{}?search=Simpson".format(url))
343347
self.assertEqual(response.status_code, 200)
344348
self.assertEqual(len(response.data), 3)
349+
350+
351+
class ImportTestCase(TestDataMixin, ModoTestCase):
352+
353+
def setUp(self):
354+
self.path = os.path.join(
355+
os.path.abspath(os.path.dirname(__file__)),
356+
"test_data/outlook_export.csv"
357+
)
358+
359+
def test_import_wrong_addressbook(self):
360+
with self.assertRaises(management.base.CommandError) as ctx:
361+
management.call_command(
362+
"import_contacts", "error@test.com", self.path)
363+
self.assertEqual(ctx.exception,
364+
"Address Book for email 'error@test.com' not found")
365+
366+
def test_import_from_outlook(self):
367+
management.call_command(
368+
"import_contacts", "user@test.com", self.path)
369+
address = models.EmailAddress.objects.get(
370+
address="toto@titi.com")
371+
phone = models.PhoneNumber.objects.get(
372+
number="12345678")
373+
self.assertEqual(address.contact.first_name, "Toto Tata")
374+
self.assertEqual(address.contact.addressbook.user.email, "user@test.com")
375+
self.assertEqual(
376+
address.contact.address,
377+
"Street 1 Street 2"
378+
)

requirements.txt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
django-filter==23.4
1+
django-filter==23.5
22
django-webpack-loader==2.0.1
33
vobject
4-
caldav==1.3.6
4+
caldav==1.3.8

0 commit comments

Comments
 (0)