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

✨(models/api) allow inviting external users to a document by their email #56

Merged
merged 2 commits into from
May 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ services:
- ".:/app"

y-webrtc-signaling:
user: ${DOCKER_USER:-1000}
build:
context: .
dockerfile: ./src/frontend/Dockerfile
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
Loading