Compare commits

..

4 Commits

Author SHA1 Message Date
Manuel Raynaud
4e1a9b6a27 📝(contributing) add signed commits paragraph
Add a paragraph to inform that only signed commits are accepted now.
2025-04-14 18:14:41 +02:00
Anthony LC
ecd06560c6 🚚(frontend) Display homepage on /home url
The homepage is now accessible at the /home URL.
Before the homepage was accessible on the /login URL.
We still keep the /login URL for backward compatibility.
2025-04-13 13:25:40 +02:00
Anthony LC
e9ab099ce0 🚩(frontend) integrate homepage feature flag
If the homepage feature flag is enabled,
the homepage will be displayed.
2025-04-13 13:25:40 +02:00
Anthony LC
67b69d05f7 🚩(backend) add homepage feature flag
Add a homepage feature flag that we will
propagate to the frontend.
It will be used to enable or disable the
homepage at runtime.
2025-04-13 13:25:40 +02:00
25 changed files with 376 additions and 424 deletions

View File

@@ -8,11 +8,15 @@ and this project adheres to
## [Unreleased]
## Added
- 🚩 add homepage feature flag #861
## [3.1.0] - 2025-04-07
## Added
- ✨(backend) add ancestors links definitions to document abilities #846
- 🚩(backend) add feature flag for the footer #841
- 🔧(backend) add view to manage footer json #841
- ✨(frontend) add custom css style #771
@@ -24,7 +28,6 @@ and this project adheres to
## Fixed
- 🐛(backend) fix link definition select options linked to ancestors #846
- 🐛(back) validate document content in serializer #822
- 🐛(frontend) fix selection click past end of content #840

View File

