Compare commits

...

9 Commits

Author SHA1 Message Date
Anthony LC 10c53d2a4e 🔖(minor) initial release to 0.1.0
Added
- Coming Soon page
- Impress, project to manage your documents easily
  and collaboratively
2024-05-24 10:49:09 +02:00
Anthony LC 5c9e4ab3e6 🏷️(backend) accept string as saved document
Saved documents has to be a string now.
Before it has to be a json object.
2024-05-24 10:27:34 +02:00
Anthony LC 7aeea18202 🗃️(frontend) add initial pad to webrtcserver
It is the webrtc server that will be responsible
for managing the data of the BlockNote Editor.

So to restore the data of a saved pads, we have to
add the initial pad to the room of the webrtc
server, it will then update the data of BlockNote
Editor.
The webrtc server accept Y.Doc, so we
convert our base64 data from Minio to make it
compatible with the server.

By doing so, we avoid the problem of
data lost when multiple users are connecting
and one user already updated the data of the
BlockNote Editor before saving it.
2024-05-24 10:27:34 +02:00
Anthony LC 64d508c260 (frontend) pad persistance
This commit manage the persistance of the pad.
We save the pad in different ways:
- when the user close the tab or the browser
- when the user leave the page (go to another pad
  by example)
- every 1 minute
----
- We save the pad only if the pad has been modified.
- Pads are collaborative, to not save multiple
  times the same pad, we save the pad only if
  the user is the last to have modified the pad.
