Skip to content

Commit

Permalink
Merge pull request #144 from modoboa/feature/import_command
Browse files Browse the repository at this point in the history
Added management command to import contacts from CSV
  • Loading branch information
tonioo authored Dec 12, 2023
2 parents f6fd455 + e69814f commit 1616cb1
Show file tree
Hide file tree
Showing 14 changed files with 310 additions and 15 deletions.
4 changes: 2 additions & 2 deletions frontend/src/components/ContactForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@
<label class="col-sm-1 control-label" for="address">
<span class="fa fa-map-marker"></span></label>
<div class="col-sm-11">
<input v-model="contact.address" type="text" id="address" name="address" class="form-control" :placeholder="addressPlaceholder">
<textarea v-model="contact.address" id="address" name="address" class="form-control" :placeholder="addressPlaceholder"></textarea>
<span v-if="formErrors['address']" class="help-block">{{ formErrors['address'][0] }}</span>
</div>
</div>
Expand All @@ -96,7 +96,7 @@
</div>
</div>
<div class="form-group">
<label class="col-sm-1 control-label" for="address">
<label class="col-sm-1 control-label" for="note">
<span class="fa fa-sticky-note"></span></label>
<div class="col-sm-11">
<textarea v-model="contact.note" id="note" name="note" class="form-control" :placeholder="notePlaceholder"></textarea>
Expand Down
33 changes: 33 additions & 0 deletions modoboa_contacts/importer/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import csv

from modoboa_contacts.importer.backends.outlook import OutlookBackend


BACKENDS = [
OutlookBackend,
]


def detect_import_backend(fp, delimiter: str = ";"):
reader = csv.DictReader(
fp,
delimiter=delimiter,
skipinitialspace=True
)
columns = reader.fieldnames
rows = reader

for backend in BACKENDS:
if backend.detect_from_columns(columns):
return backend, rows

raise RuntimeError("Failed to detect backend to use")


def import_csv_file(addressbook,
csv_filename: str,
delimiter: str,
carddav_password: str = None):
with open(csv_filename) as fp:
backend, rows = detect_import_backend(fp, delimiter)
backend(addressbook).proceed(rows, carddav_password)
58 changes: 58 additions & 0 deletions modoboa_contacts/importer/backends/__init__.py
Original file line number Diff line number Diff line change
@@ -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"])
53 changes: 53 additions & 0 deletions modoboa_contacts/importer/backends/outlook.py
Original file line number Diff line number Diff line change
@@ -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 columns == OUTLOOK_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"]
Empty file.
Empty file.
55 changes: 55 additions & 0 deletions modoboa_contacts/management/commands/import_contacts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""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(
"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["file"],
options["delimiter"],
options.get("carddav_password")
)
except RuntimeError as err:
raise CommandError(err)
self.stdout.write(
self.style.SUCCESS("File was imported successfuly")
)
18 changes: 18 additions & 0 deletions modoboa_contacts/migrations/0007_alter_contact_address.py
Original file line number Diff line number Diff line change
@@ -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 = [
('modoboa_contacts', '0006_alter_phonenumber_type'),
]

operations = [
migrations.AlterField(
model_name='contact',
name='address',
field=models.TextField(blank=True),
),
]
4 changes: 2 additions & 2 deletions modoboa_contacts/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ class Contact(models.Model):
company = models.CharField(max_length=100, blank=True)
position = models.CharField(max_length=200, blank=True)

address = 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)
Expand All @@ -70,7 +70,7 @@ class Contact(models.Model):

def __init__(self, *args, **kwargs):
"""Set uid for new object."""
super(Contact, self).__init__(*args, **kwargs)
super().__init__(*args, **kwargs)
if not self.pk:
self.uid = "{}.vcf".format(uuid.uuid4())

Expand Down
27 changes: 19 additions & 8 deletions modoboa_contacts/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,26 @@
from . import models


def get_cdav_client(request, addressbook, write_support=False):
def get_cdav_client(addressbook, user: str, passwd: str, write_support=False):
"""Instantiate a new CardDAV client."""
return carddav.PyCardDAV(
addressbook.url, user=request.user.username,
passwd=cryptutils.decrypt(request.session["password"]),
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(
Expand All @@ -32,7 +43,7 @@ def push_addressbook_to_carddav(request, addressbook):
Use only once.
"""
clt = get_cdav_client(request, addressbook, True)
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
Expand All @@ -44,7 +55,7 @@ def push_addressbook_to_carddav(request, addressbook):

def sync_addressbook_from_cdav(request, addressbook):
"""Fetch changes from CardDAV server."""
clt = get_cdav_client(request, addressbook)
clt = get_cdav_client_from_request(request, addressbook)
changes = clt.sync_vcards(addressbook.sync_token)
if not len(changes["cards"]):
return
Expand All @@ -71,15 +82,15 @@ def sync_addressbook_from_cdav(request, addressbook):

def push_contact_to_cdav(request, contact):
"""Upload new contact to cdav collection."""
clt = get_cdav_client(request, contact.addressbook, True)
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(request, contact.addressbook, True)
clt = get_cdav_client_from_request(request, contact.addressbook, write_support=True)
uid = contact.uid
if not uid.endswith(".vcf"):
uid += ".vcf"
Expand All @@ -90,7 +101,7 @@ def update_contact_cdav(request, contact):

def delete_contact_cdav(request, contact):
"""Delete a contact."""
clt = get_cdav_client(request, contact.addressbook, True)
clt = get_cdav_client_from_request(request, contact.addressbook, write_support=True)
uid = contact.uid
if not uid.endswith(".vcf"):
uid += ".vcf"
Expand Down
2 changes: 2 additions & 0 deletions modoboa_contacts/test_data/outlook_export.csv
Original file line number Diff line number Diff line change
@@ -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,[email protected],12345678,Street 1,Street 2,City,State,France
2 changes: 2 additions & 0 deletions modoboa_contacts/test_data/unknown_export.csv
Original file line number Diff line number Diff line change
@@ -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,[email protected],12345678,Street 1,Street 2,City,State,France
Loading

0 comments on commit 1616cb1

Please sign in to comment.