@@ -48,6 +48,10 @@ All commit messages must adhere to the following format:
Implemented login and signup features, and integrated OAuth2 for social login.
```
## Signing commits
Only signed commits are accepted. They can be signed using a SSH or GPG key. Github documentation about signing commits contains all the information you need : https://docs.github.com/en/authentication/managing-commit-signature-verification/about-commit-signature-verification#about-commit-signature-verification
## Changelog Update
Please add a line to the changelog describing your development. The changelog entry should include a brief summary of the changes, this helps in tracking changes effectively and keeping everyone informed. We usually include the title of the pull request, followed by the pull request ID to finish the log entry. The changelog line should be less than 80 characters in total.

View File

@@ -47,6 +47,11 @@ These are the environmental variables you can set for the impress-backend contai
| COLLABORATION_API_URL | collaboration api host | |
| COLLABORATION_SERVER_SECRET | collaboration api secret | |
| COLLABORATION_WS_URL | collaboration websocket url | |
| FRONTEND_CSS_URL | To add a external css file to the app | |
| FRONTEND_HOMEPAGE_FEATURE_ENABLED | frontend feature flag to display the homepage | false |
| FRONTEND_FOOTER_FEATURE_ENABLED | frontend feature flag to display the footer | false |
| FRONTEND_FOOTER_VIEW_CACHE_TIMEOUT | Cache duration of the json footer | 86400 |
| FRONTEND_URL_JSON_FOOTER | Url with a json to configure the footer | |
| FRONTEND_THEME | frontend theme to use | |
| POSTHOG_KEY | posthog key for analytics | |
| CRISP_WEBSITE_ID | crisp website id for support | |

View File

@@ -64,5 +64,6 @@ COLLABORATION_WS_URL=ws://localhost:4444/collaboration/ws/
# Frontend
FRONTEND_THEME=default
FRONTEND_HOMEPAGE_FEATURE_ENABLED=True
FRONTEND_FOOTER_FEATURE_ENABLED=True
FRONTEND_URL_JSON_FOOTER=http://frontend:3000/contents/footer-demo.json

View File

@@ -1,8 +1,5 @@
"""API filters for Impress' core application."""
import unicodedata
from django.db.models import CharField, Func
from django.utils.translation import gettext_lazy as _
import django_filters
@@ -10,64 +7,12 @@ import django_filters
from core import models
def remove_accents(value):
"""Remove accents from a string (vélo -> velo)."""
return "".join(
c
for c in unicodedata.normalize("NFD", value)
if unicodedata.category(c) != "Mn"
)
# pylint: disable=abstract-method
class Unaccent(Func):
"""
PostgreSQL unaccent function wrapper for use in Django ORM queries.
This allows you to annotate a field using the unaccented version of a
text column, enabling accent-insensitive filtering.
"""
function = "unaccent"
template = "unaccent(%(expressions)s::text)"
output_field = CharField()
class AccentInsensitiveCharFilter(django_filters.CharFilter):
"""
A custom CharFilter that performs case-insensitive and accent-insensitive filtering.
This filter uses PostgreSQL's extension `unaccent` function to remove diacritics (accents)
from characters before applying the lookup expression (e.g., `icontains`).
"""
def filter(self, qs, value):
"""
Apply the filter to the queryset using the unaccented version of the field.
Args:
qs: The queryset to filter.
value: The value to search for in the unaccented field.
Returns:
A filtered queryset.
"""
if value:
value = remove_accents(value)
field_name = self.field_name
annotated_field = f"unaccented_{field_name}"
return qs.annotate(**{annotated_field: Unaccent(field_name)}).filter(
**{f"{annotated_field}__{self.lookup_expr}": value}
)
return qs
class DocumentFilter(django_filters.FilterSet):
"""
Custom filter for filtering documents on title (accent and case insensitive).
Custom filter for filtering documents.
"""
title = AccentInsensitiveCharFilter(
title = django_filters.CharFilter(
field_name="title", lookup_expr="icontains", label=_("Title")
)

View File

@@ -97,7 +97,7 @@ class BaseAccessSerializer(serializers.ModelSerializer):
if not self.Meta.model.objects.filter( # pylint: disable=no-member
Q(user=user) | Q(team__in=user.teams),
role__in=models.PRIVILEGED_ROLES,
role__in=[models.RoleChoices.OWNER, models.RoleChoices.ADMIN],
**{self.Meta.resource_field_name: resource_id}, # pylint: disable=no-member
).exists():
raise exceptions.PermissionDenied(
@@ -124,10 +124,6 @@ class BaseAccessSerializer(serializers.ModelSerializer):
class DocumentAccessSerializer(BaseAccessSerializer):
"""Serialize document accesses."""
document_id = serializers.PrimaryKeyRelatedField(
read_only=True,
source="document",
)
user_id = serializers.PrimaryKeyRelatedField(
queryset=models.User.objects.all(),
write_only=True,
@@ -140,11 +136,11 @@ class DocumentAccessSerializer(BaseAccessSerializer):
class Meta:
model = models.DocumentAccess
resource_field_name = "document"
fields = ["id", "document_id", "user", "user_id", "team", "role", "abilities"]
read_only_fields = ["id", "document_id", "abilities"]
fields = ["id", "user", "user_id", "team", "role", "abilities"]
read_only_fields = ["id", "abilities"]
class DocumentAccessLightSerializer(BaseAccessSerializer):
class DocumentAccessLightSerializer(DocumentAccessSerializer):
"""Serialize document accesses with limited fields."""
user = UserLightSerializer(read_only=True)

View File

@@ -7,6 +7,7 @@ from urllib.parse import unquote, urlparse
from django.conf import settings
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.contrib.postgres.search import TrigramSimilarity
from django.core.exceptions import ValidationError
from django.core.files.storage import default_storage
@@ -218,10 +219,14 @@ class UserViewSet(
class ResourceAccessViewsetMixin:
"""Mixin with methods common to all access viewsets."""
def filter_queryset(self, queryset):
"""Override to filter on related resource."""
queryset = super().filter_queryset(queryset)
return queryset.filter(**{self.resource_field_name: self.kwargs["resource_id"]})
def get_permissions(self):
"""User only needs to be authenticated to list resource accesses"""
if self.action == "list":
permission_classes = [permissions.IsAuthenticated]
else:
return super().get_permissions()
return [permission() for permission in permission_classes]
def get_serializer_context(self):
"""Extra context provided to the serializer class."""
@@ -229,6 +234,43 @@ class ResourceAccessViewsetMixin:
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(
**{self.resource_field_name: self.kwargs["resource_id"]}
)
if self.action == "list":
user = self.request.user
teams = user.teams
user_roles_query = (
queryset.filter(
db.Q(user=user) | db.Q(team__in=teams),
**{self.resource_field_name: self.kwargs["resource_id"]},
)
.values(self.resource_field_name)
.annotate(roles_array=ArrayAgg("role"))
.values("roles_array")
)
# Limit to resource access instances related to a resource THAT also has
# a resource access
# instance for the logged-in user (we don't want to list only the resource
# access instances pointing to the logged-in user)
queryset = (
queryset.filter(
db.Q(**{f"{self.resource_field_name}__accesses__user": user})
| db.Q(
**{f"{self.resource_field_name}__accesses__team__in": teams}
),
**{self.resource_field_name: self.kwargs["resource_id"]},
)
.annotate(user_roles=db.Subquery(user_roles_query))
.distinct()
)
return queryset
def destroy(self, request, *args, **kwargs):
"""Forbid deleting the last owner access"""
instance = self.get_object()
@@ -399,6 +441,44 @@ class DocumentViewSet(
trashbin_serializer_class = serializers.ListDocumentSerializer
tree_serializer_class = serializers.ListDocumentSerializer
def annotate_is_favorite(self, queryset):
"""
Annotate document queryset with the favorite status for the current user.
"""
user = self.request.user
if user.is_authenticated:
favorite_exists_subquery = models.DocumentFavorite.objects.filter(
document_id=db.OuterRef("pk"), user=user
)
return queryset.annotate(is_favorite=db.Exists(favorite_exists_subquery))
return queryset.annotate(is_favorite=db.Value(False))
def annotate_user_roles(self, queryset):
"""
Annotate document queryset with the roles of the current user
on the document or its ancestors.
"""
user = self.request.user
output_field = ArrayField(base_field=db.CharField())
if user.is_authenticated:
user_roles_subquery = models.DocumentAccess.objects.filter(
db.Q(user=user) | db.Q(team__in=user.teams),
document__path=Left(db.OuterRef("path"), Length("document__path")),
).values_list("role", flat=True)
return queryset.annotate(
user_roles=db.Func(
user_roles_subquery, function="ARRAY", output_field=output_field
)
)
return queryset.annotate(
user_roles=db.Value([], output_field=output_field),
)
def get_queryset(self):
"""Get queryset performing all annotation and filtering on the document tree structure."""
user = self.request.user
@@ -434,9 +514,8 @@ class DocumentViewSet(
def filter_queryset(self, queryset):
"""Override to apply annotations to generic views."""
queryset = super().filter_queryset(queryset)
user = self.request.user
queryset = queryset.annotate_is_favorite(user)
queryset = queryset.annotate_user_roles(user)
queryset = self.annotate_is_favorite(queryset)
queryset = self.annotate_user_roles(queryset)
return queryset
def get_response_for_queryset(self, queryset):
@@ -460,10 +539,9 @@ class DocumentViewSet(
Additional annotations (e.g., `is_highest_ancestor_for_user`, favorite status) are
applied before ordering and returning the response.
"""
user = self.request.user
# Not calling filter_queryset. We do our own cooking.
queryset = self.get_queryset()
queryset = (
self.get_queryset()
) # Not calling filter_queryset. We do our own cooking.
filterset = ListDocumentFilter(
self.request.GET, queryset=queryset, request=self.request
@@ -476,7 +554,7 @@ class DocumentViewSet(
for field in ["is_creator_me", "title"]:
queryset = filterset.filters[field].filter(queryset, filter_data[field])
queryset = queryset.annotate_user_roles(user)
queryset = self.annotate_user_roles(queryset)
# Among the results, we may have documents that are ancestors/descendants
# of each other. In this case we want to keep only the highest ancestors.
@@ -493,7 +571,7 @@ class DocumentViewSet(
)
# Annotate favorite status and filter if applicable as late as possible
queryset = queryset.annotate_is_favorite(user)
queryset = self.annotate_is_favorite(queryset)
queryset = filterset.filters["is_favorite"].filter(
queryset, filter_data["is_favorite"]
)
@@ -576,7 +654,7 @@ class DocumentViewSet(
deleted_at__isnull=False,
deleted_at__gte=models.get_trashbin_cutoff(),
)
queryset = queryset.annotate_user_roles(self.request.user)
queryset = self.annotate_user_roles(queryset)
queryset = queryset.filter(user_roles__contains=[models.RoleChoices.OWNER])
return self.get_response_for_queryset(queryset)
@@ -756,8 +834,6 @@ class DocumentViewSet(
List ancestors tree above the document.
What we need to display is the tree structure opened for the current document.
"""
user = self.request.user
try:
current_document = self.queryset.only("depth", "path").get(pk=pk)
except models.Document.DoesNotExist as excpt:
@@ -812,8 +888,8 @@ class DocumentViewSet(
output_field=db.BooleanField(),
)
)
queryset = queryset.annotate_user_roles(user)
queryset = queryset.annotate_is_favorite(user)
queryset = self.annotate_user_roles(queryset)
queryset = self.annotate_is_favorite(queryset)
# Pass ancestors' links definitions to the serializer as a context variable
# in order to allow saving time while computing abilities on the instance
@@ -1297,11 +1373,7 @@ class DocumentViewSet(
class DocumentAccessViewSet(
ResourceAccessViewsetMixin,
drf.mixins.CreateModelMixin,
drf.mixins.RetrieveModelMixin,
drf.mixins.UpdateModelMixin,
drf.mixins.DestroyModelMixin,
viewsets.GenericViewSet,
viewsets.ModelViewSet,
):
"""
API ViewSet for all interactions with document accesses.
@@ -1328,52 +1400,37 @@ class DocumentAccessViewSet(
"""
lookup_field = "pk"
pagination_class = Pagination
permission_classes = [permissions.IsAuthenticated, permissions.AccessPermission]
queryset = models.DocumentAccess.objects.select_related("user").all()
resource_field_name = "document"
serializer_class = serializers.DocumentAccessSerializer
is_current_user_owner_or_admin = False
def list(self, request, *args, **kwargs):
"""Return accesses for the current document with filters and annotations."""
user = self.request.user
def get_queryset(self):
"""Return the queryset according to the action."""
queryset = super().get_queryset()
try:
document = models.Document.objects.get(pk=self.kwargs["resource_id"])
except models.Document.DoesNotExist:
return drf.response.Response([])
if self.action == "list":
try:
document = models.Document.objects.get(pk=self.kwargs["resource_id"])
except models.Document.DoesNotExist:
return queryset.none()
roles = set(document.get_roles(user))
if not roles:
return drf.response.Response([])
roles = set(document.get_roles(self.request.user))
is_owner_or_admin = bool(roles.intersection(set(models.PRIVILEGED_ROLES)))
self.is_current_user_owner_or_admin = is_owner_or_admin
if not is_owner_or_admin:
# Return only the document owner access
queryset = queryset.filter(role__in=models.PRIVILEGED_ROLES)
ancestors = (
(document.get_ancestors() | models.Document.objects.filter(pk=document.pk))
.filter(ancestors_deleted_at__isnull=True)
.order_by("path")
)
highest_readable = ancestors.readable_per_se(user).only("depth").first()
return queryset
if highest_readable is None:
return drf.response.Response([])
def get_serializer_class(self):
if self.action == "list" and not self.is_current_user_owner_or_admin:
return serializers.DocumentAccessLightSerializer
queryset = self.get_queryset()
queryset = queryset.filter(
document__in=ancestors.filter(depth__gte=highest_readable.depth)
)
is_privileged = bool(roles.intersection(set(models.PRIVILEGED_ROLES)))
if is_privileged:
serializer_class = serializers.DocumentAccessSerializer
else:
# Return only the document's privileged accesses
queryset = queryset.filter(role__in=models.PRIVILEGED_ROLES)
serializer_class = serializers.DocumentAccessLightSerializer
queryset = queryset.distinct()
serializer = serializer_class(
queryset, many=True, context=self.get_serializer_context()
)
return drf.response.Response(serializer.data)
return super().get_serializer_class()
def perform_create(self, serializer):
"""Add a new access to the document and send an email to the new added user."""
@@ -1485,6 +1542,7 @@ class TemplateAccessViewSet(
ResourceAccessViewsetMixin,
drf.mixins.CreateModelMixin,
drf.mixins.DestroyModelMixin,
drf.mixins.ListModelMixin,
drf.mixins.RetrieveModelMixin,
drf.mixins.UpdateModelMixin,
viewsets.GenericViewSet,
@@ -1514,28 +1572,12 @@ class TemplateAccessViewSet(
"""
lookup_field = "pk"
pagination_class = Pagination
permission_classes = [permissions.IsAuthenticated, permissions.AccessPermission]
queryset = models.TemplateAccess.objects.select_related("user").all()
resource_field_name = "template"
serializer_class = serializers.TemplateAccessSerializer
def list(self, request, *args, **kwargs):
"""Restrict templates returned by the list endpoint"""
user = self.request.user
teams = user.teams
queryset = self.filter_queryset(self.get_queryset())
# Limit to resource access instances related to a resource THAT also has
# a resource access instance for the logged-in user (we don't want to list
# only the resource access instances pointing to the logged-in user)
queryset = queryset.filter(
db.Q(template__accesses__user=user)
| db.Q(template__accesses__team__in=teams),
).distinct()
serializer = self.get_serializer(queryset, many=True)
return drf.response.Response(serializer.data)
class InvitationViewset(
drf.mixins.CreateModelMixin,
@@ -1650,6 +1692,7 @@ class ConfigView(drf.views.APIView):
"CRISP_WEBSITE_ID",
"ENVIRONMENT",
"FRONTEND_CSS_URL",
"FRONTEND_HOMEPAGE_FEATURE_ENABLED",
"FRONTEND_FOOTER_FEATURE_ENABLED",
"FRONTEND_THEME",
"MEDIA_BASE_URL",

View File

@@ -1,14 +0,0 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("core", "0020_remove_is_public_add_field_attachments_and_duplicated_from"),
]
operations = [
migrations.RunSQL(
"CREATE EXTENSION IF NOT EXISTS unaccent;",
reverse_sql="DROP EXTENSION IF EXISTS unaccent;",
),
]

View File

@@ -87,61 +87,49 @@ class LinkReachChoices(models.TextChoices):
"""
Determines the valid select options for link reach and link role depending on the
list of ancestors' link reach/role.
Args:
ancestors_links: List of dictionaries, each with 'link_reach' and 'link_role' keys
representing the reach and role of ancestors links.
Returns:
Dictionary mapping possible reach levels to their corresponding possible roles.
"""
# If no ancestors, return all options
if not ancestors_links:
return {
reach: LinkRoleChoices.values if reach != cls.RESTRICTED else None
for reach in cls.values
}
return dict.fromkeys(cls.values, LinkRoleChoices.values)
# Initialize result with all possible reaches and role options as sets
result = {
reach: set(LinkRoleChoices.values) if reach != cls.RESTRICTED else None
for reach in cls.values
}
result = {reach: set(LinkRoleChoices.values) for reach in cls.values}
# Group roles by reach level
reach_roles = defaultdict(set)
for link in ancestors_links:
reach_roles[link["link_reach"]].add(link["link_role"])
# Rule 1: public/editor → override everything
if LinkRoleChoices.EDITOR in reach_roles.get(cls.PUBLIC, set()):
return {cls.PUBLIC: [LinkRoleChoices.EDITOR]}
# Apply constraints based on ancestor links
if LinkRoleChoices.EDITOR in reach_roles[cls.RESTRICTED]:
result[cls.RESTRICTED].discard(LinkRoleChoices.READER)
# Rule 2: authenticated/editor
if LinkRoleChoices.EDITOR in reach_roles.get(cls.AUTHENTICATED, set()):
if LinkRoleChoices.EDITOR in reach_roles[cls.AUTHENTICATED]:
result[cls.AUTHENTICATED].discard(LinkRoleChoices.READER)
result.pop(cls.RESTRICTED, None)
elif LinkRoleChoices.READER in reach_roles[cls.AUTHENTICATED]:
result[cls.RESTRICTED].discard(LinkRoleChoices.READER)
# Rule 3: public/reader
if LinkRoleChoices.READER in reach_roles.get(cls.PUBLIC, set()):
if LinkRoleChoices.EDITOR in reach_roles[cls.PUBLIC]:
result[cls.PUBLIC].discard(LinkRoleChoices.READER)
result.pop(cls.AUTHENTICATED, None)
result.pop(cls.RESTRICTED, None)
elif LinkRoleChoices.READER in reach_roles[cls.PUBLIC]:
result[cls.AUTHENTICATED].discard(LinkRoleChoices.READER)
result.get(cls.RESTRICTED, set()).discard(LinkRoleChoices.READER)
# Rule 4: authenticated/reader
if LinkRoleChoices.READER in reach_roles.get(cls.AUTHENTICATED, set()):
result.pop(cls.RESTRICTED, None)
# Convert roles sets to lists while maintaining the order from LinkRoleChoices
for reach, roles in result.items():
result[reach] = [role for role in LinkRoleChoices.values if role in roles]
# Clean up: remove empty entries and convert sets to ordered lists
cleaned = {}
for reach in cls.values:
if reach in result:
if result[reach]:
cleaned[reach] = [
r for r in LinkRoleChoices.values if r in result[reach]
]
else:
# Could be [] or None (for RESTRICTED reach)
cleaned[reach] = result[reach]
return cleaned
return result
class DuplicateEmailError(Exception):
@@ -464,41 +452,6 @@ class DocumentQuerySet(MP_NodeQuerySet):
return self.filter(link_reach=LinkReachChoices.PUBLIC)
def annotate_is_favorite(self, user):
"""
Annotate document queryset with the favorite status for the current user.
"""
if user.is_authenticated:
favorite_exists_subquery = DocumentFavorite.objects.filter(
document_id=models.OuterRef("pk"), user=user
)
return self.annotate(is_favorite=models.Exists(favorite_exists_subquery))
return self.annotate(is_favorite=models.Value(False))
def annotate_user_roles(self, user):
"""
Annotate document queryset with the roles of the current user
on the document or its ancestors.
"""
output_field = ArrayField(base_field=models.CharField())
if user.is_authenticated:
user_roles_subquery = DocumentAccess.objects.filter(
models.Q(user=user) | models.Q(team__in=user.teams),
document__path=Left(models.OuterRef("path"), Length("document__path")),
).values_list("role", flat=True)
return self.annotate(
user_roles=models.Func(
user_roles_subquery, function="ARRAY", output_field=output_field
)
)
return self.annotate(
user_roles=models.Value([], output_field=output_field),
)
class DocumentManager(MP_NodeManager.from_queryset(DocumentQuerySet)):
"""
@@ -784,16 +737,17 @@ class Document(MP_Node, BaseModel):
roles = []
return roles
def get_ancestors_links_definitions(self, ancestors_links):
"""Get links reach/role definitions for ancestors of the current document."""
def get_links_definitions(self, ancestors_links):
"""Get links reach/role definitions for the current document and its ancestors."""
ancestors_links_definitions = defaultdict(set)
links_definitions = defaultdict(set)
links_definitions[self.link_reach].add(self.link_role)
# Merge ancestor link definitions
for ancestor in ancestors_links:
ancestors_links_definitions[ancestor["link_reach"]].add(
ancestor["link_role"]
)
links_definitions[ancestor["link_reach"]].add(ancestor["link_role"])
return ancestors_links_definitions
return dict(links_definitions) # Convert defaultdict back to a normal dict
def compute_ancestors_links(self, user):
"""
@@ -849,20 +803,10 @@ class Document(MP_Node, BaseModel):
) and not is_deleted
# Add roles provided by the document link, taking into account its ancestors
ancestors_links_definitions = self.get_ancestors_links_definitions(
ancestors_links
)
public_roles = ancestors_links_definitions.get(
LinkReachChoices.PUBLIC, set()
) | ({self.link_role} if self.link_reach == LinkReachChoices.PUBLIC else set())
links_definitions = self.get_links_definitions(ancestors_links)
public_roles = links_definitions.get(LinkReachChoices.PUBLIC, set())
authenticated_roles = (
ancestors_links_definitions.get(LinkReachChoices.AUTHENTICATED, set())
| (
{self.link_role}
if self.link_reach == LinkReachChoices.AUTHENTICATED
else set()
)
links_definitions.get(LinkReachChoices.AUTHENTICATED, set())
if user.is_authenticated
else set()
)
@@ -906,9 +850,6 @@ class Document(MP_Node, BaseModel):
"restore": is_owner,
"retrieve": can_get,
"media_auth": can_get,
"ancestors_links_definitions": {
k: list(v) for k, v in ancestors_links_definitions.items()
},
"link_select_options": LinkReachChoices.get_select_options(ancestors_links),
"tree": can_get,
"update": can_update,

View File

@@ -51,7 +51,12 @@ def test_api_document_accesses_list_authenticated_unrelated():
f"/api/v1.0/documents/{document.id!s}/accesses/",
)
assert response.status_code == 200
assert response.json() == []
assert response.json() == {
"count": 0,
"next": None,
"previous": None,
"results": [],
}
def test_api_document_accesses_list_unexisting_document():
@@ -65,7 +70,12 @@ def test_api_document_accesses_list_unexisting_document():
response = client.get(f"/api/v1.0/documents/{uuid4()!s}/accesses/")
assert response.status_code == 200
assert response.json() == []
assert response.json() == {
"count": 0,
"next": None,
"previous": None,
"results": [],
}
@pytest.mark.parametrize("via", VIA)
@@ -76,30 +86,22 @@ def test_api_document_accesses_list_authenticated_related_non_privileged(
via, role, mock_user_teams
):
"""
Authenticated users with no privileged role should only be able to list document
accesses associated with privileged roles for a document, including from ancestors.
Authenticated users should be able to list document accesses for a document
to which they are directly related, whatever their role in the document.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Create documents structured as a tree
unreadable_ancestor = factories.DocumentFactory(link_reach="restricted")
# make all documents below the grand parent readable without a specific access for the user
grand_parent = factories.DocumentFactory(
parent=unreadable_ancestor, link_reach="authenticated"
owner = factories.UserFactory()
accesses = []
document_access = factories.UserDocumentAccessFactory(
user=owner, role=models.RoleChoices.OWNER
)
parent = factories.DocumentFactory(parent=grand_parent)
document = factories.DocumentFactory(parent=parent)
child = factories.DocumentFactory(parent=document)
# Create accesses related to each document
factories.UserDocumentAccessFactory(document=unreadable_ancestor)
grand_parent_access = factories.UserDocumentAccessFactory(document=grand_parent)
parent_access = factories.UserDocumentAccessFactory(document=parent)
document_access = factories.UserDocumentAccessFactory(document=document)
factories.UserDocumentAccessFactory(document=child)
accesses.append(document_access)
document = document_access.document
if via == USER:
models.DocumentAccess.objects.create(
document=document,
@@ -116,6 +118,8 @@ def test_api_document_accesses_list_authenticated_related_non_privileged(
access1 = factories.TeamDocumentAccessFactory(document=document)
access2 = factories.UserDocumentAccessFactory(document=document)
accesses.append(access1)
accesses.append(access2)
# Accesses for other documents to which the user is related should not be listed either
other_access = factories.UserDocumentAccessFactory(user=user)
@@ -125,17 +129,14 @@ def test_api_document_accesses_list_authenticated_related_non_privileged(
f"/api/v1.0/documents/{document.id!s}/accesses/",
)
# Return only owners
owners_accesses = [
access for access in accesses if access.role in models.PRIVILEGED_ROLES
]
assert response.status_code == 200
content = response.json()
# Make sure only privileged roles are returned
accesses = [grand_parent_access, parent_access, document_access, access1, access2]
privileged_accesses = [
acc for acc in accesses if acc.role in models.PRIVILEGED_ROLES
]
assert len(content) == len(privileged_accesses)
assert sorted(content, key=lambda x: x["id"]) == sorted(
assert content["count"] == len(owners_accesses)
assert sorted(content["results"], key=lambda x: x["id"]) == sorted(
[
{
"id": str(access.id),
@@ -151,44 +152,38 @@ def test_api_document_accesses_list_authenticated_related_non_privileged(
"role": access.role,
"abilities": access.get_abilities(user),
}
for access in privileged_accesses
for access in owners_accesses
],
key=lambda x: x["id"],
)
for access in content["results"]:
assert access["role"] in models.PRIVILEGED_ROLES
@pytest.mark.parametrize("via", VIA)
@pytest.mark.parametrize(
"role", [role for role in models.RoleChoices if role in models.PRIVILEGED_ROLES]
)
def test_api_document_accesses_list_authenticated_related_privileged(
@pytest.mark.parametrize("role", models.PRIVILEGED_ROLES)
def test_api_document_accesses_list_authenticated_related_privileged_roles(
via, role, mock_user_teams
):
"""
Authenticated users with a privileged role should be able to list all
document accesses whatever the role, including from ancestors.
Authenticated users should be able to list document accesses for a document
to which they are directly related, whatever their role in the document.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Create documents structured as a tree
unreadable_ancestor = factories.DocumentFactory(link_reach="restricted")
# make all documents below the grand parent readable without a specific access for the user
grand_parent = factories.DocumentFactory(
parent=unreadable_ancestor, link_reach="authenticated"
owner = factories.UserFactory()
accesses = []
document_access = factories.UserDocumentAccessFactory(
user=owner, role=models.RoleChoices.OWNER
)
parent = factories.DocumentFactory(parent=grand_parent)
document = factories.DocumentFactory(parent=parent)
child = factories.DocumentFactory(parent=document)
# Create accesses related to each document
factories.UserDocumentAccessFactory(document=unreadable_ancestor)
grand_parent_access = factories.UserDocumentAccessFactory(document=grand_parent)
parent_access = factories.UserDocumentAccessFactory(document=parent)
document_access = factories.UserDocumentAccessFactory(document=document)
factories.UserDocumentAccessFactory(document=child)
accesses.append(document_access)
document = document_access.document
user_access = None
if via == USER:
user_access = models.DocumentAccess.objects.create(
document=document,
@@ -202,11 +197,11 @@ def test_api_document_accesses_list_authenticated_related_privileged(
team="lasuite",
role=role,
)
else:
raise RuntimeError()
access1 = factories.TeamDocumentAccessFactory(document=document)
access2 = factories.UserDocumentAccessFactory(document=document)
accesses.append(access1)
accesses.append(access2)
# Accesses for other documents to which the user is related should not be listed either
other_access = factories.UserDocumentAccessFactory(user=user)
@@ -216,39 +211,42 @@ def test_api_document_accesses_list_authenticated_related_privileged(
f"/api/v1.0/documents/{document.id!s}/accesses/",
)
access2_user = serializers.UserSerializer(instance=access2.user).data
base_user = serializers.UserSerializer(instance=user).data
assert response.status_code == 200
content = response.json()
# Make sure all expected accesses are returned
accesses = [
user_access,
grand_parent_access,
parent_access,
document_access,
access1,
access2,
]
assert len(content) == 6
assert sorted(content, key=lambda x: x["id"]) == sorted(
assert len(content["results"]) == 4
assert sorted(content["results"], key=lambda x: x["id"]) == sorted(
[
{
"id": str(access.id),
"document_id": str(access.document_id),
"user": {
"id": str(access.user.id),
"email": access.user.email,
"language": access.user.language,
"full_name": access.user.full_name,
"short_name": access.user.short_name,
}
if access.user
else None,
"team": access.team,
"role": access.role,
"abilities": access.get_abilities(user),
}
for access in accesses
"id": str(user_access.id),
"user": base_user if via == "user" else None,
"team": "lasuite" if via == "team" else "",
"role": user_access.role,
"abilities": user_access.get_abilities(user),
},
{
"id": str(access1.id),
"user": None,
"team": access1.team,
"role": access1.role,
"abilities": access1.get_abilities(user),
},
{
"id": str(access2.id),
"user": access2_user,
"team": "",
"role": access2.role,
"abilities": access2.get_abilities(user),
},
{
"id": str(document_access.id),
"user": serializers.UserSerializer(instance=owner).data,
"team": "",
"role": models.RoleChoices.OWNER,
"abilities": document_access.get_abilities(user),
},
],
key=lambda x: x["id"],
)
@@ -343,7 +341,6 @@ def test_api_document_accesses_retrieve_authenticated_related(
assert response.status_code == 200
assert response.json() == {
"id": str(access.id),
"document_id": str(access.document_id),
"user": access_user,
"team": "",
"role": access.role,

View File

@@ -165,7 +165,6 @@ def test_api_document_accesses_create_authenticated_administrator(via, mock_user
other_user = serializers.UserSerializer(instance=other_user).data
assert response.json() == {
"abilities": new_document_access.get_abilities(user),
"document_id": str(new_document_access.document_id),
"id": str(new_document_access.id),
"team": "",
"role": role,
@@ -223,7 +222,6 @@ def test_api_document_accesses_create_authenticated_owner(via, mock_user_teams):
new_document_access = models.DocumentAccess.objects.filter(user=other_user).get()
other_user = serializers.UserSerializer(instance=other_user).data
assert response.json() == {
"document_id": str(new_document_access.document_id),
"id": str(new_document_access.id),
"user": other_user,
"team": "",
@@ -288,7 +286,6 @@ def test_api_document_accesses_create_email_in_receivers_language(via, mock_user
).get()
other_user_data = serializers.UserSerializer(instance=other_user).data
assert response.json() == {
"document_id": str(new_document_access.document_id),
"id": str(new_document_access.id),
"user": other_user_data,
"team": "",

View File

@@ -7,7 +7,6 @@ from faker import Faker
from rest_framework.test import APIClient
from core import factories
from core.api.filters import remove_accents
fake = Faker()
pytestmark = pytest.mark.django_db
@@ -50,16 +49,14 @@ def test_api_documents_descendants_filter_unknown_field():
[
("Project Alpha", 1), # Exact match
("project", 2), # Partial match (case-insensitive)
("Guide", 2), # Word match within a title
("Guide", 1), # Word match within a title
("Special", 0), # No match (nonexistent keyword)
("2024", 2), # Match by numeric keyword
("", 6), # Empty string
("velo", 1), # Accent-insensitive match (velo vs vélo)
("bêta", 1), # Accent-insensitive match (bêta vs beta)
("", 5), # Empty string
],
)
def test_api_documents_descendants_filter_title(query, nb_results):
"""Authenticated users should be able to search documents by their unaccented title."""
"""Authenticated users should be able to search documents by their title."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
@@ -73,7 +70,6 @@ def test_api_documents_descendants_filter_title(query, nb_results):
"User Guide",
"Financial Report 2024",
"Annual Review 2024",
"Guide du vélo urbain", # <-- Title with accent for accent-insensitive test
]
for title in titles:
factories.DocumentFactory(title=title, parent=document)
@@ -89,7 +85,4 @@ def test_api_documents_descendants_filter_title(query, nb_results):
# Ensure all results contain the query in their title
for result in results:
assert (
remove_accents(query).lower().strip()
in remove_accents(result["title"]).lower()
)
assert query.lower().strip() in result["title"].lower()

View File

@@ -42,11 +42,10 @@ def test_api_documents_retrieve_anonymous_public_standalone():
"favorite": False,
"invite_owner": False,
"link_configuration": False,
"ancestors_links_definitions": {},
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"restricted": None,
"restricted": ["reader", "editor"],
},
"media_auth": True,
"move": False,
@@ -98,10 +97,6 @@ def test_api_documents_retrieve_anonymous_public_parent():
"accesses_view": False,
"ai_transform": False,
"ai_translate": False,
"ancestors_links_definitions": {
"public": [grand_parent.link_role],
parent.link_reach: [parent.link_role],
},
"attachment_upload": grand_parent.link_role == "editor",
"children_create": False,
"children_list": True,
@@ -198,7 +193,6 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
"accesses_view": False,
"ai_transform": document.link_role == "editor",
"ai_translate": document.link_role == "editor",
"ancestors_links_definitions": {},
"attachment_upload": document.link_role == "editor",
"children_create": document.link_role == "editor",
"children_list": True,
@@ -213,7 +207,7 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"restricted": None,
"restricted": ["reader", "editor"],
},
"media_auth": True,
"move": False,
@@ -273,10 +267,6 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea
"accesses_view": False,
"ai_transform": grand_parent.link_role == "editor",
"ai_translate": grand_parent.link_role == "editor",
"ancestors_links_definitions": {
grand_parent.link_reach: [grand_parent.link_role],
"restricted": [parent.link_role],
},
"attachment_upload": grand_parent.link_role == "editor",
"children_create": grand_parent.link_role == "editor",
"children_list": True,
@@ -450,7 +440,6 @@ def test_api_documents_retrieve_authenticated_related_parent():
)
assert response.status_code == 200
links = document.get_ancestors().values("link_reach", "link_role")
ancestors_roles = list({grand_parent.link_role, parent.link_role})
assert response.json() == {
"id": str(document.id),
"abilities": {
@@ -458,7 +447,6 @@ def test_api_documents_retrieve_authenticated_related_parent():
"accesses_view": True,
"ai_transform": access.role != "reader",
"ai_translate": access.role != "reader",
"ancestors_links_definitions": {"restricted": ancestors_roles},
"attachment_upload": access.role != "reader",
"children_create": access.role != "reader",
"children_list": True,

View File

@@ -74,7 +74,6 @@ def test_api_documents_trashbin_format():
"accesses_view": True,
"ai_transform": True,
"ai_translate": True,
"ancestors_links_definitions": {},
"attachment_upload": True,
"children_create": True,
"children_list": True,
@@ -89,7 +88,7 @@ def test_api_documents_trashbin_format():
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"restricted": None,
"restricted": ["reader", "editor"],
},
"media_auth": True,
"move": False, # Can't move a deleted document

View File

@@ -48,7 +48,12 @@ def test_api_template_accesses_list_authenticated_unrelated():
f"/api/v1.0/templates/{template.id!s}/accesses/",
)
assert response.status_code == 200
assert response.json() == []
assert response.json() == {
"count": 0,
"next": None,
"previous": None,
"results": [],
}
@pytest.mark.parametrize("via", VIA)
@@ -91,8 +96,8 @@ def test_api_template_accesses_list_authenticated_related(via, mock_user_teams):
assert response.status_code == 200
content = response.json()
assert len(content) == 3
assert sorted(content, key=lambda x: x["id"]) == sorted(
assert len(content["results"]) == 3
assert sorted(content["results"], key=lambda x: x["id"]) == sorted(
[
{
"id": str(user_access.id),

View File

@@ -19,6 +19,7 @@ pytestmark = pytest.mark.django_db
COLLABORATION_WS_URL="http://testcollab/",
CRISP_WEBSITE_ID="123",
FRONTEND_CSS_URL="http://testcss/",
FRONTEND_HOMEPAGE_FEATURE_ENABLED=True,
FRONTEND_FOOTER_FEATURE_ENABLED=True,
FRONTEND_THEME="test-theme",
MEDIA_BASE_URL="http://testserver/",
@@ -41,6 +42,7 @@ def test_api_config(is_authenticated):
"CRISP_WEBSITE_ID": "123",
"ENVIRONMENT": "test",
"FRONTEND_CSS_URL": "http://testcss/",
"FRONTEND_HOMEPAGE_FEATURE_ENABLED": True,
"FRONTEND_FOOTER_FEATURE_ENABLED": True,
"FRONTEND_THEME": "test-theme",
"LANGUAGES": [

View File

@@ -154,7 +154,6 @@ def test_models_documents_get_abilities_forbidden(
"accesses_view": False,
"ai_transform": False,
"ai_translate": False,
"ancestors_links_definitions": {},
"attachment_upload": False,
"children_create": False,
"children_list": False,
@@ -171,7 +170,7 @@ def test_models_documents_get_abilities_forbidden(
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"restricted": None,
"restricted": ["reader", "editor"],
},
"partial_update": False,
"restore": False,
@@ -215,7 +214,6 @@ def test_models_documents_get_abilities_reader(
"accesses_view": False,
"ai_transform": False,
"ai_translate": False,
"ancestors_links_definitions": {},
"attachment_upload": False,
"children_create": False,
"children_list": True,
@@ -230,7 +228,7 @@ def test_models_documents_get_abilities_reader(
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"restricted": None,
"restricted": ["reader", "editor"],
},
"media_auth": True,
"move": False,
@@ -252,7 +250,7 @@ def test_models_documents_get_abilities_reader(
assert all(
value is False
for key, value in document.get_abilities(user).items()
if key not in ["link_select_options", "ancestors_links_definitions"]
if key != "link_select_options"
)
@@ -278,7 +276,6 @@ def test_models_documents_get_abilities_editor(
"accesses_view": False,
"ai_transform": is_authenticated,
"ai_translate": is_authenticated,
"ancestors_links_definitions": {},
"attachment_upload": True,
"children_create": is_authenticated,
"children_list": True,
@@ -293,7 +290,7 @@ def test_models_documents_get_abilities_editor(
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"restricted": None,
"restricted": ["reader", "editor"],
},
"media_auth": True,
"move": False,
@@ -314,7 +311,7 @@ def test_models_documents_get_abilities_editor(
assert all(
value is False
for key, value in document.get_abilities(user).items()
if key not in ["link_select_options", "ancestors_links_definitions"]
if key != "link_select_options"
)
@@ -330,7 +327,6 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries):
"accesses_view": True,
"ai_transform": True,
"ai_translate": True,
"ancestors_links_definitions": {},
"attachment_upload": True,
"children_create": True,
"children_list": True,
@@ -345,7 +341,7 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries):
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"restricted": None,
"restricted": ["reader", "editor"],
},
"media_auth": True,
"move": True,
@@ -379,7 +375,6 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries)
"accesses_view": True,
"ai_transform": True,
"ai_translate": True,
"ancestors_links_definitions": {},
"attachment_upload": True,
"children_create": True,
"children_list": True,
@@ -394,7 +389,7 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries)
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"restricted": None,
"restricted": ["reader", "editor"],
},
"media_auth": True,
"move": True,
@@ -415,7 +410,7 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries)
assert all(
value is False
for key, value in document.get_abilities(user).items()
if key not in ["link_select_options", "ancestors_links_definitions"]
if key != "link_select_options"
)
@@ -431,7 +426,6 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
"accesses_view": True,
"ai_transform": True,
"ai_translate": True,
"ancestors_links_definitions": {},
"attachment_upload": True,
"children_create": True,
"children_list": True,
@@ -446,7 +440,7 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"restricted": None,
"restricted": ["reader", "editor"],
},
"media_auth": True,
"move": False,
@@ -467,7 +461,7 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
assert all(
value is False
for key, value in document.get_abilities(user).items()
if key not in ["link_select_options", "ancestors_links_definitions"]
if key != "link_select_options"
)
@@ -490,7 +484,6 @@ def test_models_documents_get_abilities_reader_user(
# You should not access AI if it's restricted to users with specific access
"ai_transform": access_from_link and ai_access_setting != "restricted",
"ai_translate": access_from_link and ai_access_setting != "restricted",
"ancestors_links_definitions": {},
"attachment_upload": access_from_link,
"children_create": access_from_link,
"children_list": True,
@@ -505,7 +498,7 @@ def test_models_documents_get_abilities_reader_user(
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"restricted": None,
"restricted": ["reader", "editor"],
},
"media_auth": True,
"move": False,
@@ -528,7 +521,7 @@ def test_models_documents_get_abilities_reader_user(
assert all(
value is False
for key, value in document.get_abilities(user).items()
if key not in ["link_select_options", "ancestors_links_definitions"]
if key != "link_select_options"
)
@@ -547,7 +540,6 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
"accesses_view": True,
"ai_transform": False,
"ai_translate": False,
"ancestors_links_definitions": {},
"attachment_upload": False,
"children_create": False,
"children_list": True,
@@ -562,7 +554,7 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"restricted": None,
"restricted": ["reader", "editor"],
},
"media_auth": True,
"move": False,
@@ -1182,6 +1174,8 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries):
(
[{"link_reach": "public", "link_role": "reader"}],
{
"restricted": ["editor"],
"authenticated": ["editor"],
"public": ["reader", "editor"],
},
),
@@ -1189,6 +1183,7 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries):
(
[{"link_reach": "authenticated", "link_role": "reader"}],
{
"restricted": ["editor"],
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
},
@@ -1200,7 +1195,7 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries):
(
[{"link_reach": "restricted", "link_role": "reader"}],
{
"restricted": None,
"restricted": ["reader", "editor"],
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
},
@@ -1208,7 +1203,7 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries):
(
[{"link_reach": "restricted", "link_role": "editor"}],
{
"restricted": None,
"restricted": ["editor"],
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
},
@@ -1234,7 +1229,7 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries):
{"link_reach": "restricted", "link_role": "editor"},
],
{
"restricted": None,
"restricted": ["editor"],
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
},
@@ -1246,6 +1241,8 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries):
{"link_reach": "public", "link_role": "reader"},
],
{
"restricted": ["editor"],
"authenticated": ["editor"],
"public": ["reader", "editor"],
},
),
@@ -1256,6 +1253,8 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries):
{"link_reach": "public", "link_role": "reader"},
],
{
"restricted": ["editor"],
"authenticated": ["editor"],
"public": ["reader", "editor"],
},
),
@@ -1265,7 +1264,7 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries):
{"link_reach": "authenticated", "link_role": "editor"},
{"link_reach": "public", "link_role": "reader"},
],
{"public": ["reader", "editor"]},
{"authenticated": ["editor"], "public": ["reader", "editor"]},
),
(
[
@@ -1280,6 +1279,7 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries):
{"link_reach": "authenticated", "link_role": "reader"},
],
{
"restricted": ["editor"],
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
},
@@ -1297,7 +1297,7 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries):
{
"public": ["reader", "editor"],
"authenticated": ["reader", "editor"],
"restricted": None,
"restricted": ["reader", "editor"],
},
),
],

View File

@@ -410,6 +410,11 @@ class Base(Configuration):
FRONTEND_THEME = values.Value(
None, environ_name="FRONTEND_THEME", environ_prefix=None
)
FRONTEND_HOMEPAGE_FEATURE_ENABLED = values.BooleanValue(
default=False,
environ_name="FRONTEND_HOMEPAGE_FEATURE_ENABLED",
environ_prefix=None,
)
FRONTEND_URL_JSON_FOOTER = values.Value(
None, environ_name="FRONTEND_URL_JSON_FOOTER", environ_prefix=None
)

View File

@@ -1,5 +1,26 @@
import { Page, expect } from '@playwright/test';
export const CONFIG = {
AI_FEATURE_ENABLED: true,
CRISP_WEBSITE_ID: null,
COLLABORATION_WS_URL: 'ws://localhost:4444/collaboration/ws/',
ENVIRONMENT: 'development',
FRONTEND_CSS_URL: null,
FRONTEND_HOMEPAGE_FEATURE_ENABLED: true,
FRONTEND_FOOTER_FEATURE_ENABLED: true,
FRONTEND_THEME: 'default',
MEDIA_BASE_URL: 'http://localhost:8083',
LANGUAGES: [
['en-us', 'English'],
['fr-fr', 'Français'],
['de-de', 'Deutsch'],
['nl-nl', 'Nederlands'],
],
LANGUAGE_CODE: 'en-us',
POSTHOG_KEY: {},
SENTRY_DSN: null,
};
export const keyCloakSignIn = async (
page: Page,
browserName: string,

View File

@@ -2,27 +2,7 @@ import path from 'path';
import { expect, test } from '@playwright/test';
import { createDoc } from './common';
const config = {
AI_FEATURE_ENABLED: true,
CRISP_WEBSITE_ID: null,
COLLABORATION_WS_URL: 'ws://localhost:4444/collaboration/ws/',
ENVIRONMENT: 'development',
FRONTEND_CSS_URL: null,
FRONTEND_FOOTER_FEATURE_ENABLED: true,
FRONTEND_THEME: 'default',
MEDIA_BASE_URL: 'http://localhost:8083',
LANGUAGES: [
['en-us', 'English'],
['fr-fr', 'Français'],
['de-de', 'Deutsch'],
['nl-nl', 'Nederlands'],
],
LANGUAGE_CODE: 'en-us',
POSTHOG_KEY: {},
SENTRY_DSN: null,
};
import { CONFIG, createDoc } from './common';
test.describe('Config', () => {
test('it checks the config api is called', async ({ page }) => {
@@ -36,7 +16,7 @@ test.describe('Config', () => {
const response = await responsePromise;
expect(response.ok()).toBeTruthy();
expect(await response.json()).toStrictEqual(config);
expect(await response.json()).toStrictEqual(CONFIG);
});
test('it checks that sentry is trying to init from config endpoint', async ({
@@ -47,7 +27,7 @@ test.describe('Config', () => {
if (request.method().includes('GET')) {
await route.fulfill({
json: {
...config,
...CONFIG,
SENTRY_DSN: 'https://sentry.io/123',
},
});
@@ -120,7 +100,7 @@ test.describe('Config', () => {
if (request.method().includes('GET')) {
await route.fulfill({
json: {
...config,
...CONFIG,
AI_FEATURE_ENABLED: false,
},
});
@@ -151,7 +131,7 @@ test.describe('Config', () => {
if (request.method().includes('GET')) {
await route.fulfill({
json: {
...config,
...CONFIG,
CRISP_WEBSITE_ID: '1234',
},
});
@@ -173,7 +153,7 @@ test.describe('Config', () => {
if (request.method().includes('GET')) {
await route.fulfill({
json: {
...config,
...CONFIG,
FRONTEND_CSS_URL: 'http://localhost:123465/css/style.css',
},
});

View File

@@ -1,5 +1,7 @@
import { expect, test } from '@playwright/test';
import { CONFIG } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/docs/');
});
@@ -50,4 +52,27 @@ test.describe('Home page', () => {
await expect(footer).toBeVisible();
});
test('it checks the homepage feature flag', async ({ page }) => {
await page.route('**/api/v1.0/config/', async (route) => {
const request = route.request();
if (request.method().includes('GET')) {
await route.fulfill({
json: {
...CONFIG,
FRONTEND_HOMEPAGE_FEATURE_ENABLED: false,
},
});
} else {
await route.continue();
}
});
await page.goto('/');
// Keyclock login page
await expect(
page.locator('.login-pf-page-header').getByText('impress'),
).toBeVisible();
});
});

View File

@@ -5,17 +5,18 @@ import { Theme } from '@/cunningham/';
import { PostHogConf } from '@/services';
interface ConfigResponse {
LANGUAGES: [string, string][];
LANGUAGE_CODE: string;
ENVIRONMENT: string;
AI_FEATURE_ENABLED?: boolean;
COLLABORATION_WS_URL?: string;
CRISP_WEBSITE_ID?: string;
FRONTEND_THEME?: Theme;
ENVIRONMENT: string;
FRONTEND_CSS_URL?: string;
FRONTEND_HOMEPAGE_FEATURE_ENABLED?: boolean;
FRONTEND_THEME?: Theme;
LANGUAGES: [string, string][];
LANGUAGE_CODE: string;
MEDIA_BASE_URL?: string;
POSTHOG_KEY?: PostHogConf;
SENTRY_DSN?: string;
AI_FEATURE_ENABLED?: boolean;
}
export const getConfig = async (): Promise<ConfigResponse> => {

View File

@@ -3,14 +3,16 @@ import { useRouter } from 'next/router';
import { PropsWithChildren } from 'react';
import { Box } from '@/components';
import { useConfig } from '@/core';
import { useAuth } from '../hooks';
import { getAuthUrl } from '../utils';
import { getAuthUrl, gotoLogin } from '../utils';
export const Auth = ({ children }: PropsWithChildren) => {
const { isLoading, pathAllowed, isFetchedAfterMount, authenticated } =
useAuth();
const { replace, pathname } = useRouter();
const { data: config } = useConfig();
if (isLoading && !isFetchedAfterMount) {
return (
@@ -40,7 +42,11 @@ export const Auth = ({ children }: PropsWithChildren) => {
* If the user is not authenticated and the path is not allowed, we redirect to the login page.
*/
if (!authenticated && !pathAllowed) {
void replace('/login');
if (config?.FRONTEND_HOMEPAGE_FEATURE_ENABLED) {
void replace('/home');
} else {
gotoLogin();
}
return (
<Box $height="100vh" $width="100vw" $align="center" $justify="center">
<Loader />
@@ -49,9 +55,9 @@ export const Auth = ({ children }: PropsWithChildren) => {
}
/**
* If the user is authenticated and the path is the login page, we redirect to the home page.
* If the user is authenticated and the path is the home page, we redirect to the index.
*/
if (pathname === '/login' && authenticated) {
if (pathname === '/home' && authenticated) {
void replace('/');
return (
<Box $height="100vh" $width="100vw" $align="center" $justify="center">

View File

@@ -0,0 +1,8 @@
import { HomeContent } from '@/features/home';
import { NextPageWithLayout } from '@/types/next';
const Page: NextPageWithLayout = () => {
return <HomeContent />;
};
export default Page;

View File

@@ -50,6 +50,7 @@ backend:
DB_USER: dinum
DB_PASSWORD: pass
DB_PORT: 5432
FRONTEND_HOMEPAGE_FEATURE_ENABLED: true
FRONTEND_FOOTER_FEATURE_ENABLED: true
FRONTEND_URL_JSON_FOOTER: https://impress.127.0.0.1.nip.io/contents/footer-demo.json
POSTGRES_DB: impress