----
Because of the collaborative aspect of the pads,
the best way to store our pad is to save the
Y.Doc, to do so the recommended way is to convert
the Y.Doc to a Uint8Array and then to a string
(base64). Our pad are saved as a string in a
object in a Minio bucket.
2024-05-24 10:27:34 +02:00
Anthony LC d75649d18a 🏷️(frontend) adapt some types
Adapt some pad types.
2024-05-24 10:27:34 +02:00
Anthony LC a851720441 (frontend) create useUpdatePad react query hook
Create a react query hook to update a pad.
PATCH /documents/:id
2024-05-24 10:27:34 +02:00
Anthony LC 29f13f5f4b 🚨(frontend) stop throwing errors on id pages
We stopped throwing errors on id pages, often
Next.js just need a rerender to get the id
from the router correctly.
2024-05-24 10:27:34 +02:00
Samuel Paccoud - DINUM 515b686795 (models/api) allow inviting external users to a document by their email
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.
2024-05-24 08:20:28 +02:00
Samuel Paccoud - DINUM 125284456f 🐛(docker-compose) run node container as logged-in user
Running the container as root will install npm modules with root
access rights and generate issues when running the application with
the logged in user as we do it with Docker Compose.
2024-05-24 08:20:28 +02:00
37 changed files with 1582 additions and 76 deletions
+12
View File
@@ -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
@@ -96,6 +101,7 @@ jobs:
test-back:
runs-on: ubuntu-latest
needs: build-mails
defaults:
run:
@@ -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
+11
View File
@@ -7,3 +7,14 @@ and this project adheres to
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [0.1.0] - 2024-05-24
## Added
- Coming Soon page (#67)
- Impress, project to manage your documents easily and collaboratively.
[unreleased]: https://github.com/numerique-gouv/impress/compare/v0.1.0...main
[0.1.0]: https://github.com/numerique-gouv/impress/releases/v0.1.0
+1
View File
@@ -142,6 +142,7 @@ services:
- ".:/app"
y-webrtc-signaling:
user: ${DOCKER_USER:-1000}
build:
context: .
dockerfile: ./src/frontend/Dockerfile
+31 -1
View File
@@ -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 -4
View File
@@ -7,8 +7,6 @@ from timezone_field.rest_framework import TimeZoneSerializerField
from core import models
from .fields import JSONField
class UserSerializer(serializers.ModelSerializer):
"""Serialize users."""
@@ -66,7 +64,6 @@ class BaseAccessSerializer(serializers.ModelSerializer):
# Create
else:
teams = user.get_teams()
try:
resource_id = self.context["resource_id"]
except KeyError as exc:
@@ -74,6 +71,7 @@ class BaseAccessSerializer(serializers.ModelSerializer):
"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],
@@ -136,7 +134,7 @@ class BaseResourceSerializer(serializers.ModelSerializer):
class DocumentSerializer(BaseResourceSerializer):
"""Serialize documents."""
content = JSONField(required=False)
content = serializers.CharField(required=False)
class Meta:
model = models.Document
@@ -172,3 +170,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
+76 -1
View File
@@ -344,7 +344,7 @@ class DocumentViewSet(
return drf_response.Response(
{
"content": json.loads(response["Body"].read()),
"content": response["Body"].read().decode("utf-8"),
"last_modified": response["LastModified"],
}
)
@@ -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
+13 -1
View File
@@ -35,7 +35,7 @@ class DocumentFactory(factory.django.DjangoModelFactory):
title = factory.Sequence(lambda n: f"document{n}")
is_public = factory.Faker("boolean")
content = factory.LazyFunction(lambda: {"foo": fake.word()})
content = factory.Sequence(lambda n: f"content{n}")
@factory.post_generation
def users(self, create, extracted, **kwargs):
@@ -112,3 +112,15 @@ class TeamTemplateAccessFactory(factory.django.DjangoModelFactory):
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)
@@ -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'),
),
]
+159 -9
View File
@@ -3,21 +3,27 @@ Declare and configure the models for the impress core application
"""
import hashlib
import json
import smtplib
import textwrap
import uuid
from datetime import timedelta
from logging import getLogger
from django.conf import settings
from django.contrib.auth import models as auth_models
from django.contrib.auth.base_user import AbstractBaseUser
from django.core import mail, validators
from django.contrib.sites.models import Site
from django.core import exceptions, mail, validators
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage
from django.db import models
from django.template.base import Template as DjangoTemplate
from django.template.context import Context
from django.template.loader import render_to_string
from django.utils import html, timezone
from django.utils.functional import lazy
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
from django.utils.translation import override
import frontmatter
import markdown
@@ -26,6 +32,8 @@ from timezone_field import TimeZoneField
from weasyprint import CSS, HTML
from weasyprint.text.fonts import FontConfiguration
logger = getLogger(__name__)
def get_resource_roles(resource, user):
"""Compute the roles a user has on a resource."""
@@ -164,6 +172,42 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
def __str__(self):
return self.email or self.admin_email or str(self.id)
def save(self, *args, **kwargs):
"""
If it's a new user, give its user access to the documents to which s.he was invited.
"""
is_adding = self._state.adding
super().save(*args, **kwargs)
if is_adding:
self._convert_valid_invitations()
def _convert_valid_invitations(self):
"""
Convert valid invitations to document accesses.
Expired invitations are ignored.
"""
valid_invitations = Invitation.objects.filter(
email=self.email,
created_at__gte=(
timezone.now()
- timedelta(seconds=settings.INVITATION_VALIDITY_DURATION)
),
).select_related("document")
if not valid_invitations.exists():
return
DocumentAccess.objects.bulk_create(
[
DocumentAccess(
user=self, document=invitation.document, role=invitation.role
)
for invitation in valid_invitations
]
)
valid_invitations.delete()
def email_user(self, subject, message, from_email=None, **kwargs):
"""Email this user."""
if not self.email:
@@ -281,16 +325,15 @@ class Document(BaseModel):
except (FileNotFoundError, ClientError):
pass
else:
self._content = json.loads(response["Body"].read())
self._content = response["Body"].read().decode('utf-8')
return self._content
@content.setter
def content(self, content):
"""Cache the content, don't write to object storage yet"""
if isinstance(content, str):
content = json.loads(content)
if not isinstance(content, dict):
raise ValueError("content should be a json object.")
if not isinstance(content, str):
raise ValueError("content should be a string.")
self._content = content
def get_content_response(self, version_id=""):
@@ -305,7 +348,7 @@ class Document(BaseModel):
if self._content:
file_key = self.file_key
bytes_content = json.dumps(self._content).encode("utf-8")
bytes_content = self._content.encode("utf-8")
if default_storage.exists(file_key):
response = default_storage.connection.meta.client.head_object(
@@ -523,7 +566,7 @@ class Template(BaseModel):
document_html = HTML(
string=DjangoTemplate(self.code).render(
Context({"body": format_html(body_html), **metadata})
Context({"body": html.format_html(body_html), **metadata})
)
)
css = CSS(
@@ -576,3 +619,110 @@ class TemplateAccess(BaseAccess):
Compute and return abilities for a given user on the template access.
"""
return self._get_abilities(self.template, user)
class Invitation(BaseModel):
"""User invitation to a document."""
email = models.EmailField(_("email address"), null=False, blank=False)
document = models.ForeignKey(
Document,
on_delete=models.CASCADE,
related_name="invitations",
)
role = models.CharField(
max_length=20, choices=RoleChoices.choices, default=RoleChoices.MEMBER
)
issuer = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name="invitations",
)
class Meta:
db_table = "impress_invitation"
verbose_name = _("Document invitation")
verbose_name_plural = _("Document invitations")
constraints = [
models.UniqueConstraint(
fields=["email", "document"], name="email_and_document_unique_together"
)
]
def __str__(self):
return f"{self.email} invited to {self.document}"
def save(self, *args, **kwargs):
"""Make invitations read-only."""
if self.created_at:
raise exceptions.PermissionDenied()
super().save(*args, **kwargs)
self.email_invitation()
def clean(self):
"""Validate fields."""
super().clean()
# Check if an identity already exists for the provided email
if User.objects.filter(email=self.email).exists():
raise exceptions.ValidationError(
{"email": _("This email is already associated to a registered user.")}
)
@property
def is_expired(self):
"""Calculate if invitation is still valid or has expired."""
if not self.created_at:
return None
validity_duration = timedelta(seconds=settings.INVITATION_VALIDITY_DURATION)
return timezone.now() > (self.created_at + validity_duration)
def get_abilities(self, user):
"""Compute and return abilities for a given user."""
can_delete = False
roles = []
if user.is_authenticated:
teams = user.get_teams()
try:
roles = self.user_roles or []
except AttributeError:
try:
roles = self.document.accesses.filter(
models.Q(user=user) | models.Q(team__in=teams),
).values_list("role", flat=True)
except (self._meta.model.DoesNotExist, IndexError):
roles = []
can_delete = bool(
set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
)
return {
"destroy": can_delete,
"update": False,
"partial_update": False,
"retrieve": bool(roles),
}
def email_invitation(self):
"""Email invitation to the user."""
try:
with override(self.issuer.language):
title = _("Invitation to join Impress!")
template_vars = {"title": title, "site": Site.objects.get_current()}
msg_html = render_to_string("mail/html/invitation.html", template_vars)
msg_plain = render_to_string("mail/text/invitation.txt", template_vars)
mail.send_mail(
title,
msg_plain,
settings.EMAIL_FROM,
[self.email],
html_message=msg_html,
fail_silently=False,
)
except smtplib.SMTPException as exception:
logger.error("invitation to %s was not sent: %s", self.email, exception)
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

@@ -0,0 +1,58 @@
"""Custom template tags for the core application of People."""
import base64
from django import template
from django.contrib.staticfiles import finders
from PIL import ImageFile as PillowImageFile
register = template.Library()
def image_to_base64(file_or_path, close=False):
"""
Return the src string of the base64 encoding of an image represented by its path
or file opened or not.
Inspired by Django's "get_image_dimensions"
"""
pil_parser = PillowImageFile.Parser()
if hasattr(file_or_path, "read"):
file = file_or_path
if file.closed and hasattr(file, "open"):
file_or_path.open()
file_pos = file.tell()
file.seek(0)
else:
try:
# pylint: disable=consider-using-with
file = open(file_or_path, "rb")
except OSError:
return ""
close = True
try:
image_data = file.read()
if not image_data:
return ""
pil_parser.feed(image_data)
if pil_parser.image:
mime_type = pil_parser.image.get_format_mimetype()
encoded_string = base64.b64encode(image_data)
return f"data:{mime_type:s};base64, {encoded_string.decode('utf-8'):s}"
return ""
finally:
if close:
file.close()
else:
file.seek(file_pos)
@register.simple_tag
def base64_static(path):
"""Return a static file into a base64."""
full_path = finders.find(path)
if full_path:
return image_to_base64(full_path, True)
return ""
@@ -31,7 +31,7 @@ def test_api_documents_retrieve_anonymous_public():
"accesses": [],
"title": document.title,
"is_public": True,
"content": {"foo": document.content["foo"]},
"content": document.content,
}
@@ -76,7 +76,7 @@ def test_api_documents_retrieve_authenticated_unrelated_public():
"accesses": [],
"title": document.title,
"is_public": True,
"content": {"foo": document.content["foo"]},
"content": document.content,
}
@@ -140,7 +140,7 @@ def test_api_documents_retrieve_authenticated_related_direct():
assert response.json() == {
"id": str(document.id),
"title": document.title,
"content": {"foo": document.content["foo"]},
"content": document.content,
"abilities": document.get_abilities(user),
"is_public": document.is_public,
}
@@ -255,7 +255,7 @@ def test_api_documents_retrieve_authenticated_related_team_members(
assert response.json() == {
"id": str(document.id),
"title": document.title,
"content": {"foo": document.content["foo"]},
"content": document.content,
"abilities": document.get_abilities(user),
"is_public": False,
}
@@ -353,7 +353,7 @@ def test_api_documents_retrieve_authenticated_related_team_administrators(
assert response.json() == {
"id": str(document.id),
"title": document.title,
"content": {"foo": document.content["foo"]},
"content": document.content,
"abilities": document.get_abilities(user),
"is_public": False,
}
@@ -455,7 +455,7 @@ def test_api_documents_retrieve_authenticated_related_team_owners(
assert response.json() == {
"id": str(document.id),
"title": document.title,
"content": {"foo": document.content["foo"]},
"content": document.content,
"abilities": document.get_abilities(user),
"is_public": False,
}
@@ -0,0 +1,494 @@
"""
Unit tests for the Invitation model
"""
import random
import time
import pytest
from rest_framework import status
from rest_framework.test import APIClient
from core import factories, models
from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
def test_api_document_invitations__create__anonymous():
"""Anonymous users should not be able to create invitations."""
document = factories.DocumentFactory()
invitation_values = {
"email": "guest@example.com",
"role": random.choice(models.RoleChoices.choices)[0],
}
response = APIClient().post(
f"/api/v1.0/documents/{document.id}/invitations/",
invitation_values,
format="json",
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_document_invitations__create__authenticated_outsider():
"""Users outside of document should not be permitted to invite to document."""
user = factories.UserFactory()
document = factories.DocumentFactory()
invitation_values = {
"email": "guest@example.com",
"role": random.choice(models.RoleChoices.choices)[0],
}
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id}/invitations/",
invitation_values,
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
@pytest.mark.parametrize(
"inviting,invited,is_allowed",
(
["member", "member", False],
["member", "administrator", False],
["member", "owner", False],
["administrator", "member", True],
["administrator", "administrator", True],
["administrator", "owner", False],
["owner", "member", True],
["owner", "administrator", True],
["owner", "owner", True],
),
)
@pytest.mark.parametrize("via", VIA)
def test_api_document_invitations__create__privileged_members(
via, inviting, invited, is_allowed, mock_user_get_teams
):
"""
Only owners and administrators should be able to invite new users.
Only owners can invite owners.
"""
user = factories.UserFactory()
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=inviting)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=inviting
)
invitation_values = {
"email": "guest@example.com",
"role": invited,
}
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id}/invitations/",
invitation_values,
format="json",
)
if is_allowed:
assert response.status_code == status.HTTP_201_CREATED
assert models.Invitation.objects.count() == 1
else:
assert response.status_code == status.HTTP_403_FORBIDDEN
assert models.Invitation.objects.exists() is False
def test_api_document_invitations__create__issuer_and_document_override():
"""It should not be possible to set the "document" and "issuer" fields."""
user = factories.UserFactory()
document = factories.DocumentFactory(users=[(user, "owner")])
other_document = factories.DocumentFactory(users=[(user, "owner")])
invitation_values = {
"document": str(other_document.id),
"issuer": str(factories.UserFactory().id),
"email": "guest@example.com",
"role": random.choice(models.RoleChoices.choices)[0],
}
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id}/invitations/",
invitation_values,
format="json",
)
assert response.status_code == status.HTTP_201_CREATED
# document and issuer automatically set
assert response.json()["document"] == str(document.id)
assert response.json()["issuer"] == str(user.id)
def test_api_document_invitations__create__cannot_duplicate_invitation():
"""An email should not be invited multiple times to the same document."""
existing_invitation = factories.InvitationFactory()
document = existing_invitation.document
# Grant privileged role on the Document to the user
user = factories.UserFactory()
models.DocumentAccess.objects.create(
document=document, user=user, role="administrator"
)
# Create a new invitation to the same document with the exact same email address
invitation_values = {
"email": existing_invitation.email,
"role": random.choice(["administrator", "member"]),
}
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id}/invitations/",
invitation_values,
format="json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json()["__all__"] == [
"Document invitation with this Email address and Document already exists."
]
def test_api_document_invitations__create__cannot_invite_existing_users():
"""
It should not be possible to invite already existing users.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(users=[(user, "owner")])
existing_user = factories.UserFactory()
# Build an invitation to the email of an exising identity in the db
invitation_values = {
"email": existing_user.email,
"role": random.choice(models.RoleChoices.choices)[0],
}
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id}/invitations/",
invitation_values,
format="json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json()["email"] == [
"This email is already associated to a registered user."
]
def test_api_document_invitations__list__anonymous_user():
"""Anonymous users should not be able to list invitations."""
document = factories.DocumentFactory()
response = APIClient().get(f"/api/v1.0/documents/{document.id}/invitations/")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@pytest.mark.parametrize("via", VIA)
def test_api_document_invitations__list__authenticated(
via, mock_user_get_teams, django_assert_num_queries
):
"""
Authenticated users should be able to list invitations for documents to which they are
related, whatever the role and including invitations issued by other users.
"""
user = factories.UserFactory()
other_user = factories.UserFactory()
document = factories.DocumentFactory()
role = random.choice(models.RoleChoices.choices)[0]
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=role
)
invitation = factories.InvitationFactory(
document=document, role="administrator", issuer=user
)
other_invitations = factories.InvitationFactory.create_batch(
2, document=document, role="member", issuer=other_user
)
# invitations from other documents should not be listed
other_document = factories.DocumentFactory()
factories.InvitationFactory.create_batch(2, document=other_document, role="member")
client = APIClient()
client.force_login(user)
with django_assert_num_queries(3):
response = client.get(
f"/api/v1.0/documents/{document.id}/invitations/",
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["count"] == 3
assert sorted(response.json()["results"], key=lambda x: x["created_at"]) == sorted(
[
{
"id": str(i.id),
"created_at": i.created_at.isoformat().replace("+00:00", "Z"),
"email": str(i.email),
"document": str(document.id),
"role": i.role,
"issuer": str(i.issuer.id),
"is_expired": False,
"abilities": {
"destroy": role in ["administrator", "owner"],
"update": False,
"partial_update": False,
"retrieve": True,
},
}
for i in [invitation, *other_invitations]
],
key=lambda x: x["created_at"],
)
def test_api_document_invitations__list__expired_invitations_still_listed(settings):
"""
Expired invitations are still listed.
"""
user = factories.UserFactory()
other_user = factories.UserFactory()
document = factories.DocumentFactory(
users=[(user, "administrator"), (other_user, "owner")]
)
# override settings to accelerate validation expiration
settings.INVITATION_VALIDITY_DURATION = 1 # second
expired_invitation = factories.InvitationFactory(
document=document,
role="member",
issuer=user,
)
time.sleep(1)
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/documents/{document.id}/invitations/",
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["count"] == 1
assert sorted(response.json()["results"], key=lambda x: x["created_at"]) == sorted(
[
{
"id": str(expired_invitation.id),
"created_at": expired_invitation.created_at.isoformat().replace(
"+00:00", "Z"
),
"email": str(expired_invitation.email),
"document": str(document.id),
"role": expired_invitation.role,
"issuer": str(expired_invitation.issuer.id),
"is_expired": True,
"abilities": {
"destroy": True,
"update": False,
"partial_update": False,
"retrieve": True,
},
},
],
key=lambda x: x["created_at"],
)
def test_api_document_invitations__retrieve__anonymous_user():
"""
Anonymous users should not be able to retrieve invitations.
"""
invitation = factories.InvitationFactory()
response = APIClient().get(
f"/api/v1.0/documents/{invitation.document.id}/invitations/{invitation.id}/",
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_api_document_invitations__retrieve__unrelated_user():
"""
Authenticated unrelated users should not be able to retrieve invitations.
"""
user = factories.UserFactory()
invitation = factories.InvitationFactory()
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/documents/{invitation.document.id!s}/invitations/{invitation.id!s}/",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
@pytest.mark.parametrize("via", VIA)
def test_api_document_invitations__retrieve__document_member(via, mock_user_get_teams):
"""
Authenticated users related to the document should be able to retrieve invitations
whatever their role in the document.
"""
user = factories.UserFactory()
invitation = factories.InvitationFactory()
role = random.choice(models.RoleChoices.choices)[0]
if via == USER:
factories.UserDocumentAccessFactory(
document=invitation.document, user=user, role=role
)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=invitation.document, team="lasuite", role=role
)
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/documents/{invitation.document.id}/invitations/{invitation.id}/",
)
assert response.status_code == status.HTTP_200_OK
assert response.json() == {
"id": str(invitation.id),
"created_at": invitation.created_at.isoformat().replace("+00:00", "Z"),
"email": invitation.email,
"document": str(invitation.document.id),
"role": str(invitation.role),
"issuer": str(invitation.issuer.id),
"is_expired": False,
"abilities": {
"destroy": role in ["administrator", "owner"],
"update": False,
"partial_update": False,
"retrieve": True,
},
}
@pytest.mark.parametrize("via", VIA)
@pytest.mark.parametrize(
"method",
["put", "patch"],
)
def test_api_document_invitations__update__forbidden(method, via, mock_user_get_teams):
"""
Update of invitations is currently forbidden.
"""
user = factories.UserFactory()
invitation = factories.InvitationFactory()
if via == USER:
factories.UserDocumentAccessFactory(
document=invitation.document, user=user, role="owner"
)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=invitation.document, team="lasuite", role="owner"
)
client = APIClient()
client.force_login(user)
url = f"/api/v1.0/documents/{invitation.document.id}/invitations/{invitation.id}/"
if method == "put":
response = client.put(url)
if method == "patch":
response = client.patch(url)
assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED
assert response.json()["detail"] == f'Method "{method.upper()}" not allowed.'
def test_api_document_invitations__delete__anonymous():
"""Anonymous user should not be able to delete invitations."""
invitation = factories.InvitationFactory()
response = APIClient().delete(
f"/api/v1.0/documents/{invitation.document.id}/invitations/{invitation.id}/",
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_api_document_invitations__delete__authenticated_outsider():
"""Members unrelated to a document should not be allowed to cancel invitations."""
user = factories.UserFactory()
document = factories.DocumentFactory()
invitation = factories.InvitationFactory(document=document)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/documents/{document.id}/invitations/{invitation.id}/",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
@pytest.mark.parametrize("via", VIA)
@pytest.mark.parametrize("role", ["owner", "administrator"])
def test_api_document_invitations__delete__privileged_members(
role, via, mock_user_get_teams
):
"""Privileged member should be able to cancel invitation."""
user = factories.UserFactory()
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=role
)
invitation = factories.InvitationFactory(document=document)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/documents/{document.id}/invitations/{invitation.id}/",
)
assert response.status_code == status.HTTP_204_NO_CONTENT
@pytest.mark.parametrize("via", VIA)
def test_api_document_invitations__delete__members(via, mock_user_get_teams):
"""Member should not be able to cancel invitation."""
user = factories.UserFactory()
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role="member")
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="member"
)
invitation = factories.InvitationFactory(document=document)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/documents/{document.id}/invitations/{invitation.id}/",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert (
response.json()["detail"]
== "You do not have permission to perform this action."
)
@@ -128,7 +128,7 @@ def test_api_document_versions_list_authenticated_related(via, mock_user_get_tea
assert len(content["versions"]) == 0
# Add a new version to the document
document.content = {"foo": "bar"}
document.content = "new content"
document.save()
response = client.get(
@@ -243,7 +243,7 @@ def test_api_document_versions_retrieve_authenticated_related(via, mock_user_get
# Create a new version should make it available to the user
time.sleep(1) # minio stores datetimes with the precision of a second
document.content = {"foo": "bar"}
document.content = "new content"
document.save()
version_id = document.get_versions_slice()["versions"][0]["version_id"]
@@ -253,7 +253,7 @@ def test_api_document_versions_retrieve_authenticated_related(via, mock_user_get
)
assert response.status_code == 200
assert response.json()["content"] == {"foo": "bar"}
assert response.json()["content"] == "new content"
def test_api_document_versions_create_anonymous():
@@ -459,7 +459,7 @@ def test_api_document_versions_delete_member(via, mock_user_get_teams):
# Create a new version should make it available to the user
time.sleep(1) # minio stores datetimes with the precision of a second
document.content = {"foo": "bar"}
document.content = "new content"
document.save()
versions = document.get_versions_slice()["versions"]
@@ -503,7 +503,7 @@ def test_api_document_versions_delete_administrator_or_owner(via, mock_user_get_
# Create a new version should make it available to the user
time.sleep(1) # minio stores datetimes with the precision of a second
document.content = {"foo": "bar"}
document.content = "new content"
document.save()
versions = document.get_versions_slice()["versions"]
@@ -196,7 +196,7 @@ def test_models_documents_get_versions_slice(settings):
# Create a document with 7 versions
document = factories.DocumentFactory()
for i in range(6):
document.content = {"foo": f"bar{i:d}"}
document.content = f"bar{i:d}"
document.save()
# Add a version not related to the first document
@@ -246,7 +246,7 @@ def test_models_documents_version_duplicate():
assert len(response["Versions"]) == 1
# Save modified content
document.content = {"foo": "spam"}
document.content = "new content"
document.save()
response = default_storage.connection.meta.client.list_object_versions(
@@ -0,0 +1,312 @@
"""
Unit tests for the Invitation model
"""
import smtplib
import time
from logging import Logger
from unittest import mock
from django.contrib.auth.models import AnonymousUser
from django.core import exceptions, mail
import pytest
from faker import Faker
from freezegun import freeze_time
from core import factories, models
from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
fake = Faker()
def test_models_invitations_readonly_after_create():
"""Existing invitations should be readonly."""
invitation = factories.InvitationFactory()
with pytest.raises(exceptions.PermissionDenied):
invitation.save()
def test_models_invitations_email_no_empty_mail():
"""The "email" field should not be empty."""
with pytest.raises(exceptions.ValidationError, match="This field cannot be blank"):
factories.InvitationFactory(email="")
def test_models_invitations_email_no_null_mail():
"""The "email" field is required."""
with pytest.raises(exceptions.ValidationError, match="This field cannot be null"):
factories.InvitationFactory(email=None)
def test_models_invitations_document_required():
"""The "document" field is required."""
with pytest.raises(exceptions.ValidationError, match="This field cannot be null"):
factories.InvitationFactory(document=None)
def test_models_invitations_document_should_be_document_instance():
"""The "document" field should be a document instance."""
with pytest.raises(
ValueError, match='Invitation.document" must be a "Document" instance'
):
factories.InvitationFactory(document="ee")
def test_models_invitations_role_required():
"""The "role" field is required."""
with pytest.raises(exceptions.ValidationError, match="This field cannot be blank"):
factories.InvitationFactory(role="")
def test_models_invitations_role_among_choices():
"""The "role" field should be a valid choice."""
with pytest.raises(
exceptions.ValidationError, match="Value 'boss' is not a valid choice"
):
factories.InvitationFactory(role="boss")
def test_models_invitations__is_expired(settings):
"""
The 'is_expired' property should return False until validity duration
is exceeded and True afterwards.
"""
expired_invitation = factories.InvitationFactory()
assert expired_invitation.is_expired is False
settings.INVITATION_VALIDITY_DURATION = 1
time.sleep(1)
assert expired_invitation.is_expired is True
def test_models_invitation__new_user__convert_invitations_to_accesses():
"""
Upon creating a new user, invitations linked to the email
should be converted to accesses and then deleted.
"""
# Two invitations to the same mail but to different documents
invitation_to_document1 = factories.InvitationFactory()
invitation_to_document2 = factories.InvitationFactory(
email=invitation_to_document1.email
)
other_invitation = factories.InvitationFactory(
document=invitation_to_document2.document
) # another person invited to document2
new_user = factories.UserFactory(email=invitation_to_document1.email)
# The invitation regarding
assert models.DocumentAccess.objects.filter(
document=invitation_to_document1.document, user=new_user
).exists()
assert models.DocumentAccess.objects.filter(
document=invitation_to_document2.document, user=new_user
).exists()
assert not models.Invitation.objects.filter(
document=invitation_to_document1.document, email=invitation_to_document1.email
).exists() # invitation "consumed"
assert not models.Invitation.objects.filter(
document=invitation_to_document2.document, email=invitation_to_document2.email
).exists() # invitation "consumed"
assert models.Invitation.objects.filter(
document=invitation_to_document2.document, email=other_invitation.email
).exists() # the other invitation remains
def test_models_invitation__new_user__filter_expired_invitations():
"""
Upon creating a new identity, valid invitations should be converted into accesses
and expired invitations should remain unchanged.
"""
document = factories.DocumentFactory()
with freeze_time("2020-01-01"):
expired_invitation = factories.InvitationFactory(document=document)
user_email = expired_invitation.email
valid_invitation = factories.InvitationFactory(email=user_email)
new_user = factories.UserFactory(email=user_email)
# valid invitation should have granted access to the related document
assert models.DocumentAccess.objects.filter(
document=valid_invitation.document, user=new_user
).exists()
assert not models.Invitation.objects.filter(
document=valid_invitation.document, email=user_email
).exists()
# expired invitation should not have been consumed
assert not models.DocumentAccess.objects.filter(
document=expired_invitation.document, user=new_user
).exists()
assert models.Invitation.objects.filter(
document=expired_invitation.document, email=user_email
).exists()
@pytest.mark.parametrize("num_invitations, num_queries", [(0, 3), (1, 6), (20, 6)])
def test_models_invitation__new_user__user_creation_constant_num_queries(
django_assert_num_queries, num_invitations, num_queries
):
"""
The number of queries executed during user creation should not be proportional
to the number of invitations being processed.
"""
user_email = fake.email()
if num_invitations != 0:
factories.InvitationFactory.create_batch(num_invitations, email=user_email)
# with no invitation, we skip an "if", resulting in 8 requests
# otherwise, we should have 11 queries with any number of invitations
with django_assert_num_queries(num_queries):
models.User.objects.create(email=user_email, password="!")
def test_models_document_invitations_email():
"""Check email invitation during invitation creation."""
member_access = factories.UserDocumentAccessFactory(role="member")
document = member_access.document
# pylint: disable-next=no-member
assert len(mail.outbox) == 0
factories.UserDocumentAccessFactory(document=document)
invitation = factories.InvitationFactory(document=document, email="john@people.com")
# pylint: disable-next=no-member
assert len(mail.outbox) == 1
# pylint: disable-next=no-member
email = mail.outbox[0]
assert email.to == [invitation.email]
assert email.subject == "Invitation to join Impress!"
email_content = " ".join(email.body.split())
assert "Invitation to join Impress!" in email_content
assert "[//example.com]" in email_content
@mock.patch(
"django.core.mail.send_mail",
side_effect=smtplib.SMTPException("Error SMTPException"),
)
@mock.patch.object(Logger, "error")
def test_models_document_invitations_email_failed(mock_logger, _mock_send_mail):
"""Check invitation behavior when an SMTP error occurs during invitation creation."""
member_access = factories.UserDocumentAccessFactory(role="member")
document = member_access.document
# pylint: disable-next=no-member
assert len(mail.outbox) == 0
factories.UserDocumentAccessFactory(document=document)
# No error should be raised
invitation = factories.InvitationFactory(document=document, email="john@people.com")
# No email has been sent
# pylint: disable-next=no-member
assert len(mail.outbox) == 0
# Logger should be called
mock_logger.assert_called_once()
(
_,
email,
exception,
) = mock_logger.call_args.args
assert email == invitation.email
assert isinstance(exception, smtplib.SMTPException)
# get_abilities
def test_models_document_invitations_get_abilities_anonymous():
"""Check abilities returned for an anonymous user."""
access = factories.InvitationFactory()
abilities = access.get_abilities(AnonymousUser())
assert abilities == {
"destroy": False,
"retrieve": False,
"partial_update": False,
"update": False,
}
def test_models_document_invitations_get_abilities_authenticated():
"""Check abilities returned for an authenticated user."""
access = factories.InvitationFactory()
user = factories.UserFactory()
abilities = access.get_abilities(user)
assert abilities == {
"destroy": False,
"retrieve": False,
"partial_update": False,
"update": False,
}
@pytest.mark.parametrize("via", VIA)
@pytest.mark.parametrize("role", ["administrator", "owner"])
def test_models_document_invitations_get_abilities_privileged_member(
role, via, mock_user_get_teams
):
"""Check abilities for a document member with a privileged role."""
user = factories.UserFactory()
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=role
)
factories.UserDocumentAccessFactory(document=document) # another one
invitation = factories.InvitationFactory(document=document)
abilities = invitation.get_abilities(user)
assert abilities == {
"destroy": True,
"retrieve": True,
"partial_update": False,
"update": False,
}
@pytest.mark.parametrize("via", VIA)
def test_models_document_invitations_get_abilities_member(via, mock_user_get_teams):
"""Check abilities for a document member with 'member' role."""
user = factories.UserFactory()
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role="member")
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="member"
)
invitation = factories.InvitationFactory(document=document)
abilities = invitation.get_abilities(user)
assert abilities == {
"destroy": False,
"retrieve": True,
"partial_update": False,
"update": False,
}
+6
View File
@@ -20,6 +20,12 @@ document_related_router.register(
viewsets.DocumentAccessViewSet,
basename="document_accesses",
)
document_related_router.register(
"invitations",
viewsets.InvitationViewset,
basename="invitations",
)
# - Routes nested under a template
template_related_router = DefaultRouter()
+1
View File
@@ -281,6 +281,7 @@ class Base(Configuration):
EMAIL_FROM = values.Value("from@example.com")
AUTH_USER_MODEL = "core.User"
INVITATION_VALIDITY_DURATION = 604800 # 7 days, in seconds
# CORS
CORS_ALLOW_CREDENTIALS = True
+1
View File
@@ -42,6 +42,7 @@ dependencies = [
"dockerflow==2022.8.0",
"easy_thumbnails==2.8.5",
"factory_boy==3.3.0",
"freezegun==1.5.0",
"gunicorn==22.0.0",
"jsonschema==4.20.0",
"markdown==3.5.1",
@@ -138,4 +138,63 @@ test.describe('Pad Editor', () => {
await expect(page.getByText('Hello World Pad 2')).toBeHidden();
await expect(page.getByText('Hello World Pad 1')).toBeVisible();
});
test('it saves the doc when we change pages', async ({
page,
browserName,
}) => {
const [pad] = await createPad(page, 'pad-save-page', browserName, 1);
const panel = page.getByLabel('Pads panel').first();
// Check the first pad
await panel.getByText(pad).click();
await expect(page.locator('h2').getByText(pad)).toBeVisible();
await page.locator('.ProseMirror.bn-editor').click();
await page
.locator('.ProseMirror.bn-editor')
.fill('Hello World Pad persisted 1');
await expect(page.getByText('Hello World Pad persisted 1')).toBeVisible();
await panel
.getByRole('button', {
name: 'Add a pad',
})
.click();
const card = page.getByLabel('Create new pad card').first();
await expect(
card.getByRole('heading', {
name: 'Name the pad',
level: 3,
}),
).toBeVisible();
await page.goto('/');
await panel.getByText(pad).click();
await expect(page.getByText('Hello World Pad persisted 1')).toBeVisible();
});
test('it saves the doc when we quit pages', async ({ page, browserName }) => {
const [pad] = await createPad(page, 'pad-save-quit', browserName, 1);
const panel = page.getByLabel('Pads panel').first();
// Check the first pad
await panel.getByText(pad).click();
await expect(page.locator('h2').getByText(pad)).toBeVisible();
await page.locator('.ProseMirror.bn-editor').click();
await page
.locator('.ProseMirror.bn-editor')
.fill('Hello World Pad persisted 2');
await expect(page.getByText('Hello World Pad persisted 2')).toBeVisible();
await page.goto('/');
await panel.getByText(pad).click();
await expect(page.getByText('Hello World Pad persisted 2')).toBeVisible();
});
});
@@ -0,0 +1,45 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { Pad } from '@/features/pads';
export type PadParams = Pick<Pad, 'id'> &
Partial<Pick<Pad, 'content' | 'title' | 'is_public'>>;
export const updatePad = async ({ id, ...params }: PadParams): Promise<Pad> => {
const response = await fetchAPI(`documents/${id}/`, {
method: 'PATCH',
body: JSON.stringify({
...params,
}),
});
if (!response.ok) {
throw new APIError('Failed to update the pad', await errorCauses(response));
}
return response.json() as Promise<Pad>;
};
interface UpdatePadProps {
onSuccess?: (data: Pad) => void;
listInvalideQueries?: string[];
}
export function useUpdatePad({
onSuccess,
listInvalideQueries,
}: UpdatePadProps = {}) {
const queryClient = useQueryClient();
return useMutation<Pad, APIError, PadParams>({
mutationFn: updatePad,
onSuccess: (data) => {
listInvalideQueries?.forEach((queryKey) => {
void queryClient.invalidateQueries({
queryKey: [queryKey],
});
});
onSuccess?.(data);
},
});
}
@@ -7,6 +7,7 @@ import { WebrtcProvider } from 'y-webrtc';
import { Box } from '@/components';
import { useAuthStore } from '@/core/auth';
import useSavePad from '../hook/useSavePad';
import { usePadStore } from '../stores';
import { Pad } from '../types';
import { randomColor } from '../utils';
@@ -22,7 +23,7 @@ export const BlockNoteEditor = ({ pad }: BlockNoteEditorProps) => {
const provider = padsStore?.[pad.id]?.provider;
if (!provider) {
createProvider(pad.id);
createProvider(pad.id, pad.content);
return null;
}
@@ -37,6 +38,7 @@ interface BlockNoteContentProps {
export const BlockNoteContent = ({ pad, provider }: BlockNoteContentProps) => {
const { userData } = useAuthStore();
const { setEditor, padsStore } = usePadStore();
useSavePad(pad.id, provider.doc);
const storedEditor = padsStore?.[pad.id]?.editor;
const editor = useMemo(() => {
@@ -0,0 +1,83 @@
import { useRouter } from 'next/router';
import { useCallback, useEffect, useRef, useState } from 'react';
import * as Y from 'yjs';
import { useUpdatePad } from '../api/useUpdatePad';
import { toBase64 } from '../utils';
const useSavePad = (padId: string, doc: Y.Doc) => {
const { mutate: updatePad } = useUpdatePad();
const [initialDoc, setInitialDoc] = useState<string>(
toBase64(Y.encodeStateAsUpdate(doc)),
);
/**
* Update initial doc when doc is updated by other users,
* so only the user typing will trigger the save.
* This is to avoid saving the same doc multiple time.
*/
useEffect(() => {
const onUpdate = (
_uintArray: Uint8Array,
_pluginKey: string,
updatedDoc: Y.Doc,
transaction: Y.Transaction,
) => {
if (!transaction.local) {
setInitialDoc(toBase64(Y.encodeStateAsUpdate(updatedDoc)));
}
};
doc.on('update', onUpdate);
return () => {
doc.off('update', onUpdate);
};
}, [doc]);
const savePad = useCallback(() => {
const newDoc = toBase64(Y.encodeStateAsUpdate(doc));
/**
* Save only if the doc has changed.
*/
if (initialDoc === newDoc) {
return;
}
setInitialDoc(newDoc);
updatePad({
id: padId,
content: newDoc,
});
}, [initialDoc, padId, doc, updatePad]);
const timeout = useRef<NodeJS.Timeout>();
const router = useRouter();
useEffect(() => {
if (timeout.current) {
clearTimeout(timeout.current);
}
const onSave = () => {
savePad();
};
// Save every minute
timeout.current = setInterval(onSave, 60000);
// Save when the user leaves the page
addEventListener('beforeunload', onSave);
// Save when the user navigates to another page
router.events.on('routeChangeStart', onSave);
return () => {
clearTimeout(timeout.current);
removeEventListener('beforeunload', onSave);
router.events.off('routeChangeStart', onSave);
};
}, [router.events, savePad]);
};
export default useSavePad;
@@ -5,7 +5,7 @@ import { create } from 'zustand';
import { signalingUrl } from '@/core';
import { Pad } from '../types';
import { Base64, Pad } from '../types';
export interface PadStore {
padsStore: {
@@ -14,7 +14,7 @@ export interface PadStore {
editor?: BlockNoteEditor;
};
};
createProvider: (padId: Pad['id']) => WebrtcProvider;
createProvider: (padId: Pad['id'], initialDoc: Base64) => WebrtcProvider;
setEditor: (padId: Pad['id'], editor: BlockNoteEditor) => void;
}
@@ -24,8 +24,14 @@ const initialState = {
export const usePadStore = create<PadStore>((set) => ({
padsStore: initialState.padsStore,
createProvider: (padId: string) => {
const provider = new WebrtcProvider(padId, new Y.Doc(), {
createProvider: (padId: string, initialDoc: Base64) => {
const doc = new Y.Doc();
if (initialDoc) {
Y.applyUpdate(doc, Buffer.from(initialDoc, 'base64'));
}
const provider = new WebrtcProvider(padId, doc, {
signaling: [signalingUrl()],
});
@@ -17,9 +17,13 @@ export enum Role {
OWNER = 'owner',
}
export type Base64 = string;
export interface Pad {
id: string;
title: string;
content: Base64;
is_public: boolean;
accesses: Access[];
created_at: string;
updated_at: string;
@@ -22,3 +22,7 @@ function hslToHex(h: number, s: number, l: number) {
};
return `#${f(0)}${f(8)}${f(4)}`;
}
export const toBase64 = (
str: WithImplicitCoercion<ArrayBuffer | SharedArrayBuffer>,
) => Buffer.from(str).toString('base64');
@@ -3,10 +3,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { KEY_LIST_PAD, Pad } from '@/features/pads';
type CreatePadParam = {
title: string;
is_public: boolean;
};
type CreatePadParam = Pick<Pad, 'title' | 'is_public'>;
export const createPad = async ({
title,
@@ -15,7 +15,7 @@ const Page: NextPageWithLayout = () => {
} = useRouter();
if (typeof id !== 'string') {
throw new Error('Invalid pad id');
return null;
}
return <Pad id={id} />;
@@ -1,7 +1,7 @@
image:
repository: lasuite/impress-backend
pullPolicy: Always
tag: "main"
tag: "v0.1.0"
backend:
migrateJobAnnotations:
@@ -115,13 +115,13 @@ frontend:
image:
repository: lasuite/impress-frontend
pullPolicy: Always
tag: "main"
tag: "v0.1.0"
webrtc:
image:
repository: lasuite/impress-y-webrtc-signaling
pullPolicy: Always
tag: "main"
tag: "v0.1.0"
ingress:
enabled: true
-28
View File
@@ -1,28 +0,0 @@
<mjml>
<mj-include path="./partial/header.mjml" />
<mj-body mj-class="bg--blue-100">
<mj-wrapper css-class="wrapper" padding="20px 40px 40px 40px">
<mj-section>
<mj-column>
<mj-image src="{% base64_static 'impress/images/logo_impress.png' %}" width="200px" align="left" alt="{%trans 'Company logo' %}" />
</mj-column>
</mj-section>
<mj-section mj-class="bg--blue-100" border-radius="6px 6px 0 0" padding="30px 50px 60px 50px">
<mj-column>
<mj-text padding="0">
<p>
{%if fullname%}
{% blocktranslate with name=fullname %}Hello {{ name }}{% endblocktranslate %}
{% else %}
{%trans "Hello" %}
{% endif %}<br/>
<strong>{%trans "Thank you very much for your visit!"%}</strong>
</p>
</mj-text>
</mj-column>
</mj-section>
</mj-wrapper>
<mj-include path="./partial/footer.mjml" />
</mj-body>
</mjml>
+54
View File
@@ -0,0 +1,54 @@
<mjml>
<mj-include path="./partial/header.mjml" />
<mj-body mj-class="bg--blue-100">
<mj-wrapper css-class="wrapper" padding="0 40px 40px 40px">
<mj-section background-url="{% base64_static 'images/mail-header-background.png' %}" background-size="cover" background-repeat="no-repeat" background-position="0 -30px">
<mj-column>
<mj-image align="center" src="{% base64_static 'images/logo-suite-numerique.png' %}" width="250px" align="left" alt="{%trans 'La Suite Numérique' %}" />
</mj-column>
</mj-section>
<mj-section mj-class="bg--white-100" padding="30px 20px 60px 20px">
<mj-column>
<mj-text font-size="14px">
<p>{% trans "Invitation to join a team" %}</p>
</mj-text>
<!-- Welcome Message -->
<mj-text>
<h1>{% blocktrans %}Welcome to <strong>Impress</strong>{% endblocktrans %}</h1>
</mj-text>
<mj-divider border-width="1px" border-style="solid" border-color="#DDDDDD" width="30%" align="left"/>
<mj-image src="{% base64_static 'images/logo.svg' %}" width="157px" align="left" alt="{%trans 'Logo' %}" />
<!-- Main Message -->
<mj-text>{% trans "We are delighted to welcome you to our community on Impress, your new companion to collaborate on documents efficiently, intuitively, and securely." %}</mj-text>
<mj-text>{% trans "Our application is designed to help you organize, collaborate, and manage permissions." %}</mj-text>
<mj-text>
{% trans "With Impress, you will be able to:" %}
<ul>
<li>{% trans "Create documents."%}</li>
<li>{% trans "Invite members of your document or community in just a few clicks."%}</li>
</ul>
</mj-text>
<mj-button href="//{{site.domain}}" background-color="#000091" color="white" padding-bottom="30px">
{% trans "Visit Impress"%}
</mj-button>
<mj-text>{% trans "We are confident that Impress will help you increase efficiency and productivity while strengthening the bond among members." %}</mj-text>
<mj-text>{% trans "Feel free to explore all the features of the application and share your feedback and suggestions with us. Your feedback is valuable to us and will enable us to continually improve our service." %}</mj-text>
<mj-text>{% trans "Once again, welcome aboard! We are eager to accompany you on you collaboration adventure." %}</mj-text>
<!-- Signature -->
<mj-text>
<p>{% trans "Sincerely," %}</p>
<p>{% trans "The La Suite Numérique Team" %}</p>
</mj-text>
</mj-column>
</mj-section>
</mj-wrapper>
</mj-body>
<mj-include path="./partial/footer.mjml" />
</mjml>
+2 -4
View File
@@ -14,10 +14,8 @@
font-family="Roboto, -apple-system, BlinkMacSystemFont, 'Segoe UI', Oxygen, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif"
font-size="16px"
line-height="1.5em"
color="#031963"
color="#3A3A3A"
/>
<mj-class name="text--small" font-size="0.875rem" />
<mj-class name="bg--blue-100" background-color="#EDF5FA" />
</mj-attributes>
<mj-style>
/* Reset */
@@ -33,7 +31,7 @@
<mj-style>
/* Global styles */
h1 {
color: #055FD2;
color: #161616;
font-size: 2rem;
line-height: 1em;
font-weight: 700;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mail_mjml",
"version": "1.1.0",
"version": "0.1.0",
"description": "An util to generate html and text django's templates from mjml templates",
"type": "module",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "impress-openapi-client-ts",
"version": "1.1.0",
"version": "0.1.0",
"private": true,
"description": "Tool to generate Typescript API client for the impress application.",
"scripts": {