Skip to content

Commit 1616cb1

Browse files
authored
Merge pull request #144 from modoboa/feature/import_command
Added management command to import contacts from CSV
2 parents f6fd455 + e69814f commit 1616cb1

File tree

14 files changed

+310
-15
lines changed

14 files changed

+310
-15
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: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
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,
28+
csv_filename: str,
29+
delimiter: str,
30+
carddav_password: str = None):
31+
with open(csv_filename) as fp:
32+
backend, rows = detect_import_backend(fp, delimiter)
33+
backend(addressbook).proceed(rows, carddav_password)
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
from modoboa_contacts import models, tasks
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, carddav_password: str = None):
45+
for row in rows:
46+
contact = self.import_contact(row)
47+
if carddav_password:
48+
# FIXME: refactor CDAV tasks to allow connection from
49+
# credentials and not only request
50+
clt = tasks.get_cdav_client(
51+
self.addressbook,
52+
self.addressbook.user.email,
53+
carddav_password,
54+
True
55+
)
56+
path, etag = clt.upload_new_card(contact.uid, contact.to_vcard())
57+
contact.etag = etag
58+
contact.save(update_fields=["etag"])
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: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
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+
"--carddav-password", type=str, default=None,
21+
help=(
22+
"Password associated to email. If provided, imported "
23+
"contacts will be synced to CardDAV servert too"
24+
)
25+
)
26+
parser.add_argument(
27+
"email", type=str,
28+
help="Email address to import contacts for"
29+
)
30+
parser.add_argument(
31+
"file", type=str,
32+
help="Path of the CSV file to import"
33+
)
34+
35+
def handle(self, *args, **options):
36+
addressbook = (
37+
models.AddressBook.objects.filter(
38+
user__email=options["email"]).first()
39+
)
40+
if not addressbook:
41+
raise CommandError(
42+
"Address Book for email '%s' not found" % options["email"]
43+
)
44+
try:
45+
import_csv_file(
46+
addressbook,
47+
options["file"],
48+
options["delimiter"],
49+
options.get("carddav_password")
50+
)
51+
except RuntimeError as err:
52+
raise CommandError(err)
53+
self.stdout.write(
54+
self.style.SUCCESS("File was imported successfuly")
55+
)
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

modoboa_contacts/tasks.py

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,26 @@
88
from . import models
99

1010

11-
def get_cdav_client(request, addressbook, write_support=False):
11+
def get_cdav_client(addressbook, user: str, passwd: str, write_support=False):
1212
"""Instantiate a new CardDAV client."""
1313
return carddav.PyCardDAV(
14-
addressbook.url, user=request.user.username,
15-
passwd=cryptutils.decrypt(request.session["password"]),
14+
addressbook.url,
15+
user=user,
16+
passwd=passwd,
1617
write_support=write_support
1718
)
1819

1920

21+
def get_cdav_client_from_request(request, addressbook, *args, **kwargs):
22+
"""Create a connection from a Request object."""
23+
return get_cdav_client(
24+
addressbook,
25+
request.user.username,
26+
passwd=cryptutils.decrypt(request.session["password"]),
27+
**kwargs
28+
)
29+
30+
2031
def create_cdav_addressbook(addressbook, password):
2132
"""Create CardDAV address book."""
2233
clt = carddav.PyCardDAV(
@@ -32,7 +43,7 @@ def push_addressbook_to_carddav(request, addressbook):
3243
3344
Use only once.
3445
"""
35-
clt = get_cdav_client(request, addressbook, True)
46+
clt = get_cdav_client_from_request(request, addressbook, write_support=True)
3647
for contact in addressbook.contact_set.all():
3748
href, etag = clt.upload_new_card(contact.uid, contact.to_vcard())
3849
contact.etag = etag
@@ -44,7 +55,7 @@ def push_addressbook_to_carddav(request, addressbook):
4455

4556
def sync_addressbook_from_cdav(request, addressbook):
4657
"""Fetch changes from CardDAV server."""
47-
clt = get_cdav_client(request, addressbook)
58+
clt = get_cdav_client_from_request(request, addressbook)
4859
changes = clt.sync_vcards(addressbook.sync_token)
4960
if not len(changes["cards"]):
5061
return
@@ -71,15 +82,15 @@ def sync_addressbook_from_cdav(request, addressbook):
7182

7283
def push_contact_to_cdav(request, contact):
7384
"""Upload new contact to cdav collection."""
74-
clt = get_cdav_client(request, contact.addressbook, True)
85+
clt = get_cdav_client_from_request(request, contact.addressbook, write_support=True)
7586
path, etag = clt.upload_new_card(contact.uid, contact.to_vcard())
7687
contact.etag = etag
7788
contact.save(update_fields=["etag"])
7889

7990

8091
def update_contact_cdav(request, contact):
8192
"""Update existing contact."""
82-
clt = get_cdav_client(request, contact.addressbook, True)
93+
clt = get_cdav_client_from_request(request, contact.addressbook, write_support=True)
8394
uid = contact.uid
8495
if not uid.endswith(".vcf"):
8596
uid += ".vcf"
@@ -90,7 +101,7 @@ def update_contact_cdav(request, contact):
90101

91102
def delete_contact_cdav(request, contact):
92103
"""Delete a contact."""
93-
clt = get_cdav_client(request, contact.addressbook, True)
104+
clt = get_cdav_client_from_request(request, contact.addressbook, write_support=True)
94105
uid = contact.uid
95106
if not uid.endswith(".vcf"):
96107
uid += ".vcf"
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
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","City","Business State","Business Postal Code"
2+
Toto,Tata,Titi,Company,toto@titi.com,12345678,Street 1,Street 2,City,State,France

0 commit comments

Comments
 (0)