Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Let users download profiles as vCards #241

Merged
merged 2 commits into from
Dec 19, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ ipdb = "*"
airtable-python = "*"
pyairtable = "*"
pytest-watch = "*"
vobject = "*"

[dev-packages]
mypy = "*"
Expand Down
1,608 changes: 1,023 additions & 585 deletions Pipfile.lock

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions cl8/templates/_profile.html
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@
href="https://linkedin.com/in/{{ profile.linkedin }}">LinkedIn</a>
{% endif %}
</p>
<p class="mb-0 text-lg">
<a href="{% url 'profile-vcard' profile.short_id %}"
class="text-blue-700"
download>Get contact</a>
</p>
{% if can_edit %}
<a href="{% url 'profile-edit' profile.short_id %}"
class="btn btn-sm mt-2 mb-2 max-w-xs">Edit</a>
Expand Down
25 changes: 24 additions & 1 deletion cl8/users/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@
from django.core import paginator
from django.core.files.images import ImageFile
from django.db.models import Case, When
from django.http import HttpRequest
from django.http import HttpRequest, HttpResponse
from django.shortcuts import render
from django.urls import resolve
from django.utils.text import slugify
from django.views import View
from django.views.generic import DetailView, UpdateView
from django.views.generic.detail import SingleObjectMixin
from django.views.generic.edit import CreateView
from markdown_it import MarkdownIt
from rest_framework import status
Expand Down Expand Up @@ -42,6 +44,7 @@
ProfileSerializer,
TagSerializer,
)
from ...utils import vcard_util

User = get_user_model()

Expand Down Expand Up @@ -264,6 +267,26 @@ def get(self, request, *args, **kwargs):
return self.render_to_response(context)


class ProfileVcardView(View, LoginRequiredMixin, SingleObjectMixin):
model = Profile
slug_field = "short_id"

def get(self, *args, **kwargs):
profile = self.get_object()

data = profile.vcard().serialize()
filename = vcard_util.content_disposition_filename(profile.name)

return HttpResponse(
data,
charset="utf-8",
content_type="text/vcard",
headers={
"Content-Disposition": f'attachment; filename="{filename}"',
},
)


class ProfileEditView(LoginRequiredMixin, PermissionRequiredMixin, UpdateView):
queryset = Profile.objects.all()
slug_field = "short_id"
Expand Down
19 changes: 19 additions & 0 deletions cl8/users/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
from taggit.models import TagBase, TaggedItemBase
from django.contrib.sites.models import Site
from shortuuid.django_fields import ShortUUIDField
import vobject
from ..utils import vcard_util
import logging


Expand Down Expand Up @@ -284,6 +286,23 @@ def generate_invite_mail(self):

return {"text": rendered_invite_txt, "html": rendered_invite_html}

def vcard(self) -> vobject.vCard:
properties = {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could add additional properties in the future, but I think this is a good starter set. The vCard Wikipedia page has a good list of these properties, as does the spec.

"FN": self.name,
"N": vcard_util.full_name_to_name(self.name),
"EMAIL": self.email,
"URL": self.website,
"PHONE": self.phone,
"ORG": self.organisation,
}

result = vobject.vCard()
for property_name, property_value in properties.items():
if property_value:
result.add(property_name).value = property_value

return result


class Constellation(models.Model):
"""
Expand Down
9 changes: 9 additions & 0 deletions cl8/users/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,15 @@ def test_clusters_as_tags(self, profile: Profile):

assert "Open Energy" in [tag_name for tag_name in profile.clusters.names()]

def test_vcard(self, profile: Profile):
vcard = profile.vcard()

assert vcard.fn.value == profile.name
assert vcard.email.value == profile.email
assert vcard.phone.value == profile.phone
assert vcard.url.value == profile.website
assert vcard.org.value == profile.organisation


class TestSiteProfile:
def test_site_profile(self):
Expand Down
Empty file added cl8/utils/tests/__init__.py
Empty file.
68 changes: 68 additions & 0 deletions cl8/utils/tests/test_vcard_util.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import pytest
from vobject.vcard import Name

from .. import vcard_util


class TestVcardUtil:
def test_full_name_to_name(self):
EvanHahn marked this conversation as resolved.
Show resolved Hide resolved
all_in_given_name = [
"",
"Biscuit",
"Dr.",
"Dr. Gravy Biscuit",
"Sweet Gravy Biscuit",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good names.

"Ms. Dr. Cheese Gravy Biscuit Jr. M.D.",
"Biscuit Jr.",
"宮本 茂",
"أبو بکر محمد بن زکریاء الرازي",
]
for full_name in all_in_given_name:
actual = vcard_util.full_name_to_name(full_name)
expected = Name(given=full_name)
assert actual == expected

split = [
("Gravy", "Biscuit"),
("Großer", "Keks"),
]
for given, family in split:
full_name = f"{given} {family}"
actual = vcard_util.full_name_to_name(full_name)
expected = Name(given=given, family=family)
assert actual == expected

def test_content_disposition_filename(self):
EvanHahn marked this conversation as resolved.
Show resolved Hide resolved
use_fallback = [
"",
" ",
"a" * 256,
"A\nB",
"ÀBC",
"A%B",
"A\\B",
"A\tB",
"A$B",
"AB123",
]
for full_name in use_fallback:
actual = vcard_util.content_disposition_filename(full_name)
expected = "contact.vcf"
assert actual == expected

ok_to_serialize = [
"Biscuit",
"Gravy Biscuit",
"Big Gravy Biscuit",
"Gravy-Biscuit",
]
for full_name in ok_to_serialize:
actual = vcard_util.content_disposition_filename(full_name)
expected = f"{full_name}.vcf"
assert actual == expected

filtered_actual = vcard_util.content_disposition_filename(
"Dr. Gravy Biscuit, Jr."
)
filtered_expected = "Dr Gravy Biscuit Jr"
assert actual == expected
107 changes: 107 additions & 0 deletions cl8/utils/vcard_util.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import itertools
import unicodedata
import vobject


def _is_all_latin_characters(string: str) -> bool:
"""
Returns True if every character in the string meets the following criteria:

