Skip to content

Commit

Permalink
✨(models/api) allow inviting external users to a document by their email
Browse files Browse the repository at this point in the history
We want to be able to share a document with a person even if this person
does not have an account in impress yet.

This code is ported from https://github.com/numerique-gouv/people.
  • Loading branch information
sampaccoud committed May 24, 2024
1 parent aa990bf commit f246d11
Show file tree
Hide file tree
Showing 20 changed files with 1,334 additions and 37 deletions.
12 changes: 12 additions & 0 deletions .github/workflows/impress.yml
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,11 @@ jobs:
run: yarn install --frozen-lockfile
- name: Build mails
run: yarn build
- name: Persist mails' templates
uses: actions/upload-artifact@v4
with:
name: mails-templates
path: src/backend/core/templates/mail

lint-back:
runs-on: ubuntu-latest
Expand All @@ -96,6 +101,7 @@ jobs:

test-back:
runs-on: ubuntu-latest
needs: build-mails

defaults:
run:
Expand Down Expand Up @@ -137,6 +143,12 @@ jobs:
sudo mkdir -p /data/media && \
sudo mkdir -p /data/static
- name: Download mails' templates
uses: actions/download-artifact@v4
with:
name: mails-templates
path: src/backend/core/templates/mail

- name: Start Minio
run: |
docker pull minio/minio
Expand Down
32 changes: 31 additions & 1 deletion src/backend/core/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,15 +78,45 @@ class TemplateAdmin(admin.ModelAdmin):

inlines = (TemplateAccessInline,)


class DocumentAccessInline(admin.TabularInline):
"""Inline admin class for template accesses."""

model = models.DocumentAccess
extra = 0


@admin.register(models.Document)
class DocumentAdmin(admin.ModelAdmin):
"""Document admin interface declaration."""

inlines = (DocumentAccessInline,)



@admin.register(models.Invitation)
class InvitationAdmin(admin.ModelAdmin):
"""Admin interface to handle invitations."""

fields = (
"email",
"document",
"role",
"created_at",
"issuer",
)
readonly_fields = (
"created_at",
"is_expired",
"issuer",
)
list_display = (
"email",
"document",
"created_at",
"is_expired",
)

def save_model(self, request, obj, form, change):
obj.issuer = request.user
obj.save()

81 changes: 80 additions & 1 deletion src/backend/core/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,14 +66,14 @@ def validate(self, attrs):

# Create
else:
teams = user.get_teams()
try:
resource_id = self.context["resource_id"]
except KeyError as exc:
raise exceptions.ValidationError(
"You must set a resource ID in kwargs to create a new access."
) from exc

teams = user.get_teams()
if not self.Meta.model.objects.filter( # pylint: disable=no-member
Q(user=user) | Q(team__in=teams),
role__in=[models.RoleChoices.OWNER, models.RoleChoices.ADMIN],
Expand Down Expand Up @@ -172,3 +172,82 @@ class DocumentGenerationSerializer(serializers.Serializer):
required=False,
default="html",
)


class InvitationSerializer(serializers.ModelSerializer):
"""Serialize invitations."""

abilities = serializers.SerializerMethodField(read_only=True)

class Meta:
model = models.Invitation
fields = [
"id",
"abilities",
"created_at",
"email",
"document",
"role",
"issuer",
"is_expired",
]
read_only_fields = [
"id",
"abilities",
"created_at",
"document",
"issuer",
"is_expired",
]

def get_abilities(self, invitation) -> dict:
"""Return abilities of the logged-in user on the instance."""
request = self.context.get("request")
if request:
return invitation.get_abilities(request.user)
return {}

def validate(self, attrs):
"""Validate and restrict invitation to new user based on email."""

request = self.context.get("request")
user = getattr(request, "user", None)
role = attrs.get("role")

try:
document_id = self.context["resource_id"]
except KeyError as exc:
raise exceptions.ValidationError(
"You must set a document ID in kwargs to create a new document invitation."
) from exc

if not user and user.is_authenticated:
raise exceptions.PermissionDenied(
"Anonymous users are not allowed to create invitations."
)

teams = user.get_teams()
if not models.DocumentAccess.objects.filter(
Q(user=user) | Q(team__in=teams),
document=document_id,
role__in=[models.RoleChoices.OWNER, models.RoleChoices.ADMIN],
).exists():
raise exceptions.PermissionDenied(
"You are not allowed to manage invitations for this document."
)

