, 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"
diff --git a/modoboa/contacts/management/__init__.py b/modoboa/contacts/management/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/modoboa/contacts/management/commands/__init__.py b/modoboa/contacts/management/commands/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/modoboa/contacts/management/commands/import_contacts.py b/modoboa/contacts/management/commands/import_contacts.py
new file mode 100644
index 000000000..972ab21c2
--- /dev/null
+++ b/modoboa/contacts/management/commands/import_contacts.py
@@ -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"))
diff --git a/modoboa/contacts/migrations/0001_initial.py b/modoboa/contacts/migrations/0001_initial.py
new file mode 100644
index 000000000..aa227d753
--- /dev/null
+++ b/modoboa/contacts/migrations/0001_initial.py
@@ -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"},
+ ),
+ ]
diff --git a/modoboa/contacts/migrations/0002_auto_20180124_2311.py b/modoboa/contacts/migrations/0002_auto_20180124_2311.py
new file mode 100644
index 000000000..367e78053
--- /dev/null
+++ b/modoboa/contacts/migrations/0002_auto_20180124_2311.py
@@ -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,
+ ),
+ ),
+ ]
diff --git a/modoboa/contacts/migrations/0003_auto_20181005_1415.py b/modoboa/contacts/migrations/0003_auto_20181005_1415.py
new file mode 100644
index 000000000..1a99bd185
--- /dev/null
+++ b/modoboa/contacts/migrations/0003_auto_20181005_1415.py
@@ -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",
+ ),
+ ),
+ ]
diff --git a/modoboa/contacts/migrations/0004_auto_20181005_1415.py b/modoboa/contacts/migrations/0004_auto_20181005_1415.py
new file mode 100644
index 000000000..8208a56d0
--- /dev/null
+++ b/modoboa/contacts/migrations/0004_auto_20181005_1415.py
@@ -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)]
diff --git a/modoboa/contacts/migrations/0005_auto_20181005_1445.py b/modoboa/contacts/migrations/0005_auto_20181005_1445.py
new file mode 100644
index 000000000..0d404cd5f
--- /dev/null
+++ b/modoboa/contacts/migrations/0005_auto_20181005_1445.py
@@ -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"
+ ),
+ ),
+ ]
diff --git a/modoboa/contacts/migrations/0006_alter_phonenumber_type.py b/modoboa/contacts/migrations/0006_alter_phonenumber_type.py
new file mode 100644
index 000000000..c57507217
--- /dev/null
+++ b/modoboa/contacts/migrations/0006_alter_phonenumber_type.py
@@ -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,
+ ),
+ ),
+ ]
diff --git a/modoboa/contacts/migrations/0007_alter_contact_address.py b/modoboa/contacts/migrations/0007_alter_contact_address.py
new file mode 100644
index 000000000..2a5b8575e
--- /dev/null
+++ b/modoboa/contacts/migrations/0007_alter_contact_address.py
@@ -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),
+ ),
+ ]
diff --git a/modoboa/contacts/migrations/__init__.py b/modoboa/contacts/migrations/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/modoboa/contacts/mocks.py b/modoboa/contacts/mocks.py
new file mode 100644
index 000000000..f777ec4f6
--- /dev/null
+++ b/modoboa/contacts/mocks.py
@@ -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"""
+
+
+ /radicale/user@test.com/contacts/
+
+
+ http://modoboa.org/ns/sync-token/3145
+
+ HTTP/1.1 200 OK
+
+
+
+"""
+ 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"""
+
+
+ /radicale/user@test.com/contacts/newcard.vcf
+
+
+ "33441-34321"
+
+ HTTP/1.1 200 OK
+
+
+
+"""
+ else:
+ content = b"""
+
+
+ /radicale/user@test.com/contacts/newcard.vcf
+
+
+ "33441-34321"
+
+ HTTP/1.1 200 OK
+
+
+
+ /radicale/user@test.com/contacts/updatedcard.vcf
+
+
+ "33541-34696"
+
+ HTTP/1.1 200 OK
+
+
+
+ /radicale/user@test.com/contacts/deletedcard.vcf
+ HTTP/1.1 404 Not Found
+
+ http://modoboa.org/ns/sync/5001
+
+"""
+ 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}
diff --git a/modoboa/contacts/models.py b/modoboa/contacts/models.py
new file mode 100644
index 000000000..81576f409
--- /dev/null
+++ b/modoboa/contacts/models.py
@@ -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)
diff --git a/modoboa/contacts/serializers.py b/modoboa/contacts/serializers.py
new file mode 100644
index 000000000..8ba9ee83e
--- /dev/null
+++ b/modoboa/contacts/serializers.py
@@ -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
diff --git a/modoboa/contacts/settings.py b/modoboa/contacts/settings.py
new file mode 100644
index 000000000..74eaf5144
--- /dev/null
+++ b/modoboa/contacts/settings.py
@@ -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
diff --git a/modoboa/contacts/static/modoboa_contacts/webpack-stats.json b/modoboa/contacts/static/modoboa_contacts/webpack-stats.json
new file mode 100644
index 000000000..d874920d2
--- /dev/null
+++ b/modoboa/contacts/static/modoboa_contacts/webpack-stats.json
@@ -0,0 +1,6 @@
+{
+ "status": "compile",
+ "assets": {},
+ "chunks": {},
+ "publicPath": "/sitestatic/"
+}
\ No newline at end of file
diff --git a/modoboa/contacts/tasks.py b/modoboa/contacts/tasks.py
new file mode 100644
index 000000000..ac3dd045c
--- /dev/null
+++ b/modoboa/contacts/tasks.py
@@ -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)
diff --git a/modoboa/contacts/templates/modoboa_contacts/index.html b/modoboa/contacts/templates/modoboa_contacts/index.html
new file mode 100644
index 000000000..afb6a3c4f
--- /dev/null
+++ b/modoboa/contacts/templates/modoboa_contacts/index.html
@@ -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 %}
+
+ {% endif %}
+ {% render_bundle 'app' 'js' 'CONTACTS' %}
+{% endblock %}
+
+{% block container_content %}
+
+
+
+
+{% endblock %}
diff --git a/modoboa/contacts/test_data/outlook_export.csv b/modoboa/contacts/test_data/outlook_export.csv
new file mode 100644
index 000000000..c05f7020d
--- /dev/null
+++ b/modoboa/contacts/test_data/outlook_export.csv
@@ -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
diff --git a/modoboa/contacts/test_data/unknown_export.csv b/modoboa/contacts/test_data/unknown_export.csv
new file mode 100644
index 000000000..c7bb215e9
--- /dev/null
+++ b/modoboa/contacts/test_data/unknown_export.csv
@@ -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
diff --git a/modoboa/contacts/tests.py b/modoboa/contacts/tests.py
new file mode 100644
index 000000000..c3668cf98
--- /dev/null
+++ b/modoboa/contacts/tests.py
@@ -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, '')
+
+
+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")
diff --git a/modoboa/contacts/urls.py b/modoboa/contacts/urls.py
new file mode 100644
index 000000000..d8ee9c70e
--- /dev/null
+++ b/modoboa/contacts/urls.py
@@ -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
diff --git a/modoboa/contacts/viewsets.py b/modoboa/contacts/viewsets.py
new file mode 100644
index 000000000..ac825b825
--- /dev/null
+++ b/modoboa/contacts/viewsets.py
@@ -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
+ )
diff --git a/modoboa/core/api/v2/viewsets.py b/modoboa/core/api/v2/viewsets.py
index f3f67a67a..5974db84a 100644
--- a/modoboa/core/api/v2/viewsets.py
+++ b/modoboa/core/api/v2/viewsets.py
@@ -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)
diff --git a/modoboa/urls_api_v2.py b/modoboa/urls_api_v2.py
index 5de4b5e0f..f8b748250 100644
--- a/modoboa/urls_api_v2.py
+++ b/modoboa/urls_api_v2.py
@@ -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")
diff --git a/test_project/test_project/settings.py b/test_project/test_project/settings.py
index 737130611..1723c6114 100644
--- a/test_project/test_project/settings.py
+++ b/test_project/test_project/settings.py
@@ -102,8 +102,7 @@ MODOBOA_APPS = (
"modoboa.postfix_autoreply",
"modoboa.sievefilters",
# Modoboa extensions here.
- "modoboa_user",
- "modoboa_user.contacts",
+ "modoboa.contacts",
)
try: