From c018c6fcf5bee63020c2a73082a21d3bfeefc74f Mon Sep 17 00:00:00 2001
From: Anthony LC
Date: Tue, 21 Jan 2025 14:16:00 +0100
Subject: [PATCH 01/16] =?UTF-8?q?=F0=9F=94=A7(backend)=20add=20posthog=20c?=
=?UTF-8?q?onfiguration?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
We add the posthog configuration to the project.
We will expose the posthog configuration to the
frontend.
---
src/backend/core/api/viewsets.py | 1 +
src/backend/core/tests/test_api_config.py | 2 ++
src/backend/impress/settings.py | 5 +++++
3 files changed, 8 insertions(+)
diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py
index 0f83e1e5..02b0f277 100644
--- a/src/backend/core/api/viewsets.py
+++ b/src/backend/core/api/viewsets.py
@@ -1124,6 +1124,7 @@ class ConfigView(drf.views.APIView):
"ENVIRONMENT",
"FRONTEND_THEME",
"MEDIA_BASE_URL",
+ "POSTHOG_KEY",
"LANGUAGES",
"LANGUAGE_CODE",
"SENTRY_DSN",
diff --git a/src/backend/core/tests/test_api_config.py b/src/backend/core/tests/test_api_config.py
index a5eb151f..a625260e 100644
--- a/src/backend/core/tests/test_api_config.py
+++ b/src/backend/core/tests/test_api_config.py
@@ -20,6 +20,7 @@ pytestmark = pytest.mark.django_db
CRISP_WEBSITE_ID="123",
FRONTEND_THEME="test-theme",
MEDIA_BASE_URL="http://testserver/",
+ POSTHOG_KEY={"id": "132456", "host": "https://eu.i.posthog-test.com"},
SENTRY_DSN="https://sentry.test/123",
)
@pytest.mark.parametrize("is_authenticated", [False, True])
@@ -41,5 +42,6 @@ def test_api_config(is_authenticated):
"LANGUAGES": [["en-us", "English"], ["fr-fr", "French"], ["de-de", "German"]],
"LANGUAGE_CODE": "en-us",
"MEDIA_BASE_URL": "http://testserver/",
+ "POSTHOG_KEY": {"id": "132456", "host": "https://eu.i.posthog-test.com"},
"SENTRY_DSN": "https://sentry.test/123",
}
diff --git a/src/backend/impress/settings.py b/src/backend/impress/settings.py
index 495ec4bc..df1cd39b 100755
--- a/src/backend/impress/settings.py
+++ b/src/backend/impress/settings.py
@@ -390,6 +390,11 @@ class Base(Configuration):
None, environ_name="FRONTEND_THEME", environ_prefix=None
)
+ # Posthog
+ POSTHOG_KEY = values.DictValue(
+ None, environ_name="POSTHOG_KEY", environ_prefix=None
+ )
+
# Crisp
CRISP_WEBSITE_ID = values.Value(
None, environ_name="CRISP_WEBSITE_ID", environ_prefix=None
From 97cfa2c1ad13f0051954c87cc3ca259a98dfec8a Mon Sep 17 00:00:00 2001
From: Anthony LC
Date: Tue, 21 Jan 2025 14:18:44 +0100
Subject: [PATCH 02/16] =?UTF-8?q?=E2=9C=A8(frontend)=20integrate=20posthog?=
=?UTF-8?q?=20analytics?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
We integrate posthog, it will help us to track
user behavior and improve the product.
We get the configuration from the backend config
endpoint.
---
.../e2e/__tests__/app-impress/config.spec.ts | 1 +
src/frontend/apps/impress/package.json | 1 +
.../src/core/config/ConfigProvider.tsx | 4 +-
.../impress/src/core/config/api/useConfig.tsx | 2 +
.../apps/impress/src/services/Posthog.tsx | 46 +++++++++++++++++++
.../apps/impress/src/services/index.ts | 1 +
src/frontend/yarn.lock | 27 ++++++++++-
7 files changed, 79 insertions(+), 3 deletions(-)
create mode 100644 src/frontend/apps/impress/src/services/Posthog.tsx
diff --git a/src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts
index cbc9a025..042c6287 100644
--- a/src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts
+++ b/src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts
@@ -16,6 +16,7 @@ const config = {
['de-de', 'German'],
],
LANGUAGE_CODE: 'en-us',
+ POSTHOG_KEY: {},
SENTRY_DSN: null,
};
diff --git a/src/frontend/apps/impress/package.json b/src/frontend/apps/impress/package.json
index 658588e5..7ae5efa6 100644
--- a/src/frontend/apps/impress/package.json
+++ b/src/frontend/apps/impress/package.json
@@ -31,6 +31,7 @@
"lodash": "4.17.21",
"luxon": "3.5.0",
"next": "15.1.3",
+ "posthog-js": "1.204.0",
"react": "*",
"react-aria-components": "1.5.0",
"react-dom": "*",
diff --git a/src/frontend/apps/impress/src/core/config/ConfigProvider.tsx b/src/frontend/apps/impress/src/core/config/ConfigProvider.tsx
index 57033110..8d021b14 100644
--- a/src/frontend/apps/impress/src/core/config/ConfigProvider.tsx
+++ b/src/frontend/apps/impress/src/core/config/ConfigProvider.tsx
@@ -3,7 +3,7 @@ import { PropsWithChildren, useEffect } from 'react';
import { Box } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
-import { configureCrispSession } from '@/services';
+import { PostHogProvider, configureCrispSession } from '@/services';
import { useSentryStore } from '@/stores/useSentryStore';
import { useConfig } from './api/useConfig';
@@ -45,5 +45,5 @@ export const ConfigProvider = ({ children }: PropsWithChildren) => {
);
}
- return children;
+ return {children};
};
diff --git a/src/frontend/apps/impress/src/core/config/api/useConfig.tsx b/src/frontend/apps/impress/src/core/config/api/useConfig.tsx
index 5cb6d3a9..7f26e9a8 100644
--- a/src/frontend/apps/impress/src/core/config/api/useConfig.tsx
+++ b/src/frontend/apps/impress/src/core/config/api/useConfig.tsx
@@ -2,6 +2,7 @@ import { useQuery } from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { Theme } from '@/cunningham/';
+import { PostHogConf } from '@/services';
interface ConfigResponse {
LANGUAGES: [string, string][];
@@ -11,6 +12,7 @@ interface ConfigResponse {
CRISP_WEBSITE_ID?: string;
FRONTEND_THEME?: Theme;
MEDIA_BASE_URL?: string;
+ POSTHOG_KEY?: PostHogConf;
SENTRY_DSN?: string;
}
diff --git a/src/frontend/apps/impress/src/services/Posthog.tsx b/src/frontend/apps/impress/src/services/Posthog.tsx
new file mode 100644
index 00000000..a8b83719
--- /dev/null
+++ b/src/frontend/apps/impress/src/services/Posthog.tsx
@@ -0,0 +1,46 @@
+import { Router } from 'next/router';
+import posthog from 'posthog-js';
+import { PostHogProvider as PHProvider } from 'posthog-js/react';
+import { PropsWithChildren, useEffect } from 'react';
+
+export interface PostHogConf {
+ id: string;
+ host: string;
+}
+
+interface PostHogProviderProps {
+ conf?: PostHogConf;
+}
+
+export function PostHogProvider({
+ children,
+ conf,
+}: PropsWithChildren) {
+ useEffect(() => {
+ if (!conf?.id || !conf?.host || posthog.__loaded) {
+ return;
+ }
+
+ posthog.init(conf.id, {
+ api_host: conf.host,
+ person_profiles: 'always',
+ loaded: (posthog) => {
+ if (process.env.NODE_ENV === 'development') {
+ posthog.debug();
+ }
+ },
+ capture_pageview: false,
+ capture_pageleave: true,
+ });
+
+ const handleRouteChange = () => posthog?.capture('$pageview');
+
+ Router.events.on('routeChangeComplete', handleRouteChange);
+
+ return () => {
+ Router.events.off('routeChangeComplete', handleRouteChange);
+ };
+ }, [conf?.host, conf?.id]);
+
+ return {children};
+}
diff --git a/src/frontend/apps/impress/src/services/index.ts b/src/frontend/apps/impress/src/services/index.ts
index 08bbf631..967ebd48 100644
--- a/src/frontend/apps/impress/src/services/index.ts
+++ b/src/frontend/apps/impress/src/services/index.ts
@@ -1 +1,2 @@
export * from './Crisp';
+export * from './Posthog';
diff --git a/src/frontend/yarn.lock b/src/frontend/yarn.lock
index 14bdb369..8b5d18b3 100644
--- a/src/frontend/yarn.lock
+++ b/src/frontend/yarn.lock
@@ -6286,7 +6286,7 @@ core-js-compat@^3.38.0, core-js-compat@^3.38.1:
dependencies:
browserslist "^4.24.2"
-core-js@^3.0.0:
+core-js@^3.0.0, core-js@^3.38.1:
version "3.39.0"
resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.39.0.tgz#57f7647f4d2d030c32a72ea23a0555b2eaa30f83"
integrity sha512-raM0ew0/jJUqkJ0E6e8UDtl+y/7ktFivgWvqw8dNSQeNWoSDLvQ1H/RN3aPXB9tBd4/FhyR4RDPGhsNIMsAn7g==
@@ -7536,6 +7536,11 @@ fetch-mock@9.11.0:
querystring "^0.2.0"
whatwg-url "^6.5.0"
+fflate@^0.4.8:
+ version "0.4.8"
+ resolved "https://registry.yarnpkg.com/fflate/-/fflate-0.4.8.tgz#f90b82aefbd8ac174213abb338bd7ef848f0f5ae"
+ integrity sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==
+
figlet@1.7.0:
version "1.7.0"
resolved "https://registry.yarnpkg.com/figlet/-/figlet-1.7.0.tgz#46903a04603fd19c3e380358418bb2703587a72e"
@@ -10921,6 +10926,21 @@ postgres-range@^1.1.1:
resolved "https://registry.yarnpkg.com/postgres-range/-/postgres-range-1.1.4.tgz#a59c5f9520909bcec5e63e8cf913a92e4c952863"
integrity sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==
+posthog-js@1.204.0:
+ version "1.204.0"
+ resolved "https://registry.yarnpkg.com/posthog-js/-/posthog-js-1.204.0.tgz#73843af471fcc484ca1e8e1bcc927887cf81b4ba"
+ integrity sha512-wVt948wKPPztCZ3OeDq8y0dtaPbhbY8vFuEVBUNHOn7PohbTXr7HZ4CNhH8fXgFkx5COEzz/20wWJmEsSU5oCA==
+ dependencies:
+ core-js "^3.38.1"
+ fflate "^0.4.8"
+ preact "^10.19.3"
+ web-vitals "^4.2.0"
+
+preact@^10.19.3:
+ version "10.25.4"
+ resolved "https://registry.yarnpkg.com/preact/-/preact-10.25.4.tgz#c1d00bee9d7b9dcd06a2311d9951973b506ae8ac"
+ integrity sha512-jLdZDb+Q+odkHJ+MpW/9U5cODzqnB+fy2EiHSZES7ldV5LK7yjlVzTp7R8Xy6W6y75kfK8iWYtFVH7lvjwrCMA==
+
prelude-ls@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396"
@@ -13622,6 +13642,11 @@ web-namespaces@^2.0.0:
resolved "https://registry.yarnpkg.com/web-namespaces/-/web-namespaces-2.0.1.tgz#1010ff7c650eccb2592cebeeaf9a1b253fd40692"
integrity sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==
+web-vitals@^4.2.0:
+ version "4.2.4"
+ resolved "https://registry.yarnpkg.com/web-vitals/-/web-vitals-4.2.4.tgz#1d20bc8590a37769bd0902b289550936069184b7"
+ integrity sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==
+
webidl-conversions@^3.0.0:
version "3.0.1"
resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871"
From b8be01038948b8b60a128b985d8a70c911708924 Mon Sep 17 00:00:00 2001
From: Anthony LC
Date: Mon, 27 Jan 2025 15:41:39 +0100
Subject: [PATCH 03/16] =?UTF-8?q?=F0=9F=9A=9A(helm)=20add=20posthog=20prox?=
=?UTF-8?q?y?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
To contourn ads blocker, we add a proxy to the
posthog service. This way, we can access the
service from the same domain as the frontend.
---
CHANGELOG.md | 2 +
docs/examples/impress.values.yaml | 7 ++
src/helm/env.d/dev/values.impress.yaml.gotmpl | 7 ++
src/helm/impress/Chart.yaml | 2 +-
src/helm/impress/README.md | 31 +++++++
src/helm/impress/templates/_helpers.tpl | 9 ++
.../impress/templates/ingress_posthog.yaml | 86 +++++++++++++++++++
.../templates/ingress_posthog_assets.yaml | 66 ++++++++++++++
.../impress/templates/posthog_assets_svc.yaml | 24 ++++++
src/helm/impress/templates/posthog_svc.yaml | 24 ++++++
src/helm/impress/values.yaml | 71 +++++++++++++++
11 files changed, 328 insertions(+), 1 deletion(-)
create mode 100644 src/helm/impress/templates/ingress_posthog.yaml
create mode 100644 src/helm/impress/templates/ingress_posthog_assets.yaml
create mode 100644 src/helm/impress/templates/posthog_assets_svc.yaml
create mode 100644 src/helm/impress/templates/posthog_svc.yaml
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c4b6ccc3..0805656a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,8 @@ and this project adheres to
## Added
- github actions to managed Crowdin workflow
+- 📈Integrate Posthog #540
+
## Changed
diff --git a/docs/examples/impress.values.yaml b/docs/examples/impress.values.yaml
index e21e7ec3..bc090450 100644
--- a/docs/examples/impress.values.yaml
+++ b/docs/examples/impress.values.yaml
@@ -40,6 +40,7 @@ backend:
LOGIN_REDIRECT_URL: https://impress.127.0.0.1.nip.io
LOGIN_REDIRECT_URL_FAILURE: https://impress.127.0.0.1.nip.io
LOGOUT_REDIRECT_URL: https://impress.127.0.0.1.nip.io
+ POSTHOG_KEY: "{'id': 'posthog_key', 'host': 'https://product.impress.127.0.0.1.nip.io'}"
DB_HOST: postgresql
DB_NAME: impress
DB_USER: dinum
@@ -121,6 +122,12 @@ yProvider:
COLLABORATION_SERVER_SECRET: my-secret
Y_PROVIDER_API_KEY: my-secret
+posthog:
+ ingress:
+ enabled: false
+ ingressAssets:
+ enabled: false
+
ingress:
enabled: true
host: impress.127.0.0.1.nip.io
diff --git a/src/helm/env.d/dev/values.impress.yaml.gotmpl b/src/helm/env.d/dev/values.impress.yaml.gotmpl
index 016f8f0e..df9f9fce 100644
--- a/src/helm/env.d/dev/values.impress.yaml.gotmpl
+++ b/src/helm/env.d/dev/values.impress.yaml.gotmpl
@@ -148,6 +148,13 @@ ingressAdmin:
enabled: true
host: impress.127.0.0.1.nip.io
+posthog:
+ ingress:
+ enabled: false
+
+ ingressAssets:
+ enabled: false
+
ingressMedia:
enabled: true
host: impress.127.0.0.1.nip.io
diff --git a/src/helm/impress/Chart.yaml b/src/helm/impress/Chart.yaml
index da70e774..749855d0 100644
--- a/src/helm/impress/Chart.yaml
+++ b/src/helm/impress/Chart.yaml
@@ -1,5 +1,5 @@
apiVersion: v2
type: application
name: docs
-version: 0.0.2
+version: 2.0.1-beta.7
appVersion: latest
diff --git a/src/helm/impress/README.md b/src/helm/impress/README.md
index 19b86429..6b5587ad 100644
--- a/src/helm/impress/README.md
+++ b/src/helm/impress/README.md
@@ -176,6 +176,37 @@
| `frontend.extraVolumeMounts` | Additional volumes to mount on the frontend. | `[]` |
| `frontend.extraVolumes` | Additional volumes to mount on the frontend. | `[]` |
+### posthog
+
+| Name | Description | Value |
+| -------------------------------------- | ----------------------------------------------------------- | ------------------------- |
+| `posthog.ingress.enabled` | Enable or disable the ingress resource creation | `false` |
+| `posthog.ingress.className` | Kubernetes ingress class name to use (e.g., nginx, traefik) | `nil` |
+| `posthog.ingress.host` | Primary hostname for the ingress resource | `impress.example.com` |
+| `posthog.ingress.path` | URL path prefix for the ingress routes (e.g., /) | `/` |
+| `posthog.ingress.hosts` | Additional hostnames array to be included in the ingress | `[]` |
+| `posthog.ingress.tls.enabled` | Enable or disable TLS/HTTPS for the ingress | `true` |
+| `posthog.ingress.tls.additional` | Additional TLS configurations for extra hosts/certificates | `[]` |
+| `posthog.ingress.customBackends` | Custom backend service configurations for the ingress | `[]` |
+| `posthog.ingress.annotations` | Additional Kubernetes annotations to apply to the ingress | `{}` |
+| `posthog.ingressAssets.enabled` | Enable or disable the ingress resource creation | `false` |
+| `posthog.ingressAssets.className` | Kubernetes ingress class name to use (e.g., nginx, traefik) | `nil` |
+| `posthog.ingressAssets.host` | Primary hostname for the ingress resource | `impress.example.com` |
+| `posthog.ingressAssets.paths` | URL paths prefix for the ingress routes (e.g., /static) | `["/static","/array"]` |
+| `posthog.ingressAssets.hosts` | Additional hostnames array to be included in the ingress | `[]` |
+| `posthog.ingressAssets.tls.enabled` | Enable or disable TLS/HTTPS for the ingress | `true` |
+| `posthog.ingressAssets.tls.additional` | Additional TLS configurations for extra hosts/certificates | `[]` |
+| `posthog.ingressAssets.customBackends` | Custom backend service configurations for the ingress | `[]` |
+| `posthog.ingressAssets.annotations` | Additional Kubernetes annotations to apply to the ingress | `{}` |
+| `posthog.service.type` | Service type (e.g. ExternalName, ClusterIP, LoadBalancer) | `ExternalName` |
+| `posthog.service.externalName` | External service hostname when type is ExternalName | `eu.i.posthog.com` |
+| `posthog.service.port` | Port number for the service | `443` |
+| `posthog.service.annotations` | Additional annotations to apply to the service | `{}` |
+| `posthog.assetsService.type` | Service type (e.g. ExternalName, ClusterIP, LoadBalancer) | `ExternalName` |
+| `posthog.assetsService.externalName` | External service hostname when type is ExternalName | `eu-assets.i.posthog.com` |
+| `posthog.assetsService.port` | Port number for the service | `443` |
+| `posthog.assetsService.annotations` | Additional annotations to apply to the service | `{}` |
+
### yProvider
| Name | Description | Value |
diff --git a/src/helm/impress/templates/_helpers.tpl b/src/helm/impress/templates/_helpers.tpl
index 63188f3b..b56b9892 100644
--- a/src/helm/impress/templates/_helpers.tpl
+++ b/src/helm/impress/templates/_helpers.tpl
@@ -148,6 +148,15 @@ Requires top level scope
{{ include "impress.fullname" . }}-frontend
{{- end }}
+{{/*
+Full name for the Posthog
+
+Requires top level scope
+*/}}
+{{- define "impress.posthog.fullname" -}}
+{{ include "impress.fullname" . }}-posthog
+{{- end }}
+
{{/*
Full name for the yProvider
diff --git a/src/helm/impress/templates/ingress_posthog.yaml b/src/helm/impress/templates/ingress_posthog.yaml
new file mode 100644
index 00000000..2c93725e
--- /dev/null
+++ b/src/helm/impress/templates/ingress_posthog.yaml
@@ -0,0 +1,86 @@
+{{- if .Values.posthog.ingress.enabled -}}
+{{- $fullName := include "impress.fullname" . -}}
+{{- if and .Values.posthog.ingress.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }}
+ {{- if not (hasKey .Values.posthog.ingress.annotations "kubernetes.io/ingress.class") }}
+ {{- $_ := set .Values.posthog.ingress.annotations "kubernetes.io/ingress.class" .Values.posthog.ingress.className}}
+ {{- end }}
+{{- end }}
+{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}}
+apiVersion: networking.k8s.io/v1
+{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}}
+apiVersion: networking.k8s.io/v1beta1
+{{- else -}}
+apiVersion: extensions/v1beta1
+{{- end }}
+kind: Ingress
+metadata:
+ name: {{ $fullName }}-posthog
+ namespace: {{ .Release.Namespace | quote }}
+ labels:
+ {{- include "impress.labels" . | nindent 4 }}
+ {{- with .Values.posthog.ingress.annotations }}
+ annotations:
+ {{- toYaml . | nindent 4 }}
+ {{- end }}
+spec:
+ {{- if and .Values.posthog.ingress.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }}
+ ingressClassName: {{ .Values.posthog.ingress.className }}
+ {{- end }}
+ {{- if .Values.posthog.ingress.tls.enabled }}
+ tls:
+ {{- if .Values.posthog.ingress.host }}
+ - secretName: {{ $fullName }}-posthog-tls
+ hosts:
+ - {{ .Values.posthog.ingress.host | quote }}
+ {{- end }}
+ {{- range .Values.posthog.ingress.tls.additional }}
+ - hosts:
+ {{- range .hosts }}
+ - {{ . | quote }}
+ {{- end }}
+ secretName: {{ .secretName }}
+ {{- end }}
+ {{- end }}
+ rules:
+ {{- if .Values.posthog.ingress.host }}
+ - host: {{ .Values.posthog.ingress.host | quote }}
+ http:
+ paths:
+ - path: {{ .Values.posthog.ingress.path }}
+ {{- if semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion }}
+ pathType: Prefix
+ {{- end }}
+ backend:
+ {{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
+ service:
+ name: {{ include "impress.posthog.fullname" . }}-proxy
+ port:
+ number: {{ .Values.posthog.service.port }}
+ {{- else }}
+ serviceName: {{ include "impress.posthog.fullname" . }}-proxy
+ servicePort: {{ .Values.posthog.service.port }}
+ {{- end }}
+ {{- end }}
+ {{- range .Values.posthog.ingress.hosts }}
+ - host: {{ . | quote }}
+ http:
+ paths:
+ - path: {{ $.Values.posthog.ingress.path | quote }}
+ {{- if semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion }}
+ pathType: Prefix
+ {{- end }}
+ backend:
+ {{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
+ service:
+ name: {{ include "impress.posthog.fullname" . }}-proxy
+ port:
+ number: {{ $.Values.posthog.service.port }}
+ {{- else }}
+ serviceName: {{ include "impress.posthog.fullname" . }}-proxy
+ servicePort: {{ $.Values.posthog.service.port }}
+ {{- end }}
+ {{- with $.Values.posthog.service.customBackends }}
+ {{- toYaml . | nindent 10 }}
+ {{- end }}
+ {{- end }}
+{{- end }}
diff --git a/src/helm/impress/templates/ingress_posthog_assets.yaml b/src/helm/impress/templates/ingress_posthog_assets.yaml
new file mode 100644
index 00000000..4a49bfa5
--- /dev/null
+++ b/src/helm/impress/templates/ingress_posthog_assets.yaml
@@ -0,0 +1,66 @@
+{{- if .Values.posthog.ingressAssets.enabled -}}
+{{- $fullName := include "impress.fullname" . -}}
+{{- if and .Values.posthog.ingressAssets.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }}
+ {{- if not (hasKey .Values.posthog.ingressAssets.annotations "kubernetes.io/ingress.class") }}
+ {{- $_ := set .Values.posthog.ingressAssets.annotations "kubernetes.io/ingress.class" .Values.posthog.ingressAssets.className}}
+ {{- end }}
+{{- end }}
+{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}}
+apiVersion: networking.k8s.io/v1
+{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}}
+apiVersion: networking.k8s.io/v1beta1
+{{- else -}}
+apiVersion: extensions/v1beta1
+{{- end }}
+kind: Ingress
+metadata:
+ name: {{ $fullName }}-posthog-assets
+ namespace: {{ .Release.Namespace | quote }}
+ labels:
+ {{- include "impress.labels" . | nindent 4 }}
+ {{- with .Values.posthog.ingressAssets.annotations }}
+ annotations:
+ {{- toYaml . | nindent 4 }}
+ {{- end }}
+spec:
+ {{- if and .Values.posthog.ingressAssets.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }}
+ ingressClassName: {{ .Values.posthog.ingressAssets.className }}
+ {{- end }}
+ {{- if .Values.posthog.ingressAssets.tls.enabled }}
+ tls:
+ {{- if .Values.posthog.ingressAssets.host }}
+ - secretName: {{ $fullName }}-posthog-tls
+ hosts:
+ - {{ .Values.posthog.ingressAssets.host | quote }}
+ {{- end }}
+ {{- range .Values.posthog.ingressAssets.tls.additional }}
+ - hosts:
+ {{- range .hosts }}
+ - {{ . | quote }}
+ {{- end }}
+ secretName: {{ .secretName }}
+ {{- end }}
+ {{- end }}
+ rules:
+ {{- if .Values.posthog.ingressAssets.host }}
+ - host: {{ .Values.posthog.ingressAssets.host | quote }}
+ http:
+ paths:
+ {{- range .Values.posthog.ingressAssets.paths }}
+ - path: {{ . | quote }}
+ {{- if semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion }}
+ pathType: Prefix
+ {{- end }}
+ backend:
+ {{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
+ service:
+ name: {{ include "impress.posthog.fullname" $ }}-assets-proxy
+ port:
+ number: {{ $.Values.posthog.assetsService.port }}
+ {{- else }}
+ serviceName: {{ include "impress.posthog.fullname" $ }}-assets-proxy
+ servicePort: {{ $.Values.posthog.assetsService.port }}
+ {{- end }}
+ {{- end }}
+ {{- end }}
+{{- end }}
diff --git a/src/helm/impress/templates/posthog_assets_svc.yaml b/src/helm/impress/templates/posthog_assets_svc.yaml
new file mode 100644
index 00000000..d80ea3b3
--- /dev/null
+++ b/src/helm/impress/templates/posthog_assets_svc.yaml
@@ -0,0 +1,24 @@
+{{- if .Values.posthog.ingressAssets.enabled -}}
+{{- $envVars := include "impress.common.env" (list . .Values.posthog) -}}
+{{- $fullName := include "impress.posthog.fullname" . -}}
+{{- $component := "posthog" -}}
+apiVersion: v1
+kind: Service
+metadata:
+ name: {{ $fullName }}-assets-proxy
+ namespace: {{ .Release.Namespace | quote }}
+ labels:
+ {{- include "impress.common.labels" (list . $component) | nindent 4 }}
+ annotations:
+ {{- toYaml $.Values.posthog.assetsService.annotations | nindent 4 }}
+spec:
+ type: {{ .Values.posthog.assetsService.type }}
+ externalName: {{ .Values.posthog.assetsService.externalName }}
+ ports:
+ - port: {{ .Values.posthog.assetsService.port }}
+ targetPort: {{ .Values.posthog.assetsService.targetPort }}
+ protocol: TCP
+ name: https
+ selector:
+ {{- include "impress.common.selectorLabels" (list . $component) | nindent 4 }}
+{{- end }}
diff --git a/src/helm/impress/templates/posthog_svc.yaml b/src/helm/impress/templates/posthog_svc.yaml
new file mode 100644
index 00000000..1fae600b
--- /dev/null
+++ b/src/helm/impress/templates/posthog_svc.yaml
@@ -0,0 +1,24 @@
+{{- if .Values.posthog.ingress.enabled -}}
+{{- $envVars := include "impress.common.env" (list . .Values.posthog) -}}
+{{- $fullName := include "impress.posthog.fullname" . -}}
+{{- $component := "posthog" -}}
+apiVersion: v1
+kind: Service
+metadata:
+ name: {{ $fullName }}-proxy
+ namespace: {{ .Release.Namespace | quote }}
+ labels:
+ {{- include "impress.common.labels" (list . $component) | nindent 4 }}
+ annotations:
+ {{- toYaml $.Values.posthog.service.annotations | nindent 4 }}
+spec:
+ type: {{ .Values.posthog.service.type }}
+ externalName: {{ .Values.posthog.service.externalName }}
+ ports:
+ - port: {{ .Values.posthog.service.port }}
+ targetPort: {{ .Values.posthog.service.targetPort }}
+ protocol: TCP
+ name: https
+ selector:
+ {{- include "impress.common.selectorLabels" (list . $component) | nindent 4 }}
+{{- end }}
diff --git a/src/helm/impress/values.yaml b/src/helm/impress/values.yaml
index fdaffd7a..9f6924bb 100644
--- a/src/helm/impress/values.yaml
+++ b/src/helm/impress/values.yaml
@@ -390,6 +390,77 @@ frontend:
## @param frontend.extraVolumes Additional volumes to mount on the frontend.
extraVolumes: []
+## @section posthog
+
+posthog:
+
+ ## @param posthog.ingress.enabled Enable or disable the ingress resource creation
+ ## @param posthog.ingress.className Kubernetes ingress class name to use (e.g., nginx, traefik)
+ ## @param posthog.ingress.host Primary hostname for the ingress resource
+ ## @param posthog.ingress.path URL path prefix for the ingress routes (e.g., /)
+ ## @param posthog.ingress.hosts Additional hostnames array to be included in the ingress
+ ## @param posthog.ingress.tls.enabled Enable or disable TLS/HTTPS for the ingress
+ ## @param posthog.ingress.tls.additional Additional TLS configurations for extra hosts/certificates
+ ## @param posthog.ingress.customBackends Custom backend service configurations for the ingress
+ ## @param posthog.ingress.annotations Additional Kubernetes annotations to apply to the ingress
+ ingress:
+ enabled: false
+ className: null
+ host: impress.example.com
+ path: /
+ hosts: [ ]
+ tls:
+ enabled: true
+ additional: [ ]
+
+ customBackends: [ ]
+ annotations: {}
+
+ ## @param posthog.ingressAssets.enabled Enable or disable the ingress resource creation
+ ## @param posthog.ingressAssets.className Kubernetes ingress class name to use (e.g., nginx, traefik)
+ ## @param posthog.ingressAssets.host Primary hostname for the ingress resource
+ ## @param posthog.ingressAssets.paths URL paths prefix for the ingress routes (e.g., /static)
+ ## @param posthog.ingressAssets.hosts Additional hostnames array to be included in the ingress
+ ## @param posthog.ingressAssets.tls.enabled Enable or disable TLS/HTTPS for the ingress
+ ## @param posthog.ingressAssets.tls.additional Additional TLS configurations for extra hosts/certificates
+ ## @param posthog.ingressAssets.customBackends Custom backend service configurations for the ingress
+ ## @param posthog.ingressAssets.annotations Additional Kubernetes annotations to apply to the ingress
+ ingressAssets:
+ enabled: false
+ className: null
+ host: impress.example.com
+ paths:
+ - /static
+ - /array
+ hosts: [ ]
+ tls:
+ enabled: true
+ additional: [ ]
+
+ customBackends: [ ]
+ annotations: {}
+
+ ## @param posthog.service.type Service type (e.g. ExternalName, ClusterIP, LoadBalancer)
+ ## @param posthog.service.externalName External service hostname when type is ExternalName
+ ## @param posthog.service.port Port number for the service
+ ## @param posthog.service.annotations Additional annotations to apply to the service
+ service:
+ type: ExternalName
+ externalName: eu.i.posthog.com
+ port: 443
+ annotations: {}
+
+ ## @param posthog.assetsService.type Service type (e.g. ExternalName, ClusterIP, LoadBalancer)
+ ## @param posthog.assetsService.externalName External service hostname when type is ExternalName
+ ## @param posthog.assetsService.port Port number for the service
+ ## @param posthog.assetsService.annotations Additional annotations to apply to the service
+ assetsService:
+ type: ExternalName
+ externalName: eu-assets.i.posthog.com
+ port: 443
+ annotations: {}
+
+
## @section yProvider
yProvider:
From 5b4b100e9072413f98551b08fbf4970f9469d97a Mon Sep 17 00:00:00 2001
From: Anthony LC
Date: Wed, 15 Jan 2025 15:58:46 +0100
Subject: [PATCH 04/16] =?UTF-8?q?=F0=9F=8F=B7=EF=B8=8F(backend)=20add=20co?=
=?UTF-8?q?ntent-type=20to=20uploaded=20files?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
All the uploaded files had the content-type set
to `application/octet-stream`. It create issues
when the file is downloaded from the frontend
because the browser doesn't know how to handle
the file.
We now determine the content-type of the file
and set it to the file object.
---
.github/workflows/impress.yml | 5 +--
CHANGELOG.md | 2 +-
Dockerfile | 2 ++
src/backend/core/api/serializers.py | 2 ++
src/backend/core/api/viewsets.py | 5 ++-
.../test_api_documents_attachment_upload.py | 31 ++++++++++++++-----
6 files changed, 35 insertions(+), 12 deletions(-)
diff --git a/.github/workflows/impress.yml b/.github/workflows/impress.yml
index 896f07ef..385aa633 100644
--- a/.github/workflows/impress.yml
+++ b/.github/workflows/impress.yml
@@ -206,10 +206,11 @@ jobs:
- name: Install development dependencies
run: pip install --user .[dev]
- - name: Install gettext (required to compile messages)
+ - name: Install gettext (required to compile messages) and MIME support
run: |
sudo apt-get update
- sudo apt-get install -y gettext pandoc
+ sudo apt-get install -y gettext pandoc shared-mime-info
+ sudo wget https://svn.apache.org/repos/asf/httpd/httpd/trunk/docs/conf/mime.types -O /etc/mime.types
- name: Generate a MO file from strings extracted from the project
run: python manage.py compilemessages
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0805656a..da6a6826 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,7 +13,7 @@ and this project adheres to
- github actions to managed Crowdin workflow
- 📈Integrate Posthog #540
-
+- 🏷️(backend) add content-type to uploaded files #552
## Changed
diff --git a/Dockerfile b/Dockerfile
index 941f0747..6547f0b6 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -76,6 +76,8 @@ RUN apk add \
pango \
shared-mime-info
+RUN wget https://svn.apache.org/repos/asf/httpd/httpd/trunk/docs/conf/mime.types -O /etc/mime.types
+
# Copy entrypoint
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py
index d5b0d4c7..e2369f49 100644
--- a/src/backend/core/api/serializers.py
+++ b/src/backend/core/api/serializers.py
@@ -388,6 +388,7 @@ class FileUploadSerializer(serializers.Serializer):
raise serializers.ValidationError("Could not determine file extension.")
self.context["expected_extension"] = extension
+ self.context["content_type"] = magic_mime_type
return file
@@ -395,6 +396,7 @@ class FileUploadSerializer(serializers.Serializer):
"""Override validate to add the computed extension to validated_data."""
attrs["expected_extension"] = self.context["expected_extension"]
attrs["is_unsafe"] = self.context["is_unsafe"]
+ attrs["content_type"] = self.context["content_type"]
return attrs
diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py
index 02b0f277..10adee35 100644
--- a/src/backend/core/api/viewsets.py
+++ b/src/backend/core/api/viewsets.py
@@ -605,7 +605,10 @@ class DocumentViewSet(
key = f"{document.key_base}/{ATTACHMENTS_FOLDER:s}/{file_id!s}.{extension:s}"
# Prepare metadata for storage
- extra_args = {"Metadata": {"owner": str(request.user.id)}}
+ extra_args = {
+ "Metadata": {"owner": str(request.user.id)},
+ "ContentType": serializer.validated_data["content_type"],
+ }
if serializer.validated_data["is_unsafe"]:
extra_args["Metadata"]["is_unsafe"] = "true"
diff --git a/src/backend/core/tests/documents/test_api_documents_attachment_upload.py b/src/backend/core/tests/documents/test_api_documents_attachment_upload.py
index 1288f8ca..4a6564d6 100644
--- a/src/backend/core/tests/documents/test_api_documents_attachment_upload.py
+++ b/src/backend/core/tests/documents/test_api_documents_attachment_upload.py
@@ -64,12 +64,22 @@ def test_api_documents_attachment_upload_anonymous_success():
assert response.status_code == 201
pattern = re.compile(rf"^/media/{document.id!s}/attachments/(.*)\.png")
- match = pattern.search(response.json()["file"])
+ file_path = response.json()["file"]
+ match = pattern.search(file_path)
file_id = match.group(1)
# Validate that file_id is a valid UUID
uuid.UUID(file_id)
+ # Now, check the metadata of the uploaded file
+ key = file_path.replace("/media", "")
+ file_head = default_storage.connection.meta.client.head_object(
+ Bucket=default_storage.bucket_name, Key=key
+ )
+
+ assert file_head["Metadata"] == {"owner": "None"}
+ assert file_head["ContentType"] == "image/png"
+
@pytest.mark.parametrize(
"reach, role",
@@ -206,6 +216,7 @@ def test_api_documents_attachment_upload_success(via, role, mock_user_teams):
Bucket=default_storage.bucket_name, Key=key
)
assert file_head["Metadata"] == {"owner": str(user.id)}
+ assert file_head["ContentType"] == "image/png"
def test_api_documents_attachment_upload_invalid(client):
@@ -247,16 +258,18 @@ def test_api_documents_attachment_upload_size_limit_exceeded(settings):
@pytest.mark.parametrize(
- "name,content,extension",
+ "name,content,extension,content_type",
[
- ("test.exe", b"text", "exe"),
- ("test", b"text", "txt"),
- ("test.aaaaaa", b"test", "txt"),
- ("test.txt", PIXEL, "txt"),
- ("test.py", b"#!/usr/bin/python", "py"),
+ ("test.exe", b"text", "exe", "text/plain"),
+ ("test", b"text", "txt", "text/plain"),
+ ("test.aaaaaa", b"test", "txt", "text/plain"),
+ ("test.txt", PIXEL, "txt", "image/png"),
+ ("test.py", b"#!/usr/bin/python", "py", "text/plain"),
],
)
-def test_api_documents_attachment_upload_fix_extension(name, content, extension):
+def test_api_documents_attachment_upload_fix_extension(
+ name, content, extension, content_type
+):
"""
A file with no extension or a wrong extension is accepted and the extension
is corrected in storage.
@@ -287,6 +300,7 @@ def test_api_documents_attachment_upload_fix_extension(name, content, extension)
Bucket=default_storage.bucket_name, Key=key
)
assert file_head["Metadata"] == {"owner": str(user.id), "is_unsafe": "true"}
+ assert file_head["ContentType"] == content_type
def test_api_documents_attachment_upload_empty_file():
@@ -335,3 +349,4 @@ def test_api_documents_attachment_upload_unsafe():
Bucket=default_storage.bucket_name, Key=key
)
assert file_head["Metadata"] == {"owner": str(user.id), "is_unsafe": "true"}
+ assert file_head["ContentType"] == "application/octet-stream"
From 67dc7feb98466090422add289531905f39c7665a Mon Sep 17 00:00:00 2001
From: Anthony LC
Date: Mon, 20 Jan 2025 17:20:51 +0100
Subject: [PATCH 05/16] =?UTF-8?q?=F0=9F=9A=91=EF=B8=8F(backend)=20command?=
=?UTF-8?q?=20to=20update=20attachment=20content-type?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The uploaded files in the system are missing
the content-type.
We add a command to update the content-type of
the existing uploaded files.
This command will run one time when we will deploy
to the environments.
---
src/backend/core/management/__init__.py | 0
.../core/management/commands/__init__.py | 0
.../update_files_content_type_metadata.py | 95 +++++++++++++++++++
...test_update_files_content_type_metadata.py | 50 ++++++++++
4 files changed, 145 insertions(+)
create mode 100644 src/backend/core/management/__init__.py
create mode 100644 src/backend/core/management/commands/__init__.py
create mode 100644 src/backend/core/management/commands/update_files_content_type_metadata.py
create mode 100644 src/backend/core/tests/commands/test_update_files_content_type_metadata.py
diff --git a/src/backend/core/management/__init__.py b/src/backend/core/management/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/backend/core/management/commands/__init__.py b/src/backend/core/management/commands/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/backend/core/management/commands/update_files_content_type_metadata.py b/src/backend/core/management/commands/update_files_content_type_metadata.py
new file mode 100644
index 00000000..bb2e5253
--- /dev/null
+++ b/src/backend/core/management/commands/update_files_content_type_metadata.py
@@ -0,0 +1,95 @@
+"""Management command updating the metadata for all the files in the MinIO bucket."""
+
+from django.core.files.storage import default_storage
+from django.core.management.base import BaseCommand
+
+import magic
+
+from core.models import Document
+
+# pylint: disable=too-many-locals, broad-exception-caught
+
+
+class Command(BaseCommand):
+ """Update the metadata for all the files in the MinIO bucket."""
+
+ help = __doc__
+
+ def handle(self, *args, **options):
+ """Execute management command."""
+ s3_client = default_storage.connection.meta.client
+ bucket_name = default_storage.bucket_name
+
+ mime_detector = magic.Magic(mime=True)
+
+ documents = Document.objects.all()
+ self.stdout.write(
+ f"[INFO] Found {documents.count()} documents. Starting ContentType fix..."
+ )
+
+ for doc in documents:
+ doc_id_str = str(doc.id)
+ prefix = f"{doc_id_str}/attachments/"
+ self.stdout.write(
+ f"[INFO] Processing attachments under prefix '{prefix}' ..."
+ )
+
+ continuation_token = None
+ total_updated = 0
+
+ while True:
+ list_kwargs = {"Bucket": bucket_name, "Prefix": prefix}
+ if continuation_token:
+ list_kwargs["ContinuationToken"] = continuation_token
+
+ response = s3_client.list_objects_v2(**list_kwargs)
+
+ # If no objects found under this prefix, break out of the loop
+ if "Contents" not in response:
+ break
+
+ for obj in response["Contents"]:
+ key = obj["Key"]
+
+ # Skip if it's a folder
+ if key.endswith("/"):
+ continue
+
+ try:
+ # Get existing metadata
+ head_resp = s3_client.head_object(Bucket=bucket_name, Key=key)
+
+ # Read first ~1KB for MIME detection
+ partial_obj = s3_client.get_object(
+ Bucket=bucket_name, Key=key, Range="bytes=0-1023"
+ )
+ partial_data = partial_obj["Body"].read()
+
+ # Detect MIME type
+ magic_mime_type = mime_detector.from_buffer(partial_data)
+
+ # Update ContentType
+ s3_client.copy_object(
+ Bucket=bucket_name,
+ CopySource={"Bucket": bucket_name, "Key": key},
+ Key=key,
+ ContentType=magic_mime_type,
+ Metadata=head_resp.get("Metadata", {}),
+ MetadataDirective="REPLACE",
+ )
+ total_updated += 1
+
+ except Exception as exc: # noqa
+ self.stderr.write(
+ f"[ERROR] Could not update ContentType for {key}: {exc}"
+ )
+
+ if response.get("IsTruncated"):
+ continuation_token = response.get("NextContinuationToken")
+ else:
+ break
+
+ if total_updated > 0:
+ self.stdout.write(
+ f"[INFO] -> Updated {total_updated} objects for Document {doc_id_str}."
+ )
diff --git a/src/backend/core/tests/commands/test_update_files_content_type_metadata.py b/src/backend/core/tests/commands/test_update_files_content_type_metadata.py
new file mode 100644
index 00000000..4ece3614
--- /dev/null
+++ b/src/backend/core/tests/commands/test_update_files_content_type_metadata.py
@@ -0,0 +1,50 @@
+"""
+Unit test for `update_files_content_type_metadata` command.
+"""
+
+import uuid
+
+from django.core.files.storage import default_storage
+from django.core.management import call_command
+
+import pytest
+
+from core import factories
+
+
+@pytest.mark.django_db
+def test_update_files_content_type_metadata():
+ """
+ Test that the command `update_files_content_type_metadata`
+ fixes the ContentType of attachment in the storage.
+ """
+ s3_client = default_storage.connection.meta.client
+ bucket_name = default_storage.bucket_name
+
+ # Create files with a wrong ContentType
+ keys = []
+ for _ in range(10):
+ doc_id = uuid.uuid4()
+ factories.DocumentFactory(id=doc_id)
+ key = f"{doc_id}/attachments/testfile.png"
+ keys.append(key)
+ fake_png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR..."
+ s3_client.put_object(
+ Bucket=bucket_name,
+ Key=key,
+ Body=fake_png,
+ ContentType="text/plain",
+ Metadata={"owner": "None"},
+ )
+
+ # Call the command that fixes the ContentType
+ call_command("update_files_content_type_metadata")
+
+ for key in keys:
+ head_resp = s3_client.head_object(Bucket=bucket_name, Key=key)
+ assert (
+ head_resp["ContentType"] == "image/png"
+ ), f"ContentType not fixed, got {head_resp['ContentType']!r}"
+
+ # Check that original metadata was preserved
+ assert head_resp["Metadata"].get("owner") == "None"
From 0d7d42254bda127190486ffce25388bcb275a89c Mon Sep 17 00:00:00 2001
From: Manuel Raynaud
Date: Fri, 24 Jan 2025 20:28:27 +0100
Subject: [PATCH 06/16] =?UTF-8?q?=E2=9C=A8(helm)=20add=20a=20job=20allowin?=
=?UTF-8?q?g=20to=20run=20arbitrary=20management=20command?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
For a specific deployment we may need to run a specific management
command, like the one added previously updating all files content-type.
A template is added responsible to manage this case. The job will be
created only if the backend.job.command is set.
---
src/helm/impress/Chart.yaml | 2 +-
src/helm/impress/README.md | 5 +
src/helm/impress/templates/backend_job.yml | 124 ++++++++++++++++++
...kend_job.yaml => backend_job_migrate.yaml} | 0
src/helm/impress/values.yaml | 13 ++
5 files changed, 143 insertions(+), 1 deletion(-)
create mode 100644 src/helm/impress/templates/backend_job.yml
rename src/helm/impress/templates/{backend_job.yaml => backend_job_migrate.yaml} (100%)
diff --git a/src/helm/impress/Chart.yaml b/src/helm/impress/Chart.yaml
index 749855d0..80115ecb 100644
--- a/src/helm/impress/Chart.yaml
+++ b/src/helm/impress/Chart.yaml
@@ -1,5 +1,5 @@
apiVersion: v2
type: application
name: docs
-version: 2.0.1-beta.7
+version: 2.0.1-beta.8
appVersion: latest
diff --git a/src/helm/impress/README.md b/src/helm/impress/README.md
index 6b5587ad..999dc479 100644
--- a/src/helm/impress/README.md
+++ b/src/helm/impress/README.md
@@ -104,6 +104,11 @@
| `backend.migrate.restartPolicy` | backend migrate job restart policy | `Never` |
| `backend.createsuperuser.command` | backend migrate command | `["/bin/sh","-c","python manage.py createsuperuser --email $DJANGO_SUPERUSER_EMAIL --password $DJANGO_SUPERUSER_PASSWORD\n"]` |
| `backend.createsuperuser.restartPolicy` | backend migrate job restart policy | `Never` |
+| `backend.job` | job dedicated to run a random management command, for example after a deployment | |
+| `backend.job.name` | The name to use to describe this job | `""` |
+| `backend.job.command` | The management command to execute | `[]` |
+| `backend.job.restartPolicy` | The restart policy for the job. | `Never` |
+| `backend.job.annotations` | Annotations to add to the job [default: argocd.argoproj.io/hook: PostSync] | |
| `backend.probes.liveness.path` | Configure path for backend HTTP liveness probe | `/__heartbeat__` |
| `backend.probes.liveness.targetPort` | Configure port for backend HTTP liveness probe | `undefined` |
| `backend.probes.liveness.initialDelaySeconds` | Configure initial delay for backend liveness probe | `10` |
diff --git a/src/helm/impress/templates/backend_job.yml b/src/helm/impress/templates/backend_job.yml
new file mode 100644
index 00000000..4888a904
--- /dev/null
+++ b/src/helm/impress/templates/backend_job.yml
@@ -0,0 +1,124 @@
+{{- if .Values.backend.job.command -}}
+{{- $envVars := include "impress.common.env" (list . .Values.backend) -}}
+{{- $fullName := include "impress.backend.fullname" . -}}
+{{- $component := "backend" -}}
+apiVersion: batch/v1
+kind: Job
+metadata:
+ name: {{ $fullName }}-{{ .Values.backend.job.name | default "random" | replace "_" "-" }}
+ namespace: {{ .Release.Namespace | quote }}
+ annotations:
+ argocd.argoproj.io/sync-options: Replace=true,Force=true
+ {{- with .Values.backend.job.annotations }}
+ {{- toYaml . | nindent 4 }}
+ {{- end }}
+ labels:
+ {{- include "impress.common.labels" (list . $component) | nindent 4 }}
+spec:
+ template:
+ metadata:
+ annotations:
+ {{- with .Values.backend.podAnnotations }}
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ labels:
+ {{- include "impress.common.selectorLabels" (list . $component) | nindent 8 }}
+ spec:
+ {{- if $.Values.image.credentials }}
+ imagePullSecrets:
+ - name: {{ include "impress.secret.dockerconfigjson.name" (dict "fullname" (include "impress.fullname" .) "imageCredentials" $.Values.image.credentials) }}
+ {{- end}}
+ shareProcessNamespace: {{ .Values.backend.shareProcessNamespace }}
+ containers:
+ {{- with .Values.backend.sidecars }}
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ - name: {{ .Chart.Name }}
+ image: "{{ (.Values.backend.image | default dict).repository | default .Values.image.repository }}:{{ (.Values.backend.image | default dict).tag | default .Values.image.tag }}"
+ imagePullPolicy: {{ (.Values.backend.image | default dict).pullPolicy | default .Values.image.pullPolicy }}
+ {{- with .Values.backend.job.command }}
+ command:
+ {{- toYaml . | nindent 12 }}
+ {{- end }}
+ {{- with .Values.backend.args }}
+ args:
+ {{- toYaml . | nindent 12 }}
+ {{- end }}
+ env:
+ {{- if $envVars}}
+ {{- $envVars | indent 12 }}
+ {{- end }}
+ {{- with .Values.backend.securityContext }}
+ securityContext:
+ {{- toYaml . | nindent 12 }}
+ {{- end }}
+ {{- with .Values.backend.resources }}
+ resources:
+ {{- toYaml . | nindent 12 }}
+ {{- end }}
+ volumeMounts:
+ {{- range $index, $value := .Values.mountFiles }}
+ - name: "files-{{ $index }}"
+ mountPath: {{ $value.path }}
+ subPath: content
+ {{- end }}
+ {{- range $name, $volume := .Values.backend.persistence }}
+ - name: "{{ $name }}"
+ mountPath: "{{ $volume.mountPath }}"
+ {{- end }}
+ {{- range .Values.backend.extraVolumeMounts }}
+ - name: {{ .name }}
+ mountPath: {{ .mountPath }}
+ subPath: {{ .subPath | default "" }}
+ readOnly: {{ .readOnly }}
+ {{- end }}
+ {{- with .Values.backend.nodeSelector }}
+ nodeSelector:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ {{- with .Values.backend.affinity }}
+ affinity:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ {{- with .Values.backend.tolerations }}
+ tolerations:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ restartPolicy: {{ .Values.backend.job.restartPolicy }}
+ volumes:
+ {{- range $index, $value := .Values.mountFiles }}
+ - name: "files-{{ $index }}"
+ configMap:
+ name: "{{ include "impress.fullname" $ }}-files-{{ $index }}"
+ {{- end }}
+ {{- range $name, $volume := .Values.backend.persistence }}
+ - name: "{{ $name }}"
+ {{- if eq $volume.type "emptyDir" }}
+ emptyDir: {}
+ {{- else }}
+ persistentVolumeClaim:
+ claimName: "{{ $fullName }}-{{ $name }}"
+ {{- end }}
+ {{- end }}
+ {{- range .Values.backend.extraVolumes }}
+ - name: {{ .name }}
+ {{- if .existingClaim }}
+ persistentVolumeClaim:
+ claimName: {{ .existingClaim }}
+ {{- else if .hostPath }}
+ hostPath:
+ {{ toYaml .hostPath | nindent 12 }}
+ {{- else if .csi }}
+ csi:
+ {{- toYaml .csi | nindent 12 }}
+ {{- else if .configMap }}
+ configMap:
+ {{- toYaml .configMap | nindent 12 }}
+ {{- else if .emptyDir }}
+ emptyDir:
+ {{- toYaml .emptyDir | nindent 12 }}
+ {{- else }}
+ emptyDir: {}
+ {{- end }}
+ {{- end }}
+{{- end }}
diff --git a/src/helm/impress/templates/backend_job.yaml b/src/helm/impress/templates/backend_job_migrate.yaml
similarity index 100%
rename from src/helm/impress/templates/backend_job.yaml
rename to src/helm/impress/templates/backend_job_migrate.yaml
diff --git a/src/helm/impress/values.yaml b/src/helm/impress/values.yaml
index 9f6924bb..d429937f 100644
--- a/src/helm/impress/values.yaml
+++ b/src/helm/impress/values.yaml
@@ -251,6 +251,19 @@ backend:
python manage.py createsuperuser --email $DJANGO_SUPERUSER_EMAIL --password $DJANGO_SUPERUSER_PASSWORD
restartPolicy: Never
+ ## @extra backend.job job dedicated to run a random management command, for example after a deployment
+ ## @param backend.job.name The name to use to describe this job
+ ## @param backend.job.command The management command to execute
+ ## @param backend.job.restartPolicy The restart policy for the job.
+ ## @extra backend.job.annotations Annotations to add to the job [default: argocd.argoproj.io/hook: PostSync]
+ ## @skip backend.job.annotations.argocd.argoproj.io/hook
+ job:
+ name: ""
+ command: []
+ restartPolicy: Never
+ annotations:
+ argocd.argoproj.io/hook: PostSync
+
## @param backend.probes.liveness.path [nullable] Configure path for backend HTTP liveness probe
## @param backend.probes.liveness.targetPort [nullable] Configure port for backend HTTP liveness probe
## @param backend.probes.liveness.initialDelaySeconds [nullable] Configure initial delay for backend liveness probe
From 40c1107959ac50a458fd9768dae4331392a23070 Mon Sep 17 00:00:00 2001
From: lunika <767834+lunika@users.noreply.github.com>
Date: Mon, 27 Jan 2025 09:28:13 +0000
Subject: [PATCH 07/16] =?UTF-8?q?=F0=9F=8C=90(i18n)=20update=20translated?=
=?UTF-8?q?=20strings?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Update translated files with new translations
---
.../locale/de_DE/LC_MESSAGES/django.po | 2 +-
.../locale/en_US/LC_MESSAGES/django.po | 234 +++++++-----------
.../locale/fr_FR/LC_MESSAGES/django.po | 2 +-
.../locale/nl_NL/LC_MESSAGES/django.po | 2 +-
.../apps/impress/src/i18n/translations.json | 36 ++-
5 files changed, 123 insertions(+), 153 deletions(-)
diff --git a/src/backend/locale/de_DE/LC_MESSAGES/django.po b/src/backend/locale/de_DE/LC_MESSAGES/django.po
index a9db616c..31bc9062 100644
--- a/src/backend/locale/de_DE/LC_MESSAGES/django.po
+++ b/src/backend/locale/de_DE/LC_MESSAGES/django.po
@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-01-15 21:00+0000\n"
-"PO-Revision-Date: 2025-01-16 19:53\n"
+"PO-Revision-Date: 2025-01-27 09:27\n"
"Last-Translator: \n"
"Language-Team: German\n"
"Language: de_DE\n"
diff --git a/src/backend/locale/en_US/LC_MESSAGES/django.po b/src/backend/locale/en_US/LC_MESSAGES/django.po
index 1b2f03c3..34382019 100644
--- a/src/backend/locale/en_US/LC_MESSAGES/django.po
+++ b/src/backend/locale/en_US/LC_MESSAGES/django.po
@@ -1,9 +1,9 @@
msgid ""
msgstr ""
-"Project-Id-Version: lasuite-people\n"
+"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2024-12-17 15:50+0000\n"
-"PO-Revision-Date: 2024-12-17 15:53\n"
+"POT-Creation-Date: 2025-01-15 21:00+0000\n"
+"PO-Revision-Date: 2025-01-27 09:27\n"
"Last-Translator: \n"
"Language-Team: English\n"
"Language: en_US\n"
@@ -11,384 +11,342 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
-"X-Crowdin-Project: lasuite-people\n"
-"X-Crowdin-Project-ID: 637934\n"
+"X-Crowdin-Project: lasuite-docs\n"
+"X-Crowdin-Project-ID: 754523\n"
"X-Crowdin-Language: en\n"
"X-Crowdin-File: backend-impress.pot\n"
-"X-Crowdin-File-ID: 8\n"
+"X-Crowdin-File-ID: 18\n"
-#: core/admin.py:33
+#: build/lib/core/admin.py:33 core/admin.py:33
msgid "Personal info"
msgstr ""
-#: core/admin.py:46
+#: build/lib/core/admin.py:46 core/admin.py:46
msgid "Permissions"
msgstr ""
-#: core/admin.py:58
+#: build/lib/core/admin.py:58 core/admin.py:58
msgid "Important dates"
msgstr ""
-#: core/api/filters.py:16
+#: build/lib/core/api/filters.py:16 core/api/filters.py:16
msgid "Creator is me"
msgstr ""
-#: core/api/filters.py:19
+#: build/lib/core/api/filters.py:19 core/api/filters.py:19
msgid "Favorite"
msgstr ""
-#: core/api/filters.py:22
+#: build/lib/core/api/filters.py:22 core/api/filters.py:22
msgid "Title"
msgstr ""
-#: core/api/serializers.py:307
+#: build/lib/core/api/serializers.py:317 core/api/serializers.py:317
msgid "A new document was created on your behalf!"
msgstr ""
-#: core/api/serializers.py:311
+#: build/lib/core/api/serializers.py:321 core/api/serializers.py:321
msgid "You have been granted ownership of a new document:"
msgstr ""
-#: core/api/serializers.py:414
+#: build/lib/core/api/serializers.py:422 core/api/serializers.py:422
msgid "Body"
msgstr ""
-#: core/api/serializers.py:417
+#: build/lib/core/api/serializers.py:425 core/api/serializers.py:425
msgid "Body type"
msgstr ""
-#: core/api/serializers.py:423
+#: build/lib/core/api/serializers.py:431 core/api/serializers.py:431
msgid "Format"
msgstr ""
-#: core/authentication/backends.py:57
+#: build/lib/core/authentication/backends.py:61
+#: core/authentication/backends.py:61
msgid "Invalid response format or token verification failed"
msgstr ""
-#: core/authentication/backends.py:81
-msgid "User info contained no recognizable user identification"
-msgstr ""
-
-#: core/authentication/backends.py:88
+#: build/lib/core/authentication/backends.py:108
+#: core/authentication/backends.py:108
msgid "User account is disabled"
msgstr ""
-#: core/models.py:62 core/models.py:69
+#: build/lib/core/models.py:63 build/lib/core/models.py:70 core/models.py:63
+#: core/models.py:70
msgid "Reader"
msgstr ""
-#: core/models.py:63 core/models.py:70
+#: build/lib/core/models.py:64 build/lib/core/models.py:71 core/models.py:64
+#: core/models.py:71
msgid "Editor"
msgstr ""
-#: core/models.py:71
+#: build/lib/core/models.py:72 core/models.py:72
msgid "Administrator"
msgstr ""
-#: core/models.py:72
+#: build/lib/core/models.py:73 core/models.py:73
msgid "Owner"
msgstr ""
-#: core/models.py:83
+#: build/lib/core/models.py:84 core/models.py:84
msgid "Restricted"
msgstr ""
-#: core/models.py:87
+#: build/lib/core/models.py:88 core/models.py:88
msgid "Authenticated"
msgstr ""
-#: core/models.py:89
+#: build/lib/core/models.py:90 core/models.py:90
msgid "Public"
msgstr ""
-#: core/models.py:101
+#: build/lib/core/models.py:112 core/models.py:112
msgid "id"
msgstr ""
-#: core/models.py:102
+#: build/lib/core/models.py:113 core/models.py:113
msgid "primary key for the record as UUID"
msgstr ""
-#: core/models.py:108
+#: build/lib/core/models.py:119 core/models.py:119
msgid "created on"
msgstr ""
-#: core/models.py:109
+#: build/lib/core/models.py:120 core/models.py:120
msgid "date and time at which a record was created"
msgstr ""
-#: core/models.py:114
+#: build/lib/core/models.py:125 core/models.py:125
msgid "updated on"
msgstr ""
-#: core/models.py:115
+#: build/lib/core/models.py:126 core/models.py:126
msgid "date and time at which a record was last updated"
msgstr ""
-#: core/models.py:135
+#: build/lib/core/models.py:162 core/models.py:162
+msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
+msgstr ""
+
+#: build/lib/core/models.py:175 core/models.py:175
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
msgstr ""
-#: core/models.py:141
+#: build/lib/core/models.py:181 core/models.py:181
msgid "sub"
msgstr ""
-#: core/models.py:143
+#: build/lib/core/models.py:183 core/models.py:183
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
msgstr ""
-#: core/models.py:152
+#: build/lib/core/models.py:192 core/models.py:192
msgid "full name"
msgstr ""
-#: core/models.py:153
+#: build/lib/core/models.py:193 core/models.py:193
msgid "short name"
msgstr ""
-#: core/models.py:155
+#: build/lib/core/models.py:195 core/models.py:195
msgid "identity email address"
msgstr ""
-#: core/models.py:160
+#: build/lib/core/models.py:200 core/models.py:200
msgid "admin email address"
msgstr ""
-#: core/models.py:167
+#: build/lib/core/models.py:207 core/models.py:207
msgid "language"
msgstr ""
-#: core/models.py:168
+#: build/lib/core/models.py:208 core/models.py:208
msgid "The language in which the user wants to see the interface."
msgstr ""
-#: core/models.py:174
+#: build/lib/core/models.py:214 core/models.py:214
msgid "The timezone in which the user wants to see times."
msgstr ""
-#: core/models.py:177
+#: build/lib/core/models.py:217 core/models.py:217
msgid "device"
msgstr ""
-#: core/models.py:179
+#: build/lib/core/models.py:219 core/models.py:219
msgid "Whether the user is a device or a real user."
msgstr ""
-#: core/models.py:182
+#: build/lib/core/models.py:222 core/models.py:222
msgid "staff status"
msgstr ""
-#: core/models.py:184
+#: build/lib/core/models.py:224 core/models.py:224
msgid "Whether the user can log into this admin site."
msgstr ""
-#: core/models.py:187
+#: build/lib/core/models.py:227 core/models.py:227
msgid "active"
msgstr ""
-#: core/models.py:190
+#: build/lib/core/models.py:230 core/models.py:230
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr ""
-#: core/models.py:202
+#: build/lib/core/models.py:242 core/models.py:242
msgid "user"
msgstr ""
-#: core/models.py:203
+#: build/lib/core/models.py:243 core/models.py:243
msgid "users"
msgstr ""
-#: core/models.py:342 core/models.py:718
+#: build/lib/core/models.py:382 build/lib/core/models.py:758 core/models.py:382
+#: core/models.py:758
msgid "title"
msgstr ""
-#: core/models.py:364
+#: build/lib/core/models.py:404 core/models.py:404
msgid "Document"
msgstr ""
-#: core/models.py:365
+#: build/lib/core/models.py:405 core/models.py:405
msgid "Documents"
msgstr ""
-#: core/models.py:368
+#: build/lib/core/models.py:408 core/models.py:408
msgid "Untitled Document"
msgstr ""
-#: core/models.py:593
+#: build/lib/core/models.py:633 core/models.py:633
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr ""
-#: core/models.py:597
+#: build/lib/core/models.py:637 core/models.py:637
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr ""
-#: core/models.py:600
+#: build/lib/core/models.py:640 core/models.py:640
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr ""
-#: core/models.py:623
+#: build/lib/core/models.py:663 core/models.py:663
msgid "Document/user link trace"
msgstr ""
-#: core/models.py:624
+#: build/lib/core/models.py:664 core/models.py:664
msgid "Document/user link traces"
msgstr ""
-#: core/models.py:630
+#: build/lib/core/models.py:670 core/models.py:670
msgid "A link trace already exists for this document/user."
msgstr ""
-#: core/models.py:653
+#: build/lib/core/models.py:693 core/models.py:693
msgid "Document favorite"
msgstr ""
-#: core/models.py:654
+#: build/lib/core/models.py:694 core/models.py:694
msgid "Document favorites"
msgstr ""
-#: core/models.py:660
+#: build/lib/core/models.py:700 core/models.py:700
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
-#: core/models.py:682
+#: build/lib/core/models.py:722 core/models.py:722
msgid "Document/user relation"
msgstr ""
-#: core/models.py:683
+#: build/lib/core/models.py:723 core/models.py:723
msgid "Document/user relations"
msgstr ""
-#: core/models.py:689
+#: build/lib/core/models.py:729 core/models.py:729
msgid "This user is already in this document."
msgstr ""
-#: core/models.py:695
+#: build/lib/core/models.py:735 core/models.py:735
msgid "This team is already in this document."
msgstr ""
-#: core/models.py:701 core/models.py:890
+#: build/lib/core/models.py:741 build/lib/core/models.py:930 core/models.py:741
+#: core/models.py:930
msgid "Either user or team must be set, not both."
msgstr ""
-#: core/models.py:719
+#: build/lib/core/models.py:759 core/models.py:759
msgid "description"
msgstr ""
-#: core/models.py:720
+#: build/lib/core/models.py:760 core/models.py:760
msgid "code"
msgstr ""
-#: core/models.py:721
+#: build/lib/core/models.py:761 core/models.py:761
msgid "css"
msgstr ""
-#: core/models.py:723
+#: build/lib/core/models.py:763 core/models.py:763
msgid "public"
msgstr ""
-#: core/models.py:725
+#: build/lib/core/models.py:765 core/models.py:765
msgid "Whether this template is public for anyone to use."
msgstr ""
-#: core/models.py:731
+#: build/lib/core/models.py:771 core/models.py:771
msgid "Template"
msgstr ""
-#: core/models.py:732
+#: build/lib/core/models.py:772 core/models.py:772
msgid "Templates"
msgstr ""
-#: core/models.py:871
+#: build/lib/core/models.py:911 core/models.py:911
msgid "Template/user relation"
msgstr ""
-#: core/models.py:872
+#: build/lib/core/models.py:912 core/models.py:912
msgid "Template/user relations"
msgstr ""
-#: core/models.py:878
+#: build/lib/core/models.py:918 core/models.py:918
msgid "This user is already in this template."
msgstr ""
-#: core/models.py:884
+#: build/lib/core/models.py:924 core/models.py:924
msgid "This team is already in this template."
msgstr ""
-#: core/models.py:907
+#: build/lib/core/models.py:947 core/models.py:947
msgid "email address"
msgstr ""
-#: core/models.py:926
+#: build/lib/core/models.py:966 core/models.py:966
msgid "Document invitation"
msgstr ""
-#: core/models.py:927
+#: build/lib/core/models.py:967 core/models.py:967
msgid "Document invitations"
msgstr ""
-#: core/models.py:944
+#: build/lib/core/models.py:987 core/models.py:987
msgid "This email is already associated to a registered user."
msgstr ""
-#: core/templates/mail/html/hello.html:159 core/templates/mail/text/hello.txt:3
-msgid "Company logo"
-msgstr ""
-
-#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5
-#, python-format
-msgid "Hello %(name)s"
-msgstr ""
-
-#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5
-msgid "Hello"
-msgstr ""
-
-#: core/templates/mail/html/hello.html:189 core/templates/mail/text/hello.txt:6
-msgid "Thank you very much for your visit!"
-msgstr ""
-
-#: core/templates/mail/html/hello.html:221
-#, python-format
-msgid "This mail has been sent to %(email)s by %(name)s"
-msgstr ""
-
-#: core/templates/mail/html/invitation.html:162
-#: core/templates/mail/text/invitation.txt:3
-msgid "Logo email"
-msgstr ""
-
-#: core/templates/mail/html/invitation.html:209
-#: core/templates/mail/text/invitation.txt:10
-msgid "Open"
-msgstr ""
-
-#: core/templates/mail/html/invitation.html:226
-#: core/templates/mail/text/invitation.txt:14
-msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
-msgstr ""
-
-#: core/templates/mail/html/invitation.html:233
-#: core/templates/mail/text/invitation.txt:16
-#, python-format
-msgid " Brought to you by %(brandname)s "
-msgstr ""
-
-#: core/templates/mail/text/hello.txt:8
-#, python-format
-msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
-msgstr ""
-
-#: impress/settings.py:236
+#: build/lib/impress/settings.py:236 impress/settings.py:236
msgid "English"
msgstr ""
-#: impress/settings.py:237
+#: build/lib/impress/settings.py:237 impress/settings.py:237
msgid "French"
msgstr ""
-#: impress/settings.py:238
+#: build/lib/impress/settings.py:238 impress/settings.py:238
msgid "German"
msgstr ""
diff --git a/src/backend/locale/fr_FR/LC_MESSAGES/django.po b/src/backend/locale/fr_FR/LC_MESSAGES/django.po
index 27272fd9..a93a965a 100644
--- a/src/backend/locale/fr_FR/LC_MESSAGES/django.po
+++ b/src/backend/locale/fr_FR/LC_MESSAGES/django.po
@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-01-15 21:00+0000\n"
-"PO-Revision-Date: 2025-01-16 19:53\n"
+"PO-Revision-Date: 2025-01-27 09:27\n"
"Last-Translator: \n"
"Language-Team: French\n"
"Language: fr_FR\n"
diff --git a/src/backend/locale/nl_NL/LC_MESSAGES/django.po b/src/backend/locale/nl_NL/LC_MESSAGES/django.po
index f979f7bb..a0c45428 100644
--- a/src/backend/locale/nl_NL/LC_MESSAGES/django.po
+++ b/src/backend/locale/nl_NL/LC_MESSAGES/django.po
@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-01-15 21:00+0000\n"
-"PO-Revision-Date: 2025-01-16 19:53\n"
+"PO-Revision-Date: 2025-01-27 09:27\n"
"Last-Translator: \n"
"Language-Team: Dutch\n"
"Language: nl_NL\n"
diff --git a/src/frontend/apps/impress/src/i18n/translations.json b/src/frontend/apps/impress/src/i18n/translations.json
index bc2ed988..3a7a535e 100644
--- a/src/frontend/apps/impress/src/i18n/translations.json
+++ b/src/frontend/apps/impress/src/i18n/translations.json
@@ -9,7 +9,6 @@
"Accessibility statement": "Erklärung zur Barrierefreiheit",
"Add": "Hinzufügen",
"Address:": "Anschrift:",
- "Administrator": "Administrator",
"All docs": "Alle Dokumente",
"Anonymous": "Gast",
"Anyone with the link can edit the document": "Jeder mit dem Link kann das Dokument bearbeiten",
@@ -27,6 +26,7 @@
"Content modal to delete document": "Inhalts-Modal zum Löschen des Dokuments",
"Content modal to export the document": "Inhalte zum Exportieren des Dokuments",
"Convert Markdown": "Markdown konvertieren",
+ "Cookies placed": "Cookies gesetzt",
"Copied to clipboard": "In die Zwischenablage kopiert",
"Copy as {{format}}": "Als {{format}} kopieren",
"Copy link": "Link kopieren",
@@ -35,33 +35,33 @@
"Delete a doc": "Dokument löschen",
"Delete document": "Dokument löschen",
"Doc visibility card": "Dokumenten-Sichtbarkeitskarte",
- "Docs": "Docs",
- "Docs Logo": "Docs Logo",
"Docs: Your new companion to collaborate on documents efficiently, intuitively, and securely.": "Pages: Ihr neuer Begleiter für eine effiziente, intuitive und sichere Zusammenarbeit bei Dokumenten.",
"Document owner": "Besitzer des Dokuments",
"Document title updated successfully": "Titel des Dokuments erfolgreich aktualisiert",
"Download": "Herunterladen",
"E-mail:": "E-Mail:",
"Edition": "Bearbeiten",
- "Editor": "Editor",
"Editor unavailable": "Editor nicht verfügbar",
"Error during delete invitation": "Fehler beim Löschen der Einladung",
"Error during invitation update": "Fehler beim Aktualisieren der Einladung",
"Error during update invitation": "Fehler beim Aktualisieren der Einladung",
"Error while deleting invitation": "Fehler beim Löschen der Einladung",
+ "Established on December 20, 2023.": "Gegründet am 20. Dezember 2023.",
"Export": "Exportieren",
"Failed to add the member in the document.": "Fehler beim Hinzufügen des Mitglieds zum Dokument.",
"Failed to copy link": "Link konnte nicht kopiert werden",
"Failed to copy to clipboard": "Fehler beim Kopieren in die Zwischenablage",
"Failed to create the invitation for {{email}}.": "Fehler beim Erstellen der Einladung für {{email}}.",
- "Format": "Format",
"History": "Versionsverlauf",
"If a member is editing, his works can be lost.": "Wenn ein Mitglied editiert, können seine Änderungen verloren gehen.",
+ "If you are unable to access a content or a service, you can contact the person responsible for https://lasuite.numerique.gouv.fr to be directed to an accessible alternative or to obtain the content in another form.": "Wenn Sie keinen Zugriff auf Inhalte oder einen Service haben, können Sie sich an die für https://lasuite.numerique.gouv.fr verantwortliche Person wenden, um eine zugängliche Alternative zu erhalten oder den Inhalt in einer anderen Form zu erhalten.",
+ "Illustration:": "Abbildung:",
"Improvement and contact": "Verbesserungen und Kontakt",
"Invite": "Einladen",
"It is the card information about the document.": "Es handelt sich um die Karteninformationen zum Dokument.",
"It is the document title": "Es ist der Titel des Dokuments",
"It seems that the page you are looking for does not exist or cannot be displayed correctly.": "Es scheint, dass die von Ihnen gesuchte Seite nicht existiert oder nicht korrekt angezeigt werden kann.",
+ "It's true, you didn't have to click on a block that covers half the page to say you agree to the placement of cookies — even if you don't know what it means!": "Es stimmt, Sie mussten nicht auf einen Block klicken, der die halbe Seite bedeckt, um zu sagen, dass Sie der Platzierung von Cookies zustimmen - auch wenn Sie nicht wissen, was es bedeutet!",
"Language": "Sprache",
"Last update: {{update}}": "Zuletzt aktualisiert: {{update}}",
"Legal Notice": "Impressum",
@@ -75,22 +75,21 @@
"Logout": "Abmelden",
"Modal confirmation to restore the version": "Modale Bestätigung um die Version wiederherzustellen",
"More docs": "Weitere Dokumente",
+ "More info?": "Mehr Informationen",
"My docs": "Meine Dokumente",
- "Name": "Name",
"New doc": "Neues Dokument",
"No active search": "Keine aktive Suche",
"No document found": "Kein Dokument gefunden",
"No documents found": "Keine Dokumente gefunden",
"No editor found": "Kein Editor gefunden",
"No versions": "Keine Versionen",
- "OK": "OK",
+ "Nothing exceptional, no special privileges related to a .gouv.fr.": "Nichts Außergewöhnliches, keine besonderen Privilegien im Zusammenhang mit .gouv.fr.",
"Offline ?!": "Offline?!",
"Only invited people can access": "Nur eingeladene Personen haben Zugriff",
"Open the document options": "Öffnen Sie die Dokumentoptionen",
"Open the header menu": "Öffne das Kopfzeilen-Menü",
"Ouch !": "Autsch!",
"Owner": "Besitzer",
- "PDF": "PDF",
"Pending invitations": "Ausstehende Einladungen",
"Personal data and cookies": "Personenbezogene Daten und Cookies",
"Pin": "Anheften",
@@ -98,9 +97,12 @@
"Private": "Privat",
"Public": "Öffentlich",
"Public document": "Öffentliches Dokument",
+ "Publication Director": "Verantwortlicher Herausgeber",
+ "Publisher": "Herausgeber",
"Quick search input": "Schnellsuche-Eingabe",
"Reader": "Leser",
"Reading": "Lesen",
+ "Remedies": "Rechtsbehelfe",
"Remove": "Löschen",
"Rename": "Umbenennen",
"Rephrase": "Umformulieren",
@@ -110,6 +112,7 @@
"Search user result": "Suchergebnis",
"Select a document": "Dokument auswählen",
"Select a version on the right to restore": "Wählen Sie rechts eine Version zum Wiederherstellen aus",
+ "Send a letter by post (free of charge, no stamp needed):": "Senden Sie einen Brief per Post (kostenlos, kein Porto erforderlich):",
"Share": "Teilen",
"Share modal": "Teilen-Modal",
"Share the document": "Dokument teilen",
@@ -118,13 +121,18 @@
"Share with {{count}} users_other": "Teilen mit {{count}} Benutzern",
"Shared with me": "Mit mir geteilt",
"Something bad happens, please retry.": "Etwas ist schiefgelaufen, bitte versuchen Sie es erneut.",
+ "Stéphanie Schaer: Interministerial Digital Director (DINUM).": "Stéphanie Schaer: Interministerielle Digitaldirektorin (DINUM).",
"Summarize": "Zusammenfassen",
"Summary": "Zusammenfassung",
"Template": "Vorlage",
"The document has been deleted.": "Das Dokument wurde gelöscht.",
"The document visibility has been updated.": "Die Sichtbarkeit des Dokuments wurde aktualisiert.",
+ "The team in charge of the digital workspace \"La Suite numérique\" can be contacted directly at": "Das Team, das für den digitalen Arbeitsbereich \"La Suite numérique\" zuständig ist, kann direkt kontaktiert werden unter",
"This accessibility statement applies to the site hosted on": "Diese Erklärung zur Barrierefreiheit gilt für die gehostete Seite",
- "This site does not display a cookie consent banner, why?": "",
+ "This allows us to measure the number of visits and understand which pages are the most viewed.": "Dies ermöglicht es uns, die Anzahl der Besuche zu messen und zu verstehen, welche Seiten am häufigsten angesehen werden.",
+ "This procedure should be used in the following case:": "Dieses Verfahren sollte in folgendem Fall verwendet werden:",
+ "This site places a small text file (a \"cookie\") on your computer when you visit it.": "Diese Website platziert beim Besuch auf Ihrem Computer eine kleine Textdatei (ein \"Cookie\").",
+ "This will protect your privacy, but will also prevent the owner from learning from your actions and creating a better experience for you and other users.": "Dies schützt Ihre Privatsphäre, verhindert jedoch auch, dass der Eigentümer aus Ihren Aktionen lernt und eine bessere Erfahrung für Sie und andere Benutzer schafft.",
"Too many requests. Please wait 60 seconds.": "Zu viele Anfragen. Bitte warten Sie 60 Sekunden.",
"Type a name or email": "Geben Sie einen Namen oder eine E-Mail-Adresse ein",
"Type the name of a document": "Geben Sie den Namen eines Dokuments ein",
@@ -139,12 +147,15 @@
"Visibility": "Sichtbarkeit",
"Visibility mode": "Sichtbarkeitseinstellungen",
"Warning": "Warnung",
+ "We simply comply with the law, which states that certain audience measurement tools, properly configured to respect privacy, are exempt from prior authorization.": "Wir halten uns einfach an das Gesetz, das besagt, dass bestimmte Publikumsmessungstools, die ordnungsgemäß konfiguriert sind, um die Privatsphäre zu respektieren, von einer vorherigen Genehmigung befreit sind.",
"We try to respond within 2 working days.": "Wir versuchen, innerhalb von 2 Arbeitstagen zu antworten.",
- "Word / Open Office": "Word / Open Office",
"You are the sole owner of this group, make another member the group owner before you can change your own role or be removed from your document.": "Sie sind der einzige Besitzer dieser Gruppe. Machen Sie ein anderes Mitglied zum Gruppenbesitzer, bevor Sie Ihre eigene Rolle ändern oder aus Ihrem Dokument entfernen können.",
+ "You can oppose the tracking of your browsing on this website.": "Sie können der Verfolgung Ihres Surfverhaltens auf dieser Website widersprechen.",
+ "You can:": "Sie können:",
"You cannot update the role or remove other owner.": "Sie können die Rolle nicht aktualisieren oder einen anderen Besitzer entfernen.",
"Your current document will revert to this version.": "Ihr aktuelles Dokument wird auf diese Version zurückgesetzt.",
- "Your {{format}} was downloaded succesfully": "Ihr {{format}} wurde erfolgreich heruntergeladen"
+ "Your {{format}} was downloaded succesfully": "Ihr {{format}} wurde erfolgreich heruntergeladen",
+ "you have reported to the website manager a lack of accessibility that prevents you from accessing content or one of the services of the portal and you have not received a satisfactory response.": "sie haben dem Website-Manager einen Mangel an Barrierefreiheit gemeldet, der Ihnen den Zugriff auf Inhalte oder einen der Dienste des Portals verwehrt, und Sie haben keine zufriedenstellende Antwort erhalten."
}
},
"en": { "translation": {} },
@@ -323,5 +334,6 @@
"accessibility-not-audit": "docs.numerique.gouv.fr n'est pas en conformité avec le RGAA 4.1. Le site n'a pas encore été audité.",
"you have reported to the website manager a lack of accessibility that prevents you from accessing content or one of the services of the portal and you have not received a satisfactory response.": "vous avez signalé au responsable du site internet un défaut d'accessibilité qui vous empêche d'accéder à un contenu ou à un des services du portail et vous n'avez pas obtenu de réponse satisfaisante."
}
- }
+ },
+ "nl": { "translation": {} }
}
From 81837aff2bcf873cb68eb42bbd318a24bbb09b1a Mon Sep 17 00:00:00 2001
From: Anthony LC
Date: Mon, 6 Jan 2025 16:15:45 +0100
Subject: [PATCH 08/16] =?UTF-8?q?=E2=9C=A8(frontend)=20export=20pdf=20docx?=
=?UTF-8?q?=20front=20side?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
We have added the export to pdf and docx feature
to the front side. Thanks to that, the images are now
correctly exported even when the doc is private.
To be able to export the doc, the data must be
in blocknote format, for legacy purpose, we have
to convert the template to blocknote format before
exporting it.
---
CHANGELOG.md | 1 +
.../__tests__/app-impress/doc-export.spec.ts | 177 ++-----
src/frontend/apps/e2e/package.json | 1 -
src/frontend/apps/impress/package.json | 4 +
.../docs/doc-header/components/DocToolBox.tsx | 12 +-
.../doc-header/components/ModalExport.tsx | 153 +++---
.../src/features/docs/doc-header/utils.ts | 144 +-----
src/frontend/yarn.lock | 481 ++++++++++++++++--
8 files changed, 610 insertions(+), 363 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index da6a6826..4721091c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,6 +14,7 @@ and this project adheres to
- github actions to managed Crowdin workflow
- 📈Integrate Posthog #540
- 🏷️(backend) add content-type to uploaded files #552
+- ✨(frontend) export pdf docx front side #537
## Changed
diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-export.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-export.spec.ts
index 6e62d3ad..eff9592a 100644
--- a/src/frontend/apps/e2e/__tests__/app-impress/doc-export.spec.ts
+++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-export.spec.ts
@@ -1,6 +1,7 @@
+import path from 'path';
+
import { expect, test } from '@playwright/test';
import cs from 'convert-stream';
-import jsdom from 'jsdom';
import pdf from 'pdf-parse';
import { createDoc, verifyDocName } from './common';
@@ -41,10 +42,8 @@ test.describe('Doc Export', () => {
).toBeVisible();
await expect(page.getByRole('button', { name: 'Download' })).toBeVisible();
});
- test('it converts the doc to pdf with a template integrated', async ({
- page,
- browserName,
- }) => {
+
+ test('it exports the doc to pdf', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(page, 'doc-editor', browserName, 1);
const downloadPromise = page.waitForEvent('download', (download) => {
@@ -77,10 +76,7 @@ test.describe('Doc Export', () => {
expect(pdfText).toContain('Hello World'); // This is the doc text
});
- test('it converts the doc to docx with a template integrated', async ({
- page,
- browserName,
- }) => {
+ test('it exports the doc to docx', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(page, 'doc-editor', browserName, 1);
const downloadPromise = page.waitForEvent('download', (download) => {
@@ -111,152 +107,75 @@ test.describe('Doc Export', () => {
expect(download.suggestedFilename()).toBe(`${randomDoc}.docx`);
});
- test('it converts the blocknote json in correct html for the export', async ({
- page,
- browserName,
- }) => {
- test.setTimeout(60000);
-
+ /**
+ * This test tell us that the export to pdf is working with images
+ * but it does not tell us if the images are beeing displayed correctly
+ * in the pdf.
+ *
+ * TODO: Check if the images are displayed correctly in the pdf
+ */
+ test('it exports the docs with images', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(page, 'doc-editor', browserName, 1);
- let body = '';
- await page.route('**/templates/*/generate-document/', async (route) => {
- const request = route.request();
- body = request.postDataJSON().body;
-
- await route.continue();
+ const fileChooserPromise = page.waitForEvent('filechooser');
+ const downloadPromise = page.waitForEvent('download', (download) => {
+ return download.suggestedFilename().includes(`${randomDoc}.pdf`);
});
await verifyDocName(page, randomDoc);
- await page.locator('.bn-block-outer').last().fill('Hello World');
- await page.locator('.bn-block-outer').last().click();
- await page.keyboard.press('Enter');
- await page.keyboard.press('Enter');
- await page.locator('.bn-block-outer').last().fill('Break');
- await expect(page.getByText('Break')).toBeVisible();
+ await page.locator('.ProseMirror.bn-editor').click();
+ await page.locator('.ProseMirror.bn-editor').fill('Hello World');
- // Center the text
- await page.getByText('Break').dblclick();
- await page.locator('button[data-test="alignTextCenter"]').click();
-
- // Change the background color
- await page.locator('button[data-test="colors"]').click();
- await page.locator('button[data-test="background-color-brown"]').click();
-
- // Change the text color
- await page.getByText('Break').dblclick();
- await page.locator('button[data-test="colors"]').click();
- await page.locator('button[data-test="text-color-orange"]').click();
-
- // Add a list
- await page.locator('.bn-block-outer').last().click();
await page.keyboard.press('Enter');
await page.locator('.bn-block-outer').last().fill('/');
- await page.getByText('Bullet List').click();
- await page
- .locator('.bn-block-content[data-content-type="bulletListItem"] p')
- .last()
- .fill('Test List 1');
- // eslint-disable-next-line playwright/no-wait-for-timeout
- await page.waitForTimeout(300);
- await page.keyboard.press('Enter');
- await page
- .locator('.bn-block-content[data-content-type="bulletListItem"] p')
- .last()
- .fill('Test List 2');
- await page.keyboard.press('Enter');
- await page
- .locator('.bn-block-content[data-content-type="bulletListItem"] p')
- .last()
- .fill('Test List 3');
+ await page.getByText('Resizable image with caption').click();
+ await page.getByText('Upload image').click();
- await page.keyboard.press('Enter');
- await page.keyboard.press('Backspace');
+ const fileChooser = await fileChooserPromise;
+ await fileChooser.setFiles(
+ path.join(__dirname, 'assets/logo-suite-numerique.png'),
+ );
- // Add a number list
- await page.locator('.bn-block-outer').last().click();
- await page.keyboard.press('Enter');
- await page.locator('.bn-block-outer').last().fill('/');
- await page.getByText('Numbered List').click();
- await page
- .locator('.bn-block-content[data-content-type="numberedListItem"] p')
- .last()
- .fill('Test Number 1');
- // eslint-disable-next-line playwright/no-wait-for-timeout
- await page.waitForTimeout(300);
- await page.keyboard.press('Enter');
- await page
- .locator('.bn-block-content[data-content-type="numberedListItem"] p')
- .last()
- .fill('Test Number 2');
- await page.keyboard.press('Enter');
- await page
- .locator('.bn-block-content[data-content-type="numberedListItem"] p')
- .last()
- .fill('Test Number 3');
+ const image = page.getByRole('img', { name: 'logo-suite-numerique.png' });
- // Add img
- await page.locator('.bn-block-outer').last().click();
- await page.keyboard.press('Enter');
- await page.locator('.bn-block-outer').last().fill('/');
- await page
- .getByRole('option', {
- name: 'Image',
- })
- .click();
- await page
- .getByRole('tab', {
- name: 'Embed',
- })
- .click();
- await page
- .getByPlaceholder('Enter URL')
- .fill('https://example.com/image.jpg');
- await page
- .getByRole('button', {
- name: 'Embed image',
- })
- .click();
+ await expect(image).toBeVisible();
- // Download
await page
.getByRole('button', {
name: 'download',
})
.click();
+ await page
+ .getByRole('combobox', {
+ name: 'Template',
+ })
+ .click();
+
+ await page
+ .getByRole('option', {
+ name: 'Demo Template',
+ })
+ .click({
+ delay: 100,
+ });
+
+ await new Promise((resolve) => setTimeout(resolve, 1000));
+
await page
.getByRole('button', {
name: 'Download',
})
.click();
- // Empty paragraph should be replaced by a
- expect(body.match(/ /g)?.length).toBeGreaterThanOrEqual(2);
- expect(body).toContain('style="color: orange;"');
- expect(body).toContain('custom-style="center"');
- expect(body).toContain('style="background-color: brown;"');
+ const download = await downloadPromise;
+ expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`);
- const { JSDOM } = jsdom;
- const DOMParser = new JSDOM().window.DOMParser;
- const parser = new DOMParser();
- const html = parser.parseFromString(body, 'text/html');
+ const pdfBuffer = await cs.toBuffer(await download.createReadStream());
+ const pdfExport = await pdf(pdfBuffer);
+ const pdfText = pdfExport.text;
- const ulLis = html.querySelectorAll('ul li');
- expect(ulLis.length).toBe(3);
- expect(ulLis[0].textContent).toBe('Test List 1');
- expect(ulLis[1].textContent).toBe('Test List 2');
- expect(ulLis[2].textContent).toBe('Test List 3');
-
- const olLis = html.querySelectorAll('ol li');
- expect(olLis.length).toBe(3);
- expect(olLis[0].textContent).toBe('Test Number 1');
- expect(olLis[1].textContent).toBe('Test Number 2');
- expect(olLis[2].textContent).toBe('Test Number 3');
-
- const img = html.querySelectorAll('img');
- expect(img.length).toBe(1);
- expect(img[0].src).toBe('https://example.com/image.jpg');
+ expect(pdfText).toContain('Hello World');
});
});
diff --git a/src/frontend/apps/e2e/package.json b/src/frontend/apps/e2e/package.json
index b46958ca..b9dcad2d 100644
--- a/src/frontend/apps/e2e/package.json
+++ b/src/frontend/apps/e2e/package.json
@@ -22,7 +22,6 @@
},
"dependencies": {
"convert-stream": "1.0.2",
- "jsdom": "25.0.1",
"pdf-parse": "1.1.1"
}
}
diff --git a/src/frontend/apps/impress/package.json b/src/frontend/apps/impress/package.json
index 7ae5efa6..a4735a57 100644
--- a/src/frontend/apps/impress/package.json
+++ b/src/frontend/apps/impress/package.json
@@ -18,13 +18,17 @@
"@blocknote/core": "0.21.0",
"@blocknote/mantine": "0.21.0",
"@blocknote/react": "0.21.0",
+ "@blocknote/xl-docx-exporter": "0.21.0",
+ "@blocknote/xl-pdf-exporter": "0.21.0",
"@gouvfr-lasuite/integration": "1.0.2",
"@hocuspocus/provider": "2.15.0",
"@openfun/cunningham-react": "2.9.4",
+ "@react-pdf/renderer": "4.1.6",
"@sentry/nextjs": "8.47.0",
"@tanstack/react-query": "5.62.11",
"cmdk": "1.0.4",
"crisp-sdk-web": "1.0.25",
+ "docx": "9.1.0",
"i18next": "24.2.0",
"i18next-browser-languagedetector": "8.0.2",
"idb": "8.0.1",
diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx
index cdfdacaf..30786e3e 100644
--- a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx
+++ b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx
@@ -26,7 +26,7 @@ import {
} from '@/features/docs/doc-versioning';
import { useResponsiveStore } from '@/stores';
-import { ModalPDF } from './ModalExport';
+import { ModalExport } from './ModalExport';
interface DocToolBoxProps {
doc: Doc;
@@ -43,7 +43,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
const colors = colorsTokens();
const [isModalRemoveOpen, setIsModalRemoveOpen] = useState(false);
- const [isModalPDFOpen, setIsModalPDFOpen] = useState(false);
+ const [isModalExportOpen, setIsModalExportOpen] = useState(false);
const selectHistoryModal = useModal();
const modalShare = useModal();
@@ -63,7 +63,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
label: t('Export'),
icon: 'download',
callback: () => {
- setIsModalPDFOpen(true);
+ setIsModalExportOpen(true);
},
},
]
@@ -198,7 +198,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
}
onClick={() => {
- setIsModalPDFOpen(true);
+ setIsModalExportOpen(true);
}}
size={isSmallMobile ? 'small' : 'medium'}
/>
@@ -228,8 +228,8 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
{modalShare.isOpen && (
modalShare.close()} doc={doc} />
)}
- {isModalPDFOpen && (
- setIsModalPDFOpen(false)} doc={doc} />
+ {isModalExportOpen && (
+ setIsModalExportOpen(false)} doc={doc} />
)}
{isModalRemoveOpen && (
setIsModalRemoveOpen(false)} doc={doc} />
diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/components/ModalExport.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/components/ModalExport.tsx
index e08ac21d..4e72a6eb 100644
--- a/src/frontend/apps/impress/src/features/docs/doc-header/components/ModalExport.tsx
+++ b/src/frontend/apps/impress/src/features/docs/doc-header/components/ModalExport.tsx
@@ -1,3 +1,11 @@
+import {
+ DOCXExporter,
+ docxDefaultSchemaMappings,
+} from '@blocknote/xl-docx-exporter';
+import {
+ PDFExporter,
+ pdfDefaultSchemaMappings,
+} from '@blocknote/xl-pdf-exporter';
import {
Button,
Loader,
@@ -7,91 +15,116 @@ import {
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
-import { useEffect, useMemo, useState } from 'react';
+import { pdf } from '@react-pdf/renderer';
+import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
+import { css } from 'styled-components';
import { Box, Text } from '@/components';
import { useEditorStore } from '@/features/docs/doc-editor';
import { Doc } from '@/features/docs/doc-management';
-import { useExport } from '../api/useExport';
import { TemplatesOrdering, useTemplates } from '../api/useTemplates';
-import { adaptBlockNoteHTML, downloadFile } from '../utils';
+import { downloadFile, exportResolveFileUrl } from '../utils';
-export enum DocDownloadFormat {
+enum DocDownloadFormat {
PDF = 'pdf',
DOCX = 'docx',
}
-interface ModalPDFProps {
+interface ModalExportProps {
onClose: () => void;
doc: Doc;
}
-export const ModalPDF = ({ onClose, doc }: ModalPDFProps) => {
+export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
const { t } = useTranslation();
const { data: templates } = useTemplates({
ordering: TemplatesOrdering.BY_CREATED_ON_DESC,
});
const { toast } = useToastProvider();
const { editor } = useEditorStore();
- const {
- mutate: createExport,
- data: documentGenerated,
- isSuccess,
- isPending,
- error,
- } = useExport();
- const [templateIdSelected, setTemplateIdSelected] = useState();
+ const [templateSelected, setTemplateSelected] = useState('');
+ const [isExporting, setIsExporting] = useState(false);
const [format, setFormat] = useState(
DocDownloadFormat.PDF,
);
const templateOptions = useMemo(() => {
- if (!templates?.pages) {
- return [];
- }
-
- const templateOptions = templates.pages
+ const templateOptions = (templates?.pages || [])
.map((page) =>
page.results.map((template) => ({
label: template.title,
- value: template.id,
+ value: template.code,
})),
)
.flat();
- if (templateOptions.length) {
- setTemplateIdSelected(templateOptions[0].value);
- }
+ templateOptions.unshift({
+ label: t('Empty template'),
+ value: '',
+ });
return templateOptions;
- }, [templates?.pages]);
+ }, [t, templates?.pages]);
- useEffect(() => {
- if (!error) {
+ async function onSubmit() {
+ if (!editor) {
+ toast(t('The export failed'), VariantType.ERROR);
return;
}
- toast(error.message, VariantType.ERROR);
+ setIsExporting(true);
- onClose();
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [error, t]);
-
- useEffect(() => {
- if (!documentGenerated || !isSuccess) {
- return;
- }
-
- // normalize title
const title = doc.title
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/\s/g, '-');
- downloadFile(documentGenerated, `${title}.${format}`);
+ const html = templateSelected;
+ let exportDocument = editor.document;
+ if (html) {
+ const blockTemplate = await editor.tryParseHTMLToBlocks(html);
+ exportDocument = [...blockTemplate, ...editor.document];
+ }
+
+ let blobExport: Blob;
+ if (format === DocDownloadFormat.PDF) {
+ const defaultExporter = new PDFExporter(
+ editor.schema,
+ pdfDefaultSchemaMappings,
+ );
+
+ const exporter = new PDFExporter(
+ editor.schema,
+ pdfDefaultSchemaMappings,
+ {
+ resolveFileUrl: async (url) =>
+ exportResolveFileUrl(url, defaultExporter.options.resolveFileUrl),
+ },
+ );
+ const pdfDocument = await exporter.toReactPDFDocument(exportDocument);
+ blobExport = await pdf(pdfDocument).toBlob();
+ } else {
+ const defaultExporter = new DOCXExporter(
+ editor.schema,
+ docxDefaultSchemaMappings,
+ );
+
+ const exporter = new DOCXExporter(
+ editor.schema,
+ docxDefaultSchemaMappings,
+ {
+ resolveFileUrl: async (url) =>
+ exportResolveFileUrl(url, defaultExporter.options.resolveFileUrl),
+ },
+ );
+
+ blobExport = await exporter.toBlob(exportDocument);
+ }
+
+ downloadFile(blobExport, `${title}.${format}`);
toast(
t('Your {{format}} was downloaded succesfully', {
@@ -100,29 +133,9 @@ export const ModalPDF = ({ onClose, doc }: ModalPDFProps) => {
VariantType.SUCCESS,
);
+ setIsExporting(false);
+
onClose();
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [documentGenerated, isSuccess, t]);
-
- async function onSubmit() {
- if (!templateIdSelected || !format) {
- return;
- }
-
- if (!editor) {
- toast(t('No editor found'), VariantType.ERROR);
- return;
- }
-
- let body = await editor.blocksToFullHTML(editor.document);
- body = adaptBlockNoteHTML(body);
-
- createExport({
- templateId: templateIdSelected,
- body,
- body_type: 'html',
- format,
- });
}
return (
@@ -138,6 +151,7 @@ export const ModalPDF = ({ onClose, doc }: ModalPDFProps) => {
color="secondary"
fullWidth
onClick={() => onClose()}
+ disabled={isExporting}
>
{t('Cancel')}
@@ -146,7 +160,7 @@ export const ModalPDF = ({ onClose, doc }: ModalPDFProps) => {
color="primary"
fullWidth
onClick={() => void onSubmit()}
- disabled={isPending || !templateIdSelected}
+ disabled={isExporting}
>
{t('Download')}
@@ -173,9 +187,9 @@ export const ModalPDF = ({ onClose, doc }: ModalPDFProps) => {
clearable={false}
label={t('Template')}
options={templateOptions}
- value={templateIdSelected}
+ value={templateSelected}
onChange={(options) =>
- setTemplateIdSelected(options.target.value as string)
+ setTemplateSelected(options.target.value as string)
}
/>
", "body_type": "html"}
-
- response = client.post(
- f"/api/v1.0/templates/{template.id!s}/generate-document/",
- data,
- format="json",
- )
-
- assert response.status_code == 200
- assert response.headers["content-type"] == "application/pdf"
-
-
-def test_api_templates_generate_document_type_markdown():
- """Generate pdf document with the body type markdown."""
- user = factories.UserFactory()
-
- client = APIClient()
- client.force_login(user)
-
- template = factories.TemplateFactory(is_public=True)
- data = {"body": "# Test markdown body", "body_type": "markdown"}
-
- response = client.post(
- f"/api/v1.0/templates/{template.id!s}/generate-document/",
- data,
- format="json",
- )
-
- assert response.status_code == 200
- assert response.headers["content-type"] == "application/pdf"
-
-
-def test_api_templates_generate_document_type_unknown():
- """Generate pdf document with the body type unknown."""
- user = factories.UserFactory()
-
- client = APIClient()
- client.force_login(user)
-
- template = factories.TemplateFactory(is_public=True)
- data = {"body": "# Test markdown body", "body_type": "unknown"}
-
- response = client.post(
- f"/api/v1.0/templates/{template.id!s}/generate-document/",
- data,
- format="json",
- )
-
- assert response.status_code == 400
- assert response.json() == {
- "body_type": [
- '"unknown" is not a valid choice.',
- ]
- }
-
-
-def test_api_templates_generate_document_export_docx():
- """Generate pdf document with the body type html."""
- user = factories.UserFactory()
-
- client = APIClient()
- client.force_login(user)
-
- template = factories.TemplateFactory(is_public=True)
- data = {"body": "
Test body
", "body_type": "html", "format": "docx"}
-
- response = client.post(
- f"/api/v1.0/templates/{template.id!s}/generate-document/",
- data,
- format="json",
- )
-
- assert response.status_code == 200
- assert (
- response.headers["content-type"]
- == "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
- )
diff --git a/src/backend/core/tests/test_models_templates.py b/src/backend/core/tests/test_models_templates.py
index 6e7cba2c..95f8fbde 100644
--- a/src/backend/core/tests/test_models_templates.py
+++ b/src/backend/core/tests/test_models_templates.py
@@ -2,10 +2,6 @@
Unit tests for the Template model
"""
-import os
-import time
-from unittest import mock
-
from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import ValidationError
@@ -189,31 +185,3 @@ def test_models_templates_get_abilities_preset_role(django_assert_num_queries):
"partial_update": False,
"generate_document": True,
}
-
-
-def test_models_templates__generate_word():
- """Generate word document and assert no tmp files are left in /tmp folder."""
- template = factories.TemplateFactory()
- response = template.generate_word("
Test body
", {})
-
- assert response.status_code == 200
- assert len([f for f in os.listdir("/tmp") if f.startswith("docx_")]) == 0
-
-
-@mock.patch(
- "pypandoc.convert_text",
- side_effect=RuntimeError("Conversion failed"),
-)
-def test_models_templates__generate_word__raise_error(_mock_pypandoc):
- """
- Generate word document and assert no tmp files are left in /tmp folder
- even when the conversion fails.
- """
- template = factories.TemplateFactory()
-
- try:
- template.generate_word("
Test body
", {})
- except RuntimeError as e:
- assert str(e) == "Conversion failed"
- time.sleep(0.5)
- assert len([f for f in os.listdir("/tmp") if f.startswith("docx_")]) == 0
diff --git a/src/backend/demo/data/template/code.txt b/src/backend/demo/data/template/code.txt
index 0ab83f60..56f6736b 100644
--- a/src/backend/demo/data/template/code.txt
+++ b/src/backend/demo/data/template/code.txt
@@ -1,10 +1,2 @@
-
-
-
-
-
-
{{ body }}
-
-
+
+
\ No newline at end of file
diff --git a/src/backend/demo/data/template/css.txt b/src/backend/demo/data/template/css.txt
index 79a440ab..e69de29b 100644
--- a/src/backend/demo/data/template/css.txt
+++ b/src/backend/demo/data/template/css.txt
@@ -1,20 +0,0 @@
-body {
- background: white;
- font-family: arial;
-}
-.header img {
- width: 5cm;
- margin-left: -0.4cm;
-}
-.body{
- margin-top: 1.5rem;
-}
-img {
- max-width: 100%;
-}
-[custom-style="center"] {
- text-align: center;
-}
-[custom-style="right"] {
- text-align: right;
-}
diff --git a/src/backend/pyproject.toml b/src/backend/pyproject.toml
index 4ce29fa6..286f07b7 100644
--- a/src/backend/pyproject.toml
+++ b/src/backend/pyproject.toml
@@ -50,13 +50,10 @@ dependencies = [
"openai==1.58.1",
"psycopg[binary]==3.2.3",
"PyJWT==2.10.1",
- "pypandoc==1.14",
- "python-frontmatter==1.1.0",
"python-magic==0.4.27",
"requests==2.32.3",
"sentry-sdk==2.19.2",
"url-normalize==1.4.3",
- "WeasyPrint>=60.2",
"whitenoise==6.8.2",
"mozilla-django-oidc==4.0.1",
]
From dd8bb18f697dd2d10c385d04c3bd729cc9cb83e4 Mon Sep 17 00:00:00 2001
From: Anthony LC
Date: Mon, 6 Jan 2025 16:31:29 +0100
Subject: [PATCH 10/16] =?UTF-8?q?=F0=9F=94=8A(changelog)=20add=20some=20ch?=
=?UTF-8?q?angelog=20entries?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Add some changelog entries that can be useful to
display in the release notes.
---
CHANGELOG.md | 2 ++
1 file changed, 2 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4721091c..99a5cb8b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -38,6 +38,8 @@ and this project adheres to
- 💄(frontend) add filtering to left panel #475
- ✨(frontend) new share modal ui #489
- ✨(frontend) add favorite feature #515
+- 📝(documentation) Documentation about self-hosted installation #530
+- ✨(helm) helm versioning #530
## Changed
From b93b43abe8b4918ad0153aa1496628b7163c613f Mon Sep 17 00:00:00 2001
From: Nathan Panchout
Date: Tue, 28 Jan 2025 09:23:01 +0100
Subject: [PATCH 11/16] =?UTF-8?q?=F0=9F=92=84(frontend)=20improve=20DocsGr?=
=?UTF-8?q?idItem=20responsive=20padding?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Adjusted padding and alignment for desktop and mobile views
- Conditionally applied CSS styles based on screen size
---
CHANGELOG.md | 1 +
.../docs/docs-grid/components/DocsGridItem.tsx | 12 ++++++++----
2 files changed, 9 insertions(+), 4 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 99a5cb8b..51e2bc5b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -19,6 +19,7 @@ and this project adheres to
## Changed
- 💄(frontend) add abilities on doc row #581
+- 💄(frontend) improve DocsGridItem responsive padding #582
## [2.0.1] - 2025-01-17
diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridItem.tsx b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridItem.tsx
index 4f37068d..e23c51f0 100644
--- a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridItem.tsx
+++ b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridItem.tsx
@@ -68,10 +68,14 @@ export const DocsGridItem = ({ doc }: DocsGridItemProps) => {
{showAccesses && (
Date: Tue, 28 Jan 2025 14:36:37 +0000
Subject: [PATCH 12/16] =?UTF-8?q?=E2=AC=86=EF=B8=8F(dependencies)=20update?=
=?UTF-8?q?=20python=20dependencies?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/backend/pyproject.toml | 16 ++++++++--------
1 file changed, 8 insertions(+), 8 deletions(-)
diff --git a/src/backend/pyproject.toml b/src/backend/pyproject.toml
index 286f07b7..f470b9ae 100644
--- a/src/backend/pyproject.toml
+++ b/src/backend/pyproject.toml
@@ -25,7 +25,7 @@ license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
- "boto3==1.35.90",
+ "boto3==1.36.7",
"Brotli==1.1.0",
"celery[redis]==5.4.0",
"django-configurations==2.5.1",
@@ -47,12 +47,12 @@ dependencies = [
"jsonschema==4.23.0",
"markdown==3.7",
"nested-multipart-parser==1.5.0",
- "openai==1.58.1",
- "psycopg[binary]==3.2.3",
+ "openai==1.60.2",
+ "psycopg[binary]==3.2.4",
"PyJWT==2.10.1",
"python-magic==0.4.27",
"requests==2.32.3",
- "sentry-sdk==2.19.2",
+ "sentry-sdk==2.20.0",
"url-normalize==1.4.3",
"whitenoise==6.8.2",
"mozilla-django-oidc==4.0.1",
@@ -71,16 +71,16 @@ dev = [
"freezegun==1.5.1",
"ipdb==0.13.13",
"ipython==8.31.0",
- "pyfakefs==5.7.3",
+ "pyfakefs==5.7.4",
"pylint-django==2.6.1",
- "pylint==3.3.3",
+ "pylint==3.3.4",
"pytest-cov==6.0.0",
"pytest-django==4.9.0",
"pytest==8.3.4",
"pytest-icdiff==0.9",
"pytest-xdist==3.6.1",
- "responses==0.25.3",
- "ruff==0.8.4",
+ "responses==0.25.6",
+ "ruff==0.9.3",
"types-requests==2.32.0.20241016",
]
From 265a24fe7efad06f34ce66f21ef8ad28e4151f72 Mon Sep 17 00:00:00 2001
From: Manuel Raynaud
Date: Tue, 28 Jan 2025 15:59:15 +0100
Subject: [PATCH 13/16] =?UTF-8?q?=F0=9F=9A=A8(back)=20update=20code=20lint?=
=?UTF-8?q?ing=20with=20ruff?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Apply new rules provided with ruff version v0.9.3
---
.../test_update_files_content_type_metadata.py | 6 +++---
.../core/tests/templates/test_api_templates_list.py | 12 ++++++------
.../tests/test_services_collaboration_services.py | 12 ++++++------
3 files changed, 15 insertions(+), 15 deletions(-)
diff --git a/src/backend/core/tests/commands/test_update_files_content_type_metadata.py b/src/backend/core/tests/commands/test_update_files_content_type_metadata.py
index 4ece3614..3ef78314 100644
--- a/src/backend/core/tests/commands/test_update_files_content_type_metadata.py
+++ b/src/backend/core/tests/commands/test_update_files_content_type_metadata.py
@@ -42,9 +42,9 @@ def test_update_files_content_type_metadata():
for key in keys:
head_resp = s3_client.head_object(Bucket=bucket_name, Key=key)
- assert (
- head_resp["ContentType"] == "image/png"
- ), f"ContentType not fixed, got {head_resp['ContentType']!r}"
+ assert head_resp["ContentType"] == "image/png", (
+ f"ContentType not fixed, got {head_resp['ContentType']!r}"
+ )
# Check that original metadata was preserved
assert head_resp["Metadata"].get("owner") == "None"
diff --git a/src/backend/core/tests/templates/test_api_templates_list.py b/src/backend/core/tests/templates/test_api_templates_list.py
index 44582b8f..11df4fa9 100644
--- a/src/backend/core/tests/templates/test_api_templates_list.py
+++ b/src/backend/core/tests/templates/test_api_templates_list.py
@@ -187,9 +187,9 @@ def test_api_templates_list_order_default():
response_template_ids = [template["id"] for template in response_data["results"]]
template_ids.reverse()
- assert (
- response_template_ids == template_ids
- ), "created_at values are not sorted from newest to oldest"
+ assert response_template_ids == template_ids, (
+ "created_at values are not sorted from newest to oldest"
+ )
def test_api_templates_list_order_param():
@@ -215,6 +215,6 @@ def test_api_templates_list_order_param():
response_template_ids = [template["id"] for template in response_data["results"]]
- assert (
- response_template_ids == templates_ids
- ), "created_at values are not sorted from oldest to newest"
+ assert response_template_ids == templates_ids, (
+ "created_at values are not sorted from oldest to newest"
+ )
diff --git a/src/backend/core/tests/test_services_collaboration_services.py b/src/backend/core/tests/test_services_collaboration_services.py
index 7d02e252..5a915170 100644
--- a/src/backend/core/tests/test_services_collaboration_services.py
+++ b/src/backend/core/tests/test_services_collaboration_services.py
@@ -55,9 +55,9 @@ def mock_reset_connections(settings):
)
yield
- assert (
- len(rsps.calls) == 1
- ), "Expected one call to reset-connections endpoint"
+ assert len(rsps.calls) == 1, (
+ "Expected one call to reset-connections endpoint"
+ )
request = rsps.calls[0].request
assert request.url == endpoint_url, f"Unexpected URL called: {request.url}"
assert (
@@ -66,9 +66,9 @@ def mock_reset_connections(settings):
), "Incorrect Authorization header"
if user_id:
- assert (
- request.headers.get("X-User-Id") == user_id
- ), "Incorrect X-User-Id header"
+ assert request.headers.get("X-User-Id") == user_id, (
+ "Incorrect X-User-Id header"
+ )
return _mock_reset_connections
From 609ff918944b73a087fbba62a57dd8dddb865afc Mon Sep 17 00:00:00 2001
From: Samuel Paccoud - DINUM
Date: Sat, 25 Jan 2025 10:51:30 +0100
Subject: [PATCH 14/16] =?UTF-8?q?=F0=9F=9A=B8(backend)=20on=20user=20searc?=
=?UTF-8?q?h=20match=20emails=20by=20Levenstein=20distance?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
When the query looks like an email (includes @) we search by
Levenstein distance because we are just trying to prevent typing
errors, not searching anymore.
It is important to still propose results with a short Levenstein
distance because it is frequent to forget a double letter in
someone's name for example "Pacoud" or even "pacou" instead of
"Paccoud" and we want to prevent duplicates or failing on
invitation.
We consider the query string to be an email as soon as it contains
a "@" character. Trying harder to identify a string that is really
an email would lead to weird behaviors like toto@example.gouv looking
like and email but if we continue typing toto@example.gouv.f not
looking like an email... before toto@example.gouv.fr finally looking
like an email. The result would be jumping from one type of search
to the other. As soon as there is a "@" in the query, we can be
sure that the user is not looking for a name anymore and we can
switch to matching by Levenstein distance.
---
CHANGELOG.md | 3 +-
src/backend/core/api/viewsets.py | 45 ++++++-----
.../0013_activate_fuzzystrmatch_extension.py | 16 ++++
src/backend/core/tests/test_api_users.py | 75 +++++++++----------
4 files changed, 78 insertions(+), 61 deletions(-)
create mode 100644 src/backend/core/migrations/0013_activate_fuzzystrmatch_extension.py
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 51e2bc5b..6198ed17 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,7 +11,7 @@ and this project adheres to
## Added
-- github actions to managed Crowdin workflow
+- github actions to manage Crowdin workflow
- 📈Integrate Posthog #540
- 🏷️(backend) add content-type to uploaded files #552
- ✨(frontend) export pdf docx front side #537
@@ -21,7 +21,6 @@ and this project adheres to
- 💄(frontend) add abilities on doc row #581
- 💄(frontend) improve DocsGridItem responsive padding #582
-
## [2.0.1] - 2025-01-17
## Fixed
diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py
index 5f2be8d8..a98ad4f5 100644
--- a/src/backend/core/api/viewsets.py
+++ b/src/backend/core/api/viewsets.py
@@ -20,6 +20,7 @@ from django.db.models import (
Subquery,
Value,
)
+from django.db.models.expressions import RawSQL
from django.http import Http404
import rest_framework as drf
@@ -150,29 +151,35 @@ class UserViewSet(
"""
queryset = self.queryset
- if self.action == "list":
- # Exclude all users already in the given document
- if document_id := self.request.GET.get("document_id", ""):
- queryset = queryset.exclude(documentaccess__document_id=document_id)
+ if self.action != "list":
+ return queryset
- # Filter users by email similarity
- if query := self.request.GET.get("q", ""):
- # For performance reasons we filter first by similarity, which relies on an index,
- # then only calculate precise similarity scores for sorting purposes
- queryset = queryset.filter(email__trigram_word_similar=query)
+ # Exclude all users already in the given document
+ if document_id := self.request.GET.get("document_id", ""):
+ queryset = queryset.exclude(documentaccess__document_id=document_id)
- queryset = queryset.annotate(
- similarity=TrigramSimilarity("email", query)
+ if not (query := self.request.GET.get("q", "")):
+ return queryset
+
+ # For emails, match emails by Levenstein distance to prevent typing errors
+ if "@" in query:
+ return (
+ queryset.annotate(
+ distance=RawSQL("levenshtein(email::text, %s::text)", (query,))
)
- # When the query only is on the name part, we should try to make many proposals
- # But when the query looks like an email we should only propose serious matches
- threshold = 0.6 if "@" in query else 0.1
+ .filter(distance__lte=3)
+ .order_by("distance", "email")
+ )
- queryset = queryset.filter(similarity__gt=threshold).order_by(
- "-similarity", "email"
- )
-
- return queryset
+ # Use trigram similarity for non-email-like queries
+ # For performance reasons we filter first by similarity, which relies on an
+ # index, then only calculate precise similarity scores for sorting purposes
+ return (
+ queryset.filter(email__trigram_word_similar=query)
+ .annotate(similarity=TrigramSimilarity("email", query))
+ .filter(similarity__gt=0.2)
+ .order_by("-similarity", "email")
+ )
@drf.decorators.action(
detail=False,
diff --git a/src/backend/core/migrations/0013_activate_fuzzystrmatch_extension.py b/src/backend/core/migrations/0013_activate_fuzzystrmatch_extension.py
new file mode 100644
index 00000000..db7fbb3a
--- /dev/null
+++ b/src/backend/core/migrations/0013_activate_fuzzystrmatch_extension.py
@@ -0,0 +1,16 @@
+# Generated by Django 5.1.4 on 2025-01-25 08:38
+
+from django.db import migrations
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('core', '0012_make_document_creator_and_invitation_issuer_optional'),
+ ]
+
+ operations = [
+ migrations.RunSQL(
+ "CREATE EXTENSION IF NOT EXISTS fuzzystrmatch;",
+ reverse_sql="DROP EXTENSION IF EXISTS fuzzystrmatch;",
+ ),
+ ]
diff --git a/src/backend/core/tests/test_api_users.py b/src/backend/core/tests/test_api_users.py
index e739d4d1..cb3022d3 100644
--- a/src/backend/core/tests/test_api_users.py
+++ b/src/backend/core/tests/test_api_users.py
@@ -42,8 +42,9 @@ def test_api_users_list_authenticated():
def test_api_users_list_query_email():
"""
- Authenticated users should be able to list users
- and filter by email.
+ Authenticated users should be able to list users and filter by email.
+ Only results with a Levenstein distance less than 3 with the query should be returned.
+ We want to match by Levenstein distance because we want to prevent typing errors.
"""
user = factories.UserFactory()
@@ -51,9 +52,7 @@ def test_api_users_list_query_email():
client.force_login(user)
dave = factories.UserFactory(email="david.bowman@work.com")
- nicole = factories.UserFactory(email="nicole_foole@work.com")
- frank = factories.UserFactory(email="frank_poole@work.com")
- factories.UserFactory(email="heywood_floyd@work.com")
+ factories.UserFactory(email="nicole.bowman@work.com")
response = client.get(
"/api/v1.0/users/?q=david.bowman@work.com",
@@ -62,59 +61,53 @@ def test_api_users_list_query_email():
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(dave.id)]
- response = client.get("/api/v1.0/users/?q=oole")
-
+ response = client.get(
+ "/api/v1.0/users/?q=davig.bovman@worm.com",
+ )
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()["results"]]
- assert user_ids == [str(nicole.id), str(frank.id)]
+ assert user_ids == [str(dave.id)]
+
+ response = client.get(
+ "/api/v1.0/users/?q=davig.bovman@worm.cop",
+ )
+ assert response.status_code == 200
+ user_ids = [user["id"] for user in response.json()["results"]]
+ assert user_ids == []
def test_api_users_list_query_email_matching():
- """While filtering by email, results should be filtered and sorted by similarity"""
+ """While filtering by email, results should be filtered and sorted by Levenstein distance."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
- alice = factories.UserFactory(email="alice.johnson@example.gouv.fr")
- factories.UserFactory(email="jane.smith@example.gouv.fr")
- michael_wilson = factories.UserFactory(email="michael.wilson@example.gouv.fr")
- factories.UserFactory(email="david.jones@example.gouv.fr")
- michael_brown = factories.UserFactory(email="michael.brown@example.gouv.fr")
- factories.UserFactory(email="sophia.taylor@example.gouv.fr")
+ user1 = factories.UserFactory(email="alice.johnson@example.gouv.fr")
+ user2 = factories.UserFactory(email="alice.johnnson@example.gouv.fr")
+ user3 = factories.UserFactory(email="alice.kohlson@example.gouv.fr")
+ user4 = factories.UserFactory(email="alicia.johnnson@example.gouv.fr")
+ user5 = factories.UserFactory(email="alicia.johnnson@example.gov.uk")
+ factories.UserFactory(email="alice.thomson@example.gouv.fr")
response = client.get(
- "/api/v1.0/users/?q=michael.johnson@example.gouv.f",
+ "/api/v1.0/users/?q=alice.johnson@example.gouv.fr",
)
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()["results"]]
- assert user_ids == [str(michael_wilson.id)]
+ assert user_ids == [str(user1.id), str(user2.id), str(user3.id), str(user4.id)]
- response = client.get("/api/v1.0/users/?q=michael.johnson@example.gouv.fr")
+ response = client.get("/api/v1.0/users/?q=alicia.johnnson@example.gouv.fr")
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()["results"]]
- assert user_ids == [str(michael_wilson.id), str(alice.id), str(michael_brown.id)]
-
- response = client.get(
- "/api/v1.0/users/?q=ajohnson@example.gouv.f",
- )
- assert response.status_code == 200
- user_ids = [user["id"] for user in response.json()["results"]]
- assert user_ids == [str(alice.id)]
-
- response = client.get(
- "/api/v1.0/users/?q=michael.wilson@example.gouv.f",
- )
- assert response.status_code == 200
- user_ids = [user["id"] for user in response.json()["results"]]
- assert user_ids == [str(michael_wilson.id)]
+ assert user_ids == [str(user4.id), str(user2.id), str(user1.id), str(user5.id)]
def test_api_users_list_query_email_exclude_doc_user():
"""
- Authenticated users should be able to list users
- and filter by email and exclude users who have access to a document.
+ Authenticated users should be able to list users while filtering by email
+ and excluding users who have access to a document.
"""
user = factories.UserFactory()
document = factories.DocumentFactory()
@@ -122,17 +115,19 @@ def test_api_users_list_query_email_exclude_doc_user():
client = APIClient()
client.force_login(user)
- nicole = factories.UserFactory(email="nicole_foole@work.com")
- frank = factories.UserFactory(email="frank_poole@work.com")
+ nicole_fool = factories.UserFactory(email="nicole_fool@work.com")
+ nicole_pool = factories.UserFactory(email="nicole_pool@work.com")
factories.UserFactory(email="heywood_floyd@work.com")
- factories.UserDocumentAccessFactory(document=document, user=frank)
+ factories.UserDocumentAccessFactory(document=document, user=nicole_pool)
- response = client.get("/api/v1.0/users/?q=oole&document_id=" + str(document.id))
+ response = client.get(
+ "/api/v1.0/users/?q=nicole_fool@work.com&document_id=" + str(document.id)
+ )
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()["results"]]
- assert user_ids == [str(nicole.id)]
+ assert user_ids == [str(nicole_fool.id)]
def test_api_users_retrieve_me_anonymous():
From a39990d90f8259a6ddd7a6d5966e4c3b3297fd2a Mon Sep 17 00:00:00 2001
From: Nathan Panchout
Date: Mon, 27 Jan 2025 11:42:00 +0100
Subject: [PATCH 15/16] =?UTF-8?q?=F0=9F=9A=B8(frontend)=20simplify=20invit?=
=?UTF-8?q?e=20user=20row=20logic=20in=20DocShareModal?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Refactor the endActions logic to show invite user row when searching by
email, removing the unnecessary length check for users
---
.../__tests__/app-impress/doc-editor.spec.ts | 46 ++++-----
.../app-impress/doc-visibility.spec.ts | 94 +++++++++----------
src/frontend/apps/impress/.env | 2 -
.../doc-share/components/DocShareModal.tsx | 17 ++--
4 files changed, 78 insertions(+), 81 deletions(-)
diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts
index 8dba8dab..02048c7f 100644
--- a/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts
+++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts
@@ -14,6 +14,29 @@ test.beforeEach(async ({ page }) => {
});
test.describe('Doc Editor', () => {
+ test('it saves the doc when we quit pages', async ({ page, browserName }) => {
+ // eslint-disable-next-line playwright/no-skipped-test
+ test.skip(browserName === 'webkit', 'This test is very flaky with webkit');
+
+ // Check the first doc
+ const doc = await goToGridDoc(page);
+
+ await verifyDocName(page, doc);
+
+ const editor = page.locator('.ProseMirror');
+ await editor.click();
+ await editor.fill('Hello World Doc persisted 2');
+ await expect(editor.getByText('Hello World Doc persisted 2')).toBeVisible();
+
+ await page.goto('/');
+
+ await goToGridDoc(page, {
+ title: doc,
+ });
+
+ await expect(editor.getByText('Hello World Doc persisted 2')).toBeVisible();
+ });
+
test('it check translations of the slash menu when changing language', async ({
page,
browserName,
@@ -241,29 +264,6 @@ test.describe('Doc Editor', () => {
await expect(editor.getByText('Hello World Doc persisted 1')).toBeVisible();
});
- test('it saves the doc when we quit pages', async ({ page, browserName }) => {
- // eslint-disable-next-line playwright/no-skipped-test
- test.skip(browserName === 'webkit', 'This test is very flaky with webkit');
-
- // Check the first doc
- const doc = await goToGridDoc(page);
-
- await verifyDocName(page, doc);
-
- const editor = page.locator('.ProseMirror');
- await editor.click();
- await editor.fill('Hello World Doc persisted 2');
- await expect(editor.getByText('Hello World Doc persisted 2')).toBeVisible();
-
- await page.goto('/');
-
- await goToGridDoc(page, {
- title: doc,
- });
-
- await expect(editor.getByText('Hello World Doc persisted 2')).toBeVisible();
- });
-
test('it cannot edit if viewer', async ({ page }) => {
await mockedDocument(page, {
abilities: {
diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-visibility.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-visibility.spec.ts
index 8414b0c7..5e5a9dc2 100644
--- a/src/frontend/apps/e2e/__tests__/app-impress/doc-visibility.spec.ts
+++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-visibility.spec.ts
@@ -67,6 +67,53 @@ test.describe('Doc Visibility', () => {
test.describe('Doc Visibility: Restricted', () => {
test.use({ storageState: { cookies: [], origins: [] } });
+ test('A doc is accessible when member.', async ({ page, browserName }) => {
+ test.slow();
+ await page.goto('/');
+ await keyCloakSignIn(page, browserName);
+
+ const [docTitle] = await createDoc(page, 'Restricted auth', browserName, 1);
+
+ await verifyDocName(page, docTitle);
+
+ await page.getByRole('button', { name: 'Share' }).click();
+
+ const inputSearch = page.getByRole('combobox', {
+ name: 'Quick search input',
+ });
+
+ const otherBrowser = browsersName.find((b) => b !== browserName);
+ const username = `user@${otherBrowser}.e2e`;
+ await inputSearch.fill(username);
+ await page.getByRole('option', { name: username }).first().click();
+
+ // Choose a role
+ const container = page.getByTestId('doc-share-add-member-list');
+ await container.getByLabel('doc-role-dropdown').click();
+ await page.getByRole('button', { name: 'Administrator' }).click();
+
+ await page.getByRole('button', { name: 'Invite' }).click();
+
+ await page.locator('.c__modal__backdrop').click({
+ position: { x: 0, y: 0 },
+ });
+
+ const urlDoc = page.url();
+
+ await page
+ .getByRole('button', {
+ name: 'Logout',
+ })
+ .click();
+
+ await keyCloakSignIn(page, otherBrowser!);
+
+ await page.goto(urlDoc);
+
+ await verifyDocName(page, docTitle);
+ await expect(page.getByLabel('Share button')).toBeVisible();
+ });
+
test('A doc is not accessible when not authentified.', async ({
page,
browserName,
@@ -127,53 +174,6 @@ test.describe('Doc Visibility: Restricted', () => {
page.getByText('You do not have permission to perform this action.'),
).toBeVisible();
});
-
- test('A doc is accessible when member.', async ({ page, browserName }) => {
- test.slow();
- await page.goto('/');
- await keyCloakSignIn(page, browserName);
-
- const [docTitle] = await createDoc(page, 'Restricted auth', browserName, 1);
-
- await verifyDocName(page, docTitle);
-
- await page.getByRole('button', { name: 'Share' }).click();
-
- const inputSearch = page.getByRole('combobox', {
- name: 'Quick search input',
- });
-
- const otherBrowser = browsersName.find((b) => b !== browserName);
- const username = `user@${otherBrowser}.e2e`;
- await inputSearch.fill(username);
- await page.getByRole('option', { name: username }).click();
-
- // Choose a role
- const container = page.getByTestId('doc-share-add-member-list');
- await container.getByLabel('doc-role-dropdown').click();
- await page.getByRole('button', { name: 'Administrator' }).click();
-
- await page.getByRole('button', { name: 'Invite' }).click();
-
- await page.locator('.c__modal__backdrop').click({
- position: { x: 0, y: 0 },
- });
-
- const urlDoc = page.url();
-
- await page
- .getByRole('button', {
- name: 'Logout',
- })
- .click();
-
- await keyCloakSignIn(page, otherBrowser!);
-
- await page.goto(urlDoc);
-
- await verifyDocName(page, docTitle);
- await expect(page.getByLabel('Share button')).toBeVisible();
- });
});
test.describe('Doc Visibility: Public', () => {
diff --git a/src/frontend/apps/impress/.env b/src/frontend/apps/impress/.env
index 3cf0e897..e69de29b 100644
--- a/src/frontend/apps/impress/.env
+++ b/src/frontend/apps/impress/.env
@@ -1,2 +0,0 @@
-NEXT_PUBLIC_API_ORIGIN=
-NEXT_PUBLIC_SW_DEACTIVATED=
diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareModal.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareModal.tsx
index 73236534..af8a3a93 100644
--- a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareModal.tsx
+++ b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareModal.tsx
@@ -140,15 +140,14 @@ export const DocShareModal = ({ doc, onClose }: Props) => {
return {
groupName: t('Search user result'),
elements: users,
- endActions:
- isEmail && users.length === 0
- ? [
- {
- content: ,
- onSelect: () => void onSelect(newUser),
- },
- ]
- : undefined,
+ endActions: isEmail
+ ? [
+ {
+ content: ,
+ onSelect: () => void onSelect(newUser),
+ },
+ ]
+ : undefined,
};
}, [searchUsersQuery.data, t, userQuery]);
From 8b0f4db650b266970855ac907281d54530511f84 Mon Sep 17 00:00:00 2001
From: virgile-deville
Date: Tue, 28 Jan 2025 19:41:33 +0100
Subject: [PATCH 16/16] =?UTF-8?q?=F0=9F=93=9D(doc)=20improve=20readme.md?=
=?UTF-8?q?=20after=20v2=20update?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
We want to serve as an example of our open source doc best practices.
We want people to find out.
- Which libraries we support
- How they can contribute translations and code
---
CHANGELOG.md | 2 +-
CONTRIBUTING.md | 9 ++++++++-
README.md | 28 +++++++++++++++++++---------
docs/assets/docs-logo.png | Bin 0 -> 17833 bytes
docs/assets/europe<3opensource.png | Bin 0 -> 82466 bytes
docs/assets/logo-docs.png | Bin 7445 -> 0 bytes
docs/assets/logo.png | Bin 0 -> 4401 bytes
7 files changed, 28 insertions(+), 11 deletions(-)
create mode 100644 docs/assets/docs-logo.png
create mode 100644 docs/assets/europe<3opensource.png
delete mode 100644 docs/assets/logo-docs.png
create mode 100644 docs/assets/logo.png
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6198ed17..5719eb92 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -51,7 +51,7 @@ and this project adheres to
- 💄(frontend) update DocHeader ui #448
- 💄(frontend) update doc versioning ui #463
- 💄(frontend) update doc summary ui #473
-- 📝(doc) update readme.md to match V2 changes #558
+- 📝(doc) update readme.md to match V2 changes #558 & #572
## Fixed
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 0c73f028..20c4e9e1 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -2,7 +2,14 @@
Thank you for taking the time to contribute! Please follow these guidelines to ensure a smooth and productive workflow. 🚀🚀🚀
-To get started with the project, please refer to the [README.md](https://github.com/numerique-gouv/impress/blob/main/README.md) for detailed instructions.
+To get started with the project, please refer to the [README.md](https://github.com/suitenumerique/docs/blob/main/README.md) for detailed instructions.
+
+Please also check out our [dev handbook](https://suitenumerique.gitbook.io/handbook) to learn our best practices.
+
+## Help us with translations
+
+You can help us with translations on [Crowdin](https://crowdin.com/project/lasuite-docs).
+Your language is not there? Request it on our Crowdin page 😊.
## Creating an Issue
diff --git a/README.md b/README.md
index 27b1af11..27b0e59f 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
@@ -27,7 +27,7 @@ Docs is a collaborative text editor designed to address common challenges in kno
* 😌 Simple collaborative editing without the formatting complexity of markdown
* 🔌 Offline? No problem, keep writing, your edits will get synced when back online
* 💅 Create clean documents with limited but beautiful formatting options and focus on content
-* 🧱 Built for productivity (markdown support, many block types, slash commands, markdown support, keyboard shortcuts) (page in french sorry 😅).
+* 🧱 Built for productivity (markdown support, many block types, slash commands, keyboard shortcuts).
* ✨ Save time thanks to our AI actions (generate, sum up, correct, translate)
### Collaborate
@@ -91,7 +91,7 @@ password: impress
$ make run-with-frontend
```
-⚠️ For the frontend developper, it is often better to run the frontend in development mode locally.
+⚠️ For the frontend developer, it is often better to run the frontend in development mode locally.
To do so, install the frontend dependencies with the following command:
@@ -144,12 +144,14 @@ Want to know where the project is headed? [🗺️ Checkout our roadmap](https:/
## Licence 📝
This work is released under the MIT License (see [LICENSE](https://github.com/suitenumerique/docs/blob/main/LICENSE)).
-While Docs is public driven initiative our licence choice is an invitation for private sector actors to use, sell and contribute to the project.
+While Docs is a public driven initiative our licence choice is an invitation for private sector actors to use, sell and contribute to the project.
## Contributing 🙌
-This project is intended to be community-driven, so please, do not hesitate to get in touch if you have any question related to our implementation or design decisions.
+This project is intended to be community-driven, so please, do not hesitate to [get in touch](https://matrix.to/#/#docs-official:matrix.org) if you have any question related to our implementation or design decisions.
-If you intend to make pull requests see CONTRIBUTING for guidelines.
+You can help us with translations on [Crowdin](https://crowdin.com/project/lasuite-docs).
+
+If you intend to make pull requests see [CONTRIBUTING](https://github.com/suitenumerique/docs/blob/main/CONTRIBUTING.md) for guidelines.
Directory structure:
@@ -167,7 +169,15 @@ docs
## Credits ❤️
### Stack
-Impress is built on top of [Django Rest Framework](https://www.django-rest-framework.org/), [Next.js](https://nextjs.org/), [MinIO](https://min.io/) and [BlocNote.js](https://www.blocknotejs.org/)
+Docs is built on top of [Django Rest Framework](https://www.django-rest-framework.org/), [Next.js](https://nextjs.org/), [MinIO](https://min.io/), [BlockNote.js](https://www.blocknotejs.org/), [HocusPocus](https://tiptap.dev/docs/hocuspocus/introduction) and [Yjs](https://yjs.dev/)
-### States ❤️ open source
-Docs is the result of a joint effort lead by the French 🇫🇷🥖 ([DINUM](https://www.numerique.gouv.fr/dinum/)) and German 🇩🇪🥨 government ([ZenDiS](https://zendis.de/)). We are always looking for new public partners feel free to reach out if you are interested in using or contributing to docs.
\ No newline at end of file
+### Gov ❤️ open source
+Docs is the result of a joint effort lead by the French 🇫🇷🥖 ([DINUM](https://www.numerique.gouv.fr/dinum/)) and German 🇩🇪🥨 governments ([ZenDiS](https://zendis.de/)).
+
+We are proud sponsors of [BlockNotejs](https://www.blocknotejs.org/) and [Yjs](https://yjs.dev/).
+
+We are always looking for new public partners (we are currently onboarding the Netherlands 🇳🇱🧀), feel free to [reach out](https://matrix.to/#/#docs-official:matrix.org) if you are interested in using or contributing to Docs.
+
+