- It's in the [Basic Latin][1] or [Latin-1 Supplement][2] Unicode block
(U+0000 to U+00FF).
- It's a hyphen or [categorized][3] as a Unicode Letter.

Returns False otherwise.

[1]: https://www.unicode.org/charts/PDF/U0000.pdf
[2]: https://www.unicode.org/charts/PDF/U0080.pdf
[3]: https://www.unicode.org/reports/tr44/#General_Category_Values
"""
for character in string:
if character == "-":
continue

# Characters < 0x41 are in the Basic Latin block,
# but 0x41 is the first letter.
is_in_latin_block = 0x41 <= ord(character) <= 0xFF
if not is_in_latin_block:
return False

is_in_valid_category = unicodedata.category(character).startswith("L")
if not is_in_valid_category:
return False

return True


def full_name_to_name(full_name: str) -> vobject.vcard.Name:
"""
Convert free-form full name strings (like "Alice McBiscuit") to structured
vCard names.

vCard has two relevant name fields:

1. [FN], which is effectively a string
2. [N], which breaks names into pieces: given name, surname, etc.

We should always provide FN. Unfortunately, we also need to provide N
because (1) some vCard parsers require it (2) our vCard library produces
vCard version 3 files where [N is required][0].

Because we don't store these structured fields, we use a simple heuristic
to build structure from free-form name strings.

If this function receives a two-word name made up of Latin characters, it
will break it into given + family name. Otherwise, it will "give up" and
put everything into the given name. See the tests for examples, as well as
some edge cases not documented here.

This heuristic could be made more sophisticated but should work well enough
given that many contact apps prefer FN and use N as a fallback.

[FN]: https://datatracker.ietf.org/doc/html/rfc6350#section-6.2.1
[N]: https://datatracker.ietf.org/doc/html/rfc6350#section-6.2.2
[0]: https://www.rfc-editor.org/rfc/rfc2426#section-3.1.2
"""

give_up_result = vobject.vcard.Name(given=full_name)

if len(full_name) >= 1024:
return give_up_result

words = full_name.split()

if len(words) != 2:
return give_up_result

if not all(_is_all_latin_characters(word) for word in words):
return give_up_result

return vobject.vcard.Name(given=words[0], family=words[1])


def content_disposition_filename(full_name: str) -> str:
"""
Generate the .vcf filename given a full name, suitable for use in the
`Content-Disposition` header. Should be quoted.
"""

fallback = "contact.vcf"

if len(full_name) >= 256:
return fallback

if not full_name.isascii():
return fallback

full_name = full_name.replace(".", "").replace(",", "").strip()

if len(full_name) == 0:
return fallback

for character in full_name:
is_valid = character == "-" or character == " " or character.isalpha()
if not is_valid:
return fallback

return f"{full_name}.vcf"
2 changes: 2 additions & 0 deletions config/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from cl8.users.api.views import (
ProfileCreateView,
ProfileDetailView,
ProfileVcardView,
ProfileEditView,
TagAutoCompleteView,
homepage,
Expand All @@ -22,6 +23,7 @@
# main profile functionality
path("", homepage, name="home"),
path("profiles/create/", ProfileCreateView.as_view(), name="profile-create"),
path("profiles/<slug>.vcf", ProfileVcardView.as_view(), name="profile-vcard"),
path("profiles/<slug>", ProfileDetailView.as_view(), name="profile-detail"),
path("profiles/<slug>/edit", ProfileEditView.as_view(), name="profile-edit"),
#
Expand Down