if (
role == models.RoleChoices.OWNER
and not models.DocumentAccess.objects.filter(
Q(user=user) | Q(team__in=teams),
document=document_id,
role=models.RoleChoices.OWNER,
).exists()
):
raise exceptions.PermissionDenied(
"Only owners of a document can invite other users as owners."
)

attrs["document_id"] = document_id
attrs["issuer"] = user
return attrs
75 changes: 75 additions & 0 deletions src/backend/core/api/viewsets.py
Original file line number Diff line number Diff line change
Expand Up @@ -479,3 +479,78 @@ class TemplateAccessViewSet(
queryset = models.TemplateAccess.objects.select_related("user").all()
resource_field_name = "template"
serializer_class = serializers.TemplateAccessSerializer


class InvitationViewset(
mixins.CreateModelMixin,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.DestroyModelMixin,
viewsets.GenericViewSet,
):
"""API ViewSet for user invitations to document.
GET /api/v1.0/documents/<document_id>/invitations/:<invitation_id>/
Return list of invitations related to that document or one
document access if an id is provided.
POST /api/v1.0/documents/<document_id>/invitations/ with expected data:
- email: str
- role: str [owner|admin|member]
Return newly created invitation (issuer and document are automatically set)
PUT / PATCH : Not permitted. Instead of updating your invitation,
delete and create a new one.
DELETE /api/v1.0/documents/<document_id>/invitations/<invitation_id>/
Delete targeted invitation
"""

lookup_field = "id"
pagination_class = Pagination
permission_classes = [permissions.IsAuthenticated, permissions.AccessPermission]
queryset = (
models.Invitation.objects.all()
.select_related("document")
.order_by("-created_at")
)
serializer_class = serializers.InvitationSerializer

def get_serializer_context(self):
"""Extra context provided to the serializer class."""
context = super().get_serializer_context()
context["resource_id"] = self.kwargs["resource_id"]
return context

def get_queryset(self):
"""Return the queryset according to the action."""
queryset = super().get_queryset()
queryset = queryset.filter(document=self.kwargs["resource_id"])

if self.action == "list":
user = self.request.user
teams = user.get_teams()

# Determine which role the logged-in user has in the document
user_roles_query = (
models.DocumentAccess.objects.filter(
Q(user=user) | Q(team__in=teams),
document=self.kwargs["resource_id"],
)
.values("document")
.annotate(roles_array=ArrayAgg("role"))
.values("roles_array")
)

queryset = (
# The logged-in user should be part of a document to see its accesses
queryset.filter(
Q(document__accesses__user=user)
| Q(document__accesses__team__in=teams),
)
# Abilities are computed based on logged-in user's role and
# the user role on each document access
.annotate(user_roles=Subquery(user_roles_query))
.distinct()
)
return queryset
12 changes: 12 additions & 0 deletions src/backend/core/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,3 +112,15 @@ class Meta:
template = factory.SubFactory(TemplateFactory)
team = factory.Sequence(lambda n: f"team{n}")
role = factory.fuzzy.FuzzyChoice([r[0] for r in models.RoleChoices.choices])


class InvitationFactory(factory.django.DjangoModelFactory):
"""A factory to create invitations for a user"""

class Meta:
model = models.Invitation

email = factory.Faker("email")
document = factory.SubFactory(DocumentFactory)
role = factory.fuzzy.FuzzyChoice([role[0] for role in models.RoleChoices.choices])
issuer = factory.SubFactory(UserFactory)
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Generated by Django 5.0.3 on 2024-05-12 19:02

import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('core', '0001_initial'),
]

operations = [
migrations.AlterField(
model_name='user',
name='language',
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
),
migrations.CreateModel(
name='Invitation',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
('email', models.EmailField(max_length=254, verbose_name='email address')),
('role', models.CharField(choices=[('member', 'Member'), ('administrator', 'Administrator'), ('owner', 'Owner')], default='member', max_length=20)),
('document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invitations', to='core.document')),
('issuer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invitations', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Document invitation',
'verbose_name_plural': 'Document invitations',
'db_table': 'impress_invitation',
},
),
migrations.AddConstraint(
model_name='invitation',
constraint=models.UniqueConstraint(fields=('email', 'document'), name='email_and_document_unique_together'),
),
]
Loading

0 comments on commit f246d11

Please sign in to comment.