Compare commits

..

211 Commits

Author SHA1 Message Date
Manuel Raynaud
fed3ad6a81 fixup! 📝(doc) add documentation to install using compose 2025-01-28 16:31:49 +01:00
Manuel Raynaud
350643a4c8 📝(doc) add documentation to install using compose
Write the documentation related to the docker compose installation.
2025-01-28 12:35:30 +01:00
Manuel Raynaud
6f62d8ec2a (compose) a config to deploy docs in production using compose
To ease the deployment of the application on infrastructure not running
k8s we created a compose file for production.
2025-01-28 12:35:30 +01:00
Manuel Raynaud
24328b5d6b ♻️(compose) remove usage of dockerize
Compose is able to manage service status to be healthy in its
dependecies. Configuring a healthcheck on services then defining a
condition on the depends_on settings allow to remove dockerize
2025-01-28 10:41:20 +01:00
Manuel Raynaud
9179fdb2fa 🔧(compose) rename legacy docker-compose.yml in compose.yaml
The compose configuration preferred filename is now compose.yaml
docker-compose.yml is the legacy one used by docker-compose v1
2025-01-28 10:41:18 +01:00
Manuel Raynaud
0d7d42254b (helm) add a job allowing to run arbitrary management command
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.
2025-01-28 10:33:30 +01:00
Anthony LC
67dc7feb98 🚑️(backend) command to update attachment content-type
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.
2025-01-28 10:33:30 +01:00
Anthony LC
5b4b100e90 🏷️(backend) add content-type to uploaded files
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.
2025-01-28 10:33:30 +01:00
Anthony LC
b8be010389 🚚(helm) add posthog proxy
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.
2025-01-28 10:05:37 +01:00
Anthony LC
97cfa2c1ad (frontend) integrate posthog analytics
We integrate posthog, it will help us to track
user behavior and improve the product.
We get the configuration from the backend config
endpoint.
2025-01-28 10:05:37 +01:00
Anthony LC
c018c6fcf5 🔧(backend) add posthog configuration
We add the posthog configuration to the project.
We will expose the posthog configuration to the
frontend.
2025-01-28 10:05:37 +01:00
Nathan Panchout
70048328d1 (frontend) update document sharing tests
- Added Share button interactions in various document visibility
scenarios
- Updated test assertions for share and copy link functionality
- Improved test coverage for document sharing features
2025-01-28 08:59:28 +01:00
Nathan Panchout
55ddfe9181 💄(frontend) refactor document sharing and grid components
Improvements:
- Added disabled state for dropdown menus in share settings
- Updated document grid layout and responsiveness
- Simplified sharing and access count logic
- Improved tooltips and visibility of shared documents
- Created a new responsive doc grid hook
2025-01-28 08:59:28 +01:00
renovate[bot]
ee41d156c7 ⬆️(dependencies) update django to v5.1.5 [SECURITY] 2025-01-27 10:06:16 +01:00
Manuel Raynaud
5be2bc7360 ♻️(actions) create a reusable workflow to install front dependencies
In more than one workflow we need to install frontend dependencies and
this 3 workflows we are copy/pasting the same code. We want to refactor
this by creating a reusable workflow
https://docs.github.com/en/actions/sharing-automations/reusing-workflows
2025-01-24 12:22:48 +01:00
Manuel Raynaud
e46ba4f506 🌐(back) update source translations
In order to have a repo correctly confiogured to managed translation
with crowdin, source translations for the backend app are updated
2025-01-24 12:22:48 +01:00
Manuel Raynaud
7c8b969fa9 🔥(back) remove compiled translation file
The compiles translation file should not be track with git.
2025-01-24 12:22:48 +01:00
Manuel Raynaud
95515fd460 🌐(action) create a workflow to download translation
A new workflow is added responsible todownload new translated strings
and then create a pull request to update the code.
2025-01-24 12:22:48 +01:00
Manuel Raynaud
ce6cfc22ef 🌐(action) upload sources translation on crowdin
Crowdin has released its own github action to automatize translation
workflow. We want to use to upload sources when a PR is merged.
2025-01-24 12:22:48 +01:00
virgile-dev
4b3b441fc3 📝(project) update readme following upgrade to v2
Remove old mentions to "impress" following the repository renaming.
Improve and update descriptions to better reflect the status of the
project after release version 2.
2025-01-21 23:39:22 +01:00
Anthony LC
9194bf5a90 🔖(patch) release 2.0.1
Fixed:
🐛(frontend) title copy break app
2025-01-17 11:58:55 +01:00
Anthony LC
dc63a5839e ⬇️(frontend) downgraded blocknote to 0.21.0
The last version of Blocknote (0.22.0) has a bug,
when we copy paste a title, the app sometimes crashes.
Better to downgrade to 0.21.0 until the bug is fixed.
2025-01-17 11:33:47 +01:00
Anthony LC
d406846986 🎨(frontend) format css blocknote editor
We use the "css" function of style components
to format correctly the blocknote editor css.
2025-01-17 11:33:47 +01:00
Nathan Panchout
e85b07021e 🐛(frontend) fix collaboration cursor
- The collaboration slider is not fully shown when a user is at the very
top of the document
2025-01-17 11:02:41 +01:00
Nathan Panchout
282200ac3d 🐛(frontend) hide the sharing method when you don't have the rights
- Added a new hook `useCopyDocLink` to handle copying document links to
the clipboard with success/error notifications.
- Updated the `DocToolBox`, `DocsGridActions`, and `DocShareModal`
components to utilize the new copy link feature.
- Enhanced tests to verify the functionality of the copy link button in
various scenarios.
- Adjusted visibility checks for sharing options based on user access
rights.
2025-01-17 11:02:41 +01:00
Anthony LC
de8dea20d5 🔖(major) release 2.0.0
Added:
- 🔧(backend) add option to configure list of
essential OIDC claims
- 🔧(helm) add option to disable default tls
setting by @dominikkaminski
- 💄(frontend) Add left panel
- 💄(frontend) add filtering to left panel
- (frontend) new share modal ui
- (frontend) add favorite feature

Changed:
- 🏗️(yjs-server) organize yjs server
- ♻️(frontend) better separation collaboration
process
- 💄(frontend) updating the header and leftpanel
for responsive
- 💄(frontend) update DocsGrid component
- 💄(frontend) update DocsGridOptions component
- 💄(frontend) update DocHeader ui
- 💄(frontend) update doc versioning ui
- 💄(frontend) update doc summary u

Fixed:
- 🐛(backend) fix create document via s2s
if sub unknown but email found
- 🐛(frontend) hide search and create doc
button if not authenticated
- 🐛(backend) race condition creation issue
2025-01-15 12:46:00 +01:00
Anthony LC
342fc2ab59 ✏️(backend) fix read_only_fields is_favorite
is_favorite has a typo error.
This commit fixes it.
2025-01-15 12:13:40 +01:00
Anthony LC
b8132ef393 🐛(backend) creation race condition
3 requests we able to create a document:
- POST document request
- GET collaboration-auth
- GET media-auth

If the 2 last were faster than the first, a
document was created without the necessary
informations.
2025-01-15 12:13:40 +01:00
Nathan Panchout
2ede746d8a (frontend) hide search and create doc button if not logged
- Added visibility checks for 'search' and 'New doc' buttons in the
document visibility tests.
- Updated LeftPanelHeader to conditionally render 'search' and 'New doc'
buttons based on user authentication status, improving user experience
and access control.
2025-01-15 12:00:40 +01:00
Anthony LC
5bd0764bdd 🌐(frontend) add last translations
Add the missing translations FR / DE.
2025-01-14 17:38:19 +01:00
Samuel Paccoud - DINUM
610948cd16 🐛(backend) fix create document for user when sub does not match
When creating a document on behalf of a user via the server-to-server
API, a special edge case was broken that should should never happen
but happens in our OIDC federation because one of the provider modifies
the users "sub" each time they login.

We end-up with existing users for who the email matches but not the sub.
They were not correctly handled.

I made a few additional fixes and improvements to the endpoint.
2025-01-14 16:18:14 +01:00
Samuel Paccoud - DINUM
96bb99d6ec 🐛(compose) fix "port already taken" errors when starting docker compose
We have changed the project's name from "impress" to "docs" but haven't
replaced all occurrences of impress in the project because we want to be
careful of the consequences on deployments.

The name of the docker compose project was different for the "make pylint"
target. This was causing the bug error on ports. Let's rename it without
waiting.
2025-01-14 16:18:14 +01:00
Anthony LC
a090f180f4 💄(frontend) make favicon more visible
In dark mode, the favicon is not correctly visible.
This commit makes it more visible by adding some
white color to the favicon.
2025-01-13 18:15:25 +01:00
Anthony LC
c7e543d459 🐛(frontend) switch to other provider
When we redirect from a doc to another, the components
are not unmounted and states are not reset.
We now destroy the provider if we see that
the provider is not bind to the current doc.
2025-01-13 18:15:25 +01:00
Anthony LC
23e6b508f8 🚚(frontend) harmonize imports
Harmonize the imports in the frontend codebase
to keep the codebase consistent.
2025-01-13 11:02:24 +01:00
Nathan Panchout
49a3989977 💄(frontend) fix minor bugs and enhance DocTitle and DocShareModal
- Fixed minor bugs in the frontend codebase for improved stability.
- Enhanced DocTitle component to update title display dynamically using
useEffect.
- Refactored DocShareModal to improve modal content height calculation
for better responsiveness.
2025-01-13 11:02:24 +01:00
Nathan Panchout
8eb2b60937 (frontend) enhance document grid tests and component interactions
- Updated test cases to replace 'docs-grid-loader' with 'grid-loader'
for improved consistency across document grid tests.
- Refactored document interaction tests to utilize the 'docs-grid'
locator for better readability and maintainability.
- Enhanced document table content tests by refining element selection
methods for improved clarity and performance.
- Cleaned up test code to ensure better structure and maintainability.
2025-01-13 11:02:24 +01:00
Nathan Panchout
f02dcae52a (frontend) enhance DocShareModal and footer components
- Refactored DocShareModal to improve user experience with dynamic list
height and responsive design adjustments.
- Introduced new styling for modal elements using createGlobalStyle for
better visual consistency.
- Updated footer button text from 'Ok' to 'OK' for improved clarity.
- Enhanced user selection handling and search functionality within the
modal for better document sharing experience.
2025-01-13 11:02:24 +01:00
Nathan Panchout
098df5c0b5 (frontend) enhance UI components and improve document management
- Updated DropdownMenu to include index-based styling for better visual
consistency.
- Refactored QuickSearchStyle to remove unnecessary transitions for
smoother performance.
- Adjusted modal styles in cunningham-style.css for improved layout.
- Changed BlockNoteEditor to update block type from 'heading' to
'paragraph' for better content structure.
- Enhanced DocHeader and DocToolBox components with updated color themes
for improved visibility.
- Modified ModalRemoveDoc to change size and clean up unnecessary props
for better usability.
- Improved Heading and TableContent components to handle empty states
more gracefully.
- Updated DocsGrid to conditionally render content based on document
availability, enhancing user experience.
- Refined LeftPanel components for better layout and visual hierarchy,
including adjustments to padding and separators.
2025-01-13 11:02:24 +01:00
Nathan Panchout
684b77cbe6 (frontend) enhance DocsGrid component and simplify layout
- add scroll inside the doc grid
2025-01-13 11:02:24 +01:00
Nathan Panchout
81e9fc49fe (frontend) enhance document interaction tests
- Updated test cases to improve accessibility by replacing 'more_vert'
with 'more_horiz' for action buttons across various components.
- Refactored document deletion confirmation messages to use consistent
heading roles for better visibility.
- Simplified keyboard interactions in document table content tests for
improved clarity.
- Adjusted visibility checks for the share button to utilize more
descriptive labels.
- Cleaned up test code for maintainability and consistency.
2025-01-13 11:02:24 +01:00
Nathan Panchout
7c696fc1ec (frontend) enhance document sharing components
- Refactored DocShareAddMemberList to simplify button styling and
improve loading state handling.
- Updated DocShareAddMemberListItem and DocShareMemberItem to enhance
spacing and button color for better visual consistency.
- Improved DocShareInvitationItem and SearchUserRow with new theming and
spacing tokens for a more cohesive design.
- Adjusted padding and layout in DocShareModal and DocShareModalFooter
for improved responsiveness.
- Enhanced DocVisibility component with updated padding and text styling
for better readability.
- Cleaned up unused imports and optimized component structures for
maintainability.
2025-01-13 11:02:24 +01:00
Nathan Panchout
fc27043e9e (frontend) enhance document editor and header components
- Improved styling for headings in BlockNoteEditor for better visual
hierarchy.
- Adjusted padding in DocEditor and DocHeader based on device type for
responsive design.
- Updated DocTitle and ModalExport components to enhance typography and
spacing.
- Refactored DocToolBox to improve share button functionality and access
display.
- Enhanced versioning modal with better layout and accessibility
features.
- Cleaned up unused imports and optimized component structures for
maintainability.
2025-01-13 11:02:24 +01:00
Nathan Panchout
6ad1e27acf (frontend) enhance UI components and improve styling
- Updated DropdownMenu and ButtonLogin components for better
accessibility and visual consistency.
- Refactored Header and Title components to utilize new theming and
spacing tokens.
- Enhanced LanguagePicker styles for improved user experience.
- Introduced new utility functions in doc-management for better handling
of ProseMirror nodes and Yjs integration.
- Cleaned up unused imports and adjusted component styles for overall
code maintainability.
2025-01-13 11:02:24 +01:00
Nathan Panchout
899047d9a2 (frontend) enhance QuickSearch components
- Updated QuickSearchItemContent to ensure full width for better layout
consistency.
- Adjusted padding in QuickSearchStyle for improved spacing and visual
hierarchy.
- Refactored DocSearchItem to utilize Box component for consistent
styling and layout.
- Removed unused imports in DocSearchItem to streamline the codebase.
2025-01-13 11:02:24 +01:00
Nathan Panchout
78b5e2c1cc (frontend) enhance document grid
- Updated the layout and styling of the DocsGrid and DocsGridItem
components for improved responsiveness and visual consistency.
- Added a new background prop to the UserAvatar component for
customizable user avatars.
- Enhanced the DocsGridActions component to include a share option,
allowing users to share documents easily.
- Refactored SVG assets for pinned and simple documents to improve their
dimensions and visual representation.
- Improved the SimpleDocItem component to display document update times
and access indicators more effectively.
- Adjusted padding and spacing across various components to enhance
overall user experience.
2025-01-13 11:02:24 +01:00
Nathan Panchout
72f234027c (frontend) enhance left panel components
- Updated padding and radius styles in LeftPanelTargetFilters and
LeftPanelFavorites for improved layout consistency.
- Introduced LeftPanelDocContent component to display document details
when navigating to specific documentation pages.
- Enhanced LeftPanelContent to conditionally render LeftPanelDocContent
based on the current route.
- Adjusted LeftPanelHeader button colors for better visual hierarchy.
- Refactored MainLayout padding for a more responsive design.
2025-01-13 11:02:24 +01:00
Nathan Panchout
730efe7b74 (frontend) update color tokens and styles
- Modified color tokens for danger and info categories to enhance visual
consistency and accessibility.
- Updated button and modal styles, including adjustments to padding and
dimensions for improved layout.
- Replaced font files for Marianne with updated versions to ensure
better typography.
2025-01-13 11:02:24 +01:00
Nathan Panchout
63885117e1 (frontend) implement document favorites feature
- Added functionality to mark documents as favorites, including new
hooks `useMakeFavoriteDoc` and `useRemoveFavoriteDoc` for managing
favorite status.
- Enhanced the document management API to support favorite filtering
with the `is_favorite` parameter.
- Created a new e2e test for the favorite workflow to ensure proper
functionality.
- Updated the UI components to reflect favorite status, including
changes in `DocsGridActions`, `DocsGridItem`, and the new
`LeftPanelFavorites` component for displaying pinned documents.
- Adjusted SVG assets for better visual representation of pinned
documents.
2025-01-13 11:02:24 +01:00
Nathan Panchout
4f4c8905ff (frontend) update tests
- Enhanced test coverage for document sharing, member roles, and
visibility settings
2025-01-13 11:02:24 +01:00
Nathan Panchout
a5f6cb542d 🔥(frontend) remove unused document management components
- Deleted `DocVisibility`, `ModalShare`, `InvitationList`, `MemberList`,
and related components to streamline the document management feature.
- Updated component exports to reflect the removal of these components.
- Cleaned up associated assets and styles to improve code
maintainability.
2025-01-13 11:02:24 +01:00
Nathan Panchout
8456f47260 (frontend) enhance dropdown components and add new LoadMoreText feature
- Updated DropButton and DropdownMenu components to include new props
for accessibility and improved layout.
- Introduced LoadMoreText component for better user experience in
loading additional content.
- Added SearchUserRow and UserAvatar components for improved user search
functionality.
- Cleaned up unused imports and adjusted styles for better consistency
across components.
2025-01-13 11:02:24 +01:00
Nathan Panchout
eb35fdc7a9 (frontend) enhance document sharing features and role management
- Introduced new hooks and components for improved document sharing
functionality, including `useTranslatedShareSettings` and
`DocShareModal`.
- Added role management capabilities with `DocRoleDropdown` and
`DocShareAddMemberList` components, allowing users to manage document
access and roles effectively.
- Implemented user invitation handling with `DocShareInvitationItem` and
`DocShareMemberItem` components, enhancing the user experience for
managing document collaborators.
- Updated translation handling for role and visibility settings to
ensure consistency across the application.
- Refactored existing components to integrate new features and improve
overall code organization.
2025-01-13 11:02:24 +01:00
Nathan Panchout
ceaf1e28f9 (frontend) refactor QuickSearch components
- Simplified QuickSearchProps by removing unused properties and
enhancing type definitions.
- Updated QuickSearch component to utilize children for rendering,
improving flexibility.
- Added separator prop to QuickSearchInput for better control over
layout.
- Removed data prop from DocSearchModal's QuickSearch to streamline the
component's usage.
2025-01-13 11:02:24 +01:00
Nathan Panchout
c3da23f5d3 (frontend) add new color tokens and utility classes
- Introduced a comprehensive set of color tokens for blue, brown, cyan,
gold, green, olive, orange, pink, purple, and yellow shades.
2025-01-13 11:02:24 +01:00
Nathan Panchout
44784b2236 (frontend) implement document search functionality
- Added a new DocSearchModal component for searching documents.
- Introduced DocSearchItem component to display individua
 document results.
- Enhanced the useDocs API to support title-based searching.
- Implemented e2e tests for document search visibility and
functionality.
- Included an empty state illustration for no search results.
- Updated the LeftPanelHeader to open the document search modal.
2025-01-13 11:02:24 +01:00
Nathan Panchout
157f6200f2 (frontend) add Quick Search component suite
- Introduced a new Quick Search feature with multiple components
- Implemented styling for the Quick Search components to
ensure a cohesive look and feel across the application.
2025-01-13 11:02:24 +01:00
Nathan Panchout
2882348547 (frontend) update dependencies and enhance package configurations
- Added new dependencies: `luxon` and its type definitions
to the e2e app
- Introduced `cmdk` and `use-debounce` to the impress
app for enhanced UI components and debouncing functionality.
2025-01-13 11:02:24 +01:00
Nathan Panchout
e016cfab70 🐛(frontend) fix document editor height and update translations
- Adjusted the document editor height in the DocEditor component
- Updated translations for various terms to ensure consistency
cross the application.
- Improved layout and spacing in the DocsGridItem
component for a cleaner presentation.
2025-01-13 11:02:24 +01:00
Nathan Panchout
23b11e4096 💄(frontend) update document summary UI
- Enhanced the document summary UI for better visibility
and interaction.
- Refactored the DocHeader and DocEditor components to
 improve layout and responsiveness.
- Updated tests for the DocTableContent to reflect changes
in heading interactions and visibility checks.
2025-01-13 11:02:24 +01:00
Nathan Panchout
7696872416 (frontend) implement document filtering
- Introduced a new enum for default document filters
to improve code clarity.
- Updated the API call to support filtering documents
based on the creator.
- Enhanced the DocsGrid component to accept a target
filter, allowing dynamic content rendering based on user selection.
- Modified the main layout to include a left panel for improved
navigation and user experience.
- Added a new test suite for document filters, verifying the visibility
and selection states of 'All docs', 'My docs', and 'Shared with me'.
2025-01-13 11:02:24 +01:00
Nathan Panchout
42d9fa70a2 🔧(frontend) remove deprecated routes and update service worker
- Removed the versioning route from the default configuration to
streamline the documentation structure.
- Updated the service worker to eliminate references to the deprecated
 versioning fallback, enhancing the offline experience for users.
2025-01-13 11:02:24 +01:00
Nathan Panchout
a8a89def98 (frontend) enhance document versioning and loading experience
- Updated tests for document member list and versioning to utilize
'Load more' button instead of mouse wheel scrolling.
- Improved UI for document versioning, including visibility
checks and modal interactions.
- Refactored InfiniteScroll component to include a button for
loading more items, enhancing user experience.
- Adjusted DocEditor and DocHeader components to handle
version IDs more effectively.
- Removed deprecated versioning pages to streamline the codebase.
2025-01-13 11:02:24 +01:00
Nathan Panchout
5bcce0c64a 💄(frontend) update doc export modal
In the new interface, the export modal changes a little.

- We put the buttons on the right
- We remove the alert
- We transform the radio into select
2025-01-13 11:02:24 +01:00
Nathan Panchout
3a738fe701 (frontend) adapt all tests related to the new header
Since we no longer use an editable div but an input, we must
modify the tests accordingly
2025-01-13 11:02:24 +01:00
Nathan Panchout
d5670640f5 (frontend) update doc header ui
Modification of the header style to be consistent with the new UI :
- We replace the option menu with the DropdownMenu component
- We add a dowload button
- We put an input in place of an editable div.
2025-01-13 11:02:24 +01:00
Nathan Panchout
1d85eee78f 💄(frontend) add dropdown option for DocGridItem
Implement dropdown menu with functionality to delete a document
from the list
2025-01-13 11:02:24 +01:00
Nathan Panchout
5a46ab0055 (frontend) update tests to align with the new interface changes
- Adjust selectors and assertions to reflect updates in the UI layout and
design.
- Ensure all modified tests maintain compatibility with the updated structure.
- Fix any broken test cases caused by the redesign.
2025-01-13 11:02:24 +01:00
Nathan Panchout
3d5ff93a51 💄(frontend) update DocsGrid component
Implement the new version of  the DocsGrid  component
2025-01-13 11:02:24 +01:00
Nathan Panchout
b9c66c7c2a 🔧(frontend) update cunningham configuration
- update primary colors,and spacing.
- update tertiary button
2025-01-13 11:02:24 +01:00
Nathan Panchout
68a390ef59 (frontend) add react-intersection-observer package
- Install `react-intersection-observer` to manage element visibility detection.
- Enables features like lazy loading, animations on scroll, and triggering
events when elements appear in the viewport.
2025-01-13 11:02:24 +01:00
Nathan Panchout
192ab1121c 🔥(frontend) remove unused components due to new interface
Deleted two components that were no longer needed following the
implementation of the new interface. This cleanup helps streamline
he codebase and avoid unnecessary maintenance.
2025-01-13 11:02:24 +01:00
Nathan Panchout
83eb33d54a 💄(frontend) updating the header and leftpanel for responsive
Previously we added a left panel. We now need to adapt the layout
so that it becomesresponsive.

We therefore add a burger menu on the left on mobile which,
when clicked, deploys the left-panel over all the content.
2025-01-13 11:02:24 +01:00
Nathan Panchout
ee937de2c4 (frontend) update tests
Some minor changes have been integrated into the list of documents.
The tests must therefore be adapted accordingly.
2025-01-13 11:02:24 +01:00
Nathan Panchout
8d514bd571 💄(frontend) add left panel
In the new interface there is a new left panel. We implement it and add it
to the MainLayout
2025-01-13 11:02:24 +01:00
Nathan Panchout
e83c404e21 💄(frontend) add cunningham tokens
In order to use the spaces and grays of the DSFR,
we update the cunningham.ts file
2025-01-13 11:02:24 +01:00
Samuel Paccoud - DINUM
945f55f50d (backend) add test to secure updating user when matched on email
We had doubts that the user was correctly updated in the case where
its identity was matched on the email and not on the sub. I added
a test and confirmed that it was working correctly. I still modified
the backend to update the user based on its "id" instead of its "sub"
because it was confusing, but both actually work the same.
2025-01-10 19:30:17 +01:00
Samuel Paccoud - DINUM
9f83ea7111 ♻️(backend) rename required claims to essential claims as per spec
It was pointed by @lebaudantoine that the OIDC specification uses
the term "essential claims" for what we called required claims.

Further more, the Mozilla OIDC library that we use, validates claims
in a method called "verify_claims". Let's override this method.
2025-01-10 19:30:17 +01:00
Jacques ROUSSEL
f12c06e975 🔧(helm) add option to configure deployment annotations
We need to be abble to add specific annotations on Deployment in order
to use a reloader when external-secret sync new secrets
2025-01-09 07:20:01 +01:00
Jacques ROUSSEL
bbb176e153 🐛(CI) fix ci
The backend secret is managed by external-secret now so we should not
keep it in the chart
2025-01-08 11:55:40 +01:00
Jacques ROUSSEL
02793040fd 🐛(CI) improve helm release
In order to avoid a github release when we build the helm chart, we use
another action
2025-01-06 15:44:16 +01:00
Jacques ROUSSEL
0773e83149 📝(self-hosted) add documentation
Add a documentation to deploy a self-hosted visio instance in a
standalone way (witout AI features)
2025-01-06 15:44:16 +01:00
Jacques ROUSSEL
21205b4d19 🐛(CI) add helm release action
In order to avoird code duplication we have to release a helm chart
2025-01-06 15:44:16 +01:00
Jacques ROUSSEL
60dbf6c11d 💚(ci) fix jobs after migration
The repository migration broke the CI. To fix it, we removed the
dependency on the secrets repository.
2025-01-06 12:17:40 +01:00
Anthony LC
2491ad7142 (e2e) adapt test to new Blocknote release
Copy as html provide a html lightly different than
before, so the test was adapted to the new html
provided.
2025-01-02 15:42:39 +01:00
Anthony LC
3b2834cf6d ⬆️(dependencies) update js dependencies 2025-01-02 15:42:39 +01:00
renovate[bot]
7ed2b23ea3 ⬆️(dependencies) update python dependencies 2024-12-30 11:00:32 +01:00
Samuel Paccoud - DINUM
c879f82114 (backend) add option to configure list of required OIDC claims
We want to be able to refuse connection for users who have missing
claims from a list of required keys.
2024-12-24 17:10:52 +01:00
Anthony LC
02a4740c66 ♻️(frontend) create useProviderStore
We created useProviderStore, a store dedicated
to managing the provider of the document.
We created as well a new hook useCollaboration,
it will be use to interact with the provider store.
This refacto is a first step to implement
the long polling.
2024-12-24 12:29:30 +01:00
Anthony LC
6cb2702e6b ♻️(frontend) update already existing tasks
When a task was already existing, we were not
updating it. This commit fixes that.
2024-12-24 12:29:30 +01:00
Anthony LC
94a9f7a84e 🔒️(y-provider) add cors middlewares
Add cors middlewares to y-provider server.
It will control how clients connect to the server
with http requests.
2024-12-24 12:29:30 +01:00
Anthony LC
e53465ce11 🏗️(y-provider) organize yjs server
Many routes were in the server.ts file, now they
are in their own files in the handlers folder.
The server.ts file is now AppServer that handles
the routes.
We split as well the tests.
2024-12-24 12:29:30 +01:00
Julien Bouquillon
33d1f3c151 ️(y-provider) reduce sentry tracesSampleRate
Reduce `tracesSampleRate` due to +120k daily events.
2024-12-20 09:52:43 +01:00
Julien Bouquillon
fc4eba2497 ️(frontend) reduce sentry tracesSampleRate
Reduce `tracesSampleRate` due to +120k daily events.
2024-12-20 09:52:43 +01:00
Dominik Kaminski
3e5f27c1d5 🔧(helm) add option to disable default tls setting
Sets an option for those who uses impress
with a different secretName in ingress.
2024-12-19 15:16:16 +01:00
Anthony LC
f2f64f7dd6 🔖(minor) release 1.10.0
Added:
- (backend) add server-to-server API endpoint
to create documents
- (email) white brand email
- (y-provider) create a markdown converter endpoint

Changed:
- ️(docker) improve y-provider image

Fixed:
- ️(e2e) reduce flakiness on e2e tests
2024-12-17 17:54:49 +01:00
Anthony LC
d842800df3 ✏️(email) change the quotation marks around role
The quotation marks around role have been changed
to the wrong ones. This commit fixes this issue.
2024-12-17 17:29:05 +01:00
Anthony LC
1af2ad0ec4 🔧(helm) add conf white brand email
Add the demo configuration for the
white brand email.
2024-12-17 17:29:05 +01:00
Anthony LC
67915151aa (e2e) add a test on doc creation server side
We recently added a new feature to the app, which
is the ability to create a document from server to
server.
Server A will send a request to Server B with
a markdown content, and Server B will create a
the document after converting the markdown to
yjs base64 format.
This test will check all the steps of the process
and assert that the document is displayed correctly
on the frontend in the blocknote editor.
2024-12-17 14:49:23 +01:00
Anthony LC
de25b36a01 🐛(CI) add chrome playwright install
In a recent commit we removed Chrome from the
install of playwright in the CI job test-e2e,
but it is needed, we put it back.
2024-12-17 14:12:41 +01:00
Anthony LC
59e74e6eeb 🐛(e2e) fix flaky tests
3 tests were flags are flaky or bringing flakiness.
We improved them.
2024-12-17 12:42:02 +01:00
Anthony LC
4e7f095b0f ️(e2e) set maxFailures with CI
If a test fails (retries included), the test runner
will stop after reaching maxFailures.
We will not have to wait for all tests to
run to see the results.
2024-12-17 12:42:02 +01:00
Anthony LC
cdea75b87f ️(ci) playwright install
Sometimes Playwwright installation fails on CI,
it seems to arrive when we update the dependency cache.
We will do a general install before installing the
playwright browser to be sure everything is in place,
it should be fast since we have the cache.
We move the playwright installation before setting
the docker container, so we will wait less if we have
to retry the test because of the Playwwright installation.
2024-12-17 12:42:02 +01:00
Anthony LC
6a0d2e21b5 🐛(frontend) fix rerendering when doc is saving
When the document is saved, the blocknote toolbar
was rerendering, causing the toolbar to close
some panels.
It was creating flakiness in the e2e tests, plus
it was not a good user experience.
This commit fixes this issue.
2024-12-17 12:42:02 +01:00
Anthony LC
b79d5fccbc ⬆️(dependencies) update js dependencies 2024-12-16 18:28:37 +01:00
Anthony LC
6d77cb1801 ️(docker) improve y-provider image
Improve y-provider image by having the
node_modules as small as possible.
We move split the Dockerfile and
add it to the y-provider folder,
it will be easier to read and maintain.
2024-12-16 17:39:45 +01:00
lebaudantoine
e4a45a556c 🐛(backend) fix bucket access in the tilt stack
S3 username was desynchronized with the helmfile. Leading to error,
when patching object or saving any update to the Minio bucket.

@rouja fixed it.
2024-12-16 17:17:42 +01:00
lebaudantoine
3ca39ceb8a ♻️(yprovider) support multiple API keys to separate responsibilities
Support for two API keys has been added to the YProvider microservice to
decouple responsibilities between the collaboration server and other
endpoints. This improves security by scoping keys to specific purposes and
ensures a clearer separation of concerns for easier management and debugging.
2024-12-16 17:17:42 +01:00
lebaudantoine
8a93122882 (yprovider) add test to prevent silent breaking changes
Per Quentin's request, added a test to ensure developers are warned
if the token format is updated, preventing backend compatibility issues.
2024-12-16 17:17:42 +01:00
lebaudantoine
8eb986591a 💡(backend) warm about the token nature of Yprovider microservice
Note to the future myself, using a raw token format is
not common. It should be refactor
2024-12-16 17:17:42 +01:00
lebaudantoine
c10808b611 ♻️(backend) generalize YProvider API config
Abstracted base URL and API key under 'y-provider' for
reuse in future endpoints, aligning with microservice naming.

Please note the YProvider API here is internal to the cluster.
In facts, we don't want these endpoints to be exposed by any ingress
2024-12-16 17:17:42 +01:00
lebaudantoine
ba63358098 🔧(backend) configure conversion microservice in dev
Update helm values to configure the conversion microservice while
creating document server to server.
2024-12-16 17:17:42 +01:00
lebaudantoine
52534db3e1 🐛(backend) fix issues with conversion microservice integration
Minor adjustments were needed after working in parallel on two PRs.
The microservice now accepts an API key without requiring it as a Bearer token.

A mistake in reading the microservice response was corrected after refactoring
the serializer to delegate logic to the converter microservice.
2024-12-16 17:17:42 +01:00
renovate[bot]
dc9b375ff5 ⬆️(dependencies) update python dependencies 2024-12-16 10:45:49 +01:00
renovate[bot]
65fdf115be ⬆️(dependencies) update django to v5.1.4 [SECURITY] 2024-12-13 21:28:11 +01:00
Anthony LC
ecb2b35ec8 (email) white brand email
The email was branded "La Suite Numérique",
we updated the template to make it generic, we
will use settings env variables to customize the
email for each brand.
2024-12-13 17:58:43 +01:00
renovate[bot]
2d13e0985e ⬆️(dependencies) update python dependencies 2024-12-12 18:56:25 +01:00
lebaudantoine
5014443f80 💩(y-provider) init a markdown converter endpoint
This code is quite poor. Sorry, I don't have much time working
on this feature. However, it should be functional.

I've reused the code we created for the Demo with Kasbarian.
I've not tested it yet with all corner case. Error handling
might be improved for sure, same for logging.

This endpoint is not modular. We could easily introduce options
to modify its behavior based on some options. YAGNI

I've added bearer token authentification, because it's unclear
how this micro service would be exposed. It's totally not required
if the microservice is not exposed through an Ingress.
2024-12-12 14:37:30 +01:00
lebaudantoine
3fef7596b3 (y-provider) create utils function toBase64
Add utility function to convert BitArray to Base64 string.
This is required for creating Base64-encoded documents,
as the frontend do.
2024-12-12 14:37:30 +01:00
lebaudantoine
19042907be (y-provider) add BlockNote server utils and yjs
Needed dependencies to mimic frontend code when generating a
document from a markdown string.

Will be used in the upcoming commits.
2024-12-12 14:37:30 +01:00
Samuel Paccoud - DINUM
5cdd06d432 (backend) add server-to-server API endpoint to create documents
We want trusted external applications to be able to create documents
via the API on behalf of any user. The user may or may not pre-exist
in our database and should be notified of the document creation by
email.
2024-12-12 14:01:46 +01:00
Samuel Paccoud - DINUM
47e23bff90 🔥(emails) remove dead template code
The email footer file is not being used and should be deleted.
2024-12-12 14:01:46 +01:00
Anthony LC
7dfc62b2c5 🔖(minor) release 1.9.0
Added:
- (backend) annotate number of accesses
on documents in list view
- (backend) allow users to mark/unmark
documents as favorite

Changed:
- 🔒️(collaboration) increase collaboration access security
- 🔨(frontend) encapsulated title to its own component
- ️(backend) optimize number of queries on
document list view
- ♻️(frontend) stop to use provider with version
- 🚚(collaboration) change the websocket key name

Fixed:
- 🐛(frontend) fix initial content with collaboration
- 🐛(frontend) Fix hidden menu on Firefox
- 🐛(backend) fix sanitize problem IA
2024-12-12 08:37:10 +01:00
Anthony LC
39c4af0a7c 🐛(frontend) hide cursor for authenticated users
When a authenticated user was in read-only mode,
the cursor was still visible.
This commit hides the cursor for authenticated users
in read-only mode.
2024-12-11 21:18:40 +01:00
Anthony LC
57c5c394f5 🐛(backend) improve sanitizer ai_services
The json response of the AI service is badly formatted.
This commit improves the sanitizer to try
to handle the response correctly.
2024-12-11 21:18:40 +01:00
Anthony LC
be6da38a08 🐛(frontend) only owner will make initial content
In some cases, the sync of the initial content
is not being done correctly.
We will let only the owner of the document
to make the initial content.
2024-12-11 21:18:40 +01:00
Anthony LC
fc36ed08f1 🐛(frontend) fix initial content with collaboration
The way the initial content was created was causing
issues with the collaboration server.
As soon a user started typing, the problem was gone.
This commit fixes that by letting Blocknote
managing the initial content, then we update the
Blocknote initial content with our initial content.
2024-12-11 16:08:13 +01:00
Anthony LC
ed90769081 🐛(backend) fix sanitize problem IA
Albert send us back a malformed IA json, the
sanitize function was not able to handle it correctly.
We add a try catch on it, to not use the sanitizer if
the json.loads fails.
2024-12-11 15:21:56 +01:00
Anthony LC
a8310fa0ff ️(frontend) remove debounce on useHeadings
We remove the debounce on useHeadings, it
decreases the user experience and it's not
necessary a big performance improvement.
2024-12-11 14:54:41 +01:00
Anthony LC
a902e31521 🔧(helm) add ingress collaboration api
We need to keep the stickyness between the
collaboration api and the ws server, to do so,
we will use "upstream-hash-by: $arg_room", meaning
that the stickyness will be based on the room query.
We need to ahve 2 ingress to handle the
"collaboration_auth", only the ws routes has to
use the "collaboration_auth" subrequest.
2024-12-11 14:54:41 +01:00
Anthony LC
932ab13d97 📈(collaboration) add sentry
Add sentry to the collaboration server.
It will be used to log errors and exceptions.
2024-12-11 14:54:41 +01:00
Anthony LC
94a1ba7989 (backend) notify collaboration server
When an access is updated or removed, the
collaboration server is notified to reset the
access connection; by being disconnected, the
accesses will automatically reconnect by passing
by the ngnix subrequest, and so get the good
rights.
We do the same system when the document link is
updated, except here we reset every access
connection.
2024-12-11 14:54:41 +01:00
Anthony LC
bfecdbf83a (y-provider) add tests for y-provider server
We add jest tests for the y-provider server.
The CI will be able to run the tests.
2024-12-11 14:54:41 +01:00
Anthony LC
ba1cfc3c27 (y-provider) endpoint POST /collaboration/api/reset-connections
We want to be able to reset the connections of a document.
To do this, we need to be able to send a
request to the collaboration server.
To do so, we added the endpoint
POST "/collaboration/api/reset-connections"
to the collaboration server thanks to "express".
2024-12-11 14:54:41 +01:00
Samuel Paccoud - DINUM
2cba228a67 🧑‍💻(helm) rename minio root user password
Using "impress" as the name of minio's root user in Tilt's
dev environment, was triggering obfuscation of the logs in Tilt's
console each time the word "impress" was used.
This made the logs hard to read.
2024-12-11 14:54:41 +01:00
Samuel Paccoud - DINUM
66553ee236 (backend) add subrequest auth view for collaboration server
We need to improve security on the access to The collaboration server
We can use the same pattern as for media files leveraging the nginx
subrequest feature.
2024-12-11 14:54:41 +01:00
Samuel Paccoud - DINUM
64674b6a73 ♻️(backend) rename, factorize and improve the subrequest media auth view
We want to use the same pattern for the websocket collaboration service
authorization as what we use for media files.

This addition comes in the next commit but doing it efficiently
required factorizing some code with the media auth view.
2024-12-11 14:54:41 +01:00
Anthony LC
a9def8cb18 ♻️(frontend) create useHeadings hook
- We create the useHeadings hook to manage the
headings of the document and staty DRY.
- We use the headings store in IconOpenPanelEditor
and TableContent, to avoid prop drilling.
- We add a debounce on the onEditorContentChange
to improve a bit the performance.
2024-12-06 15:23:16 +01:00
Anthony LC
69186e9a26 🩺(CI) wait for services to be ready
We add a check to be sure all the services are
ready before starting the e2e tests.
2024-12-06 15:23:16 +01:00
Anthony LC
f606826098 ♻️(frontend) stop to use provider with version
Version are not editable, we don't need to activate
the collaboration provider for them.
Simplify the code by removing the provider
from the version.
2024-12-06 15:23:16 +01:00
Anthony LC
aff036d9fb 🚚(collaboration) change the websocket url name
We will have 2 urls targeting the server, better
to improve the naming to avoid confusion.
2024-12-06 15:23:16 +01:00
Anthony LC
57ed08994b 🔊(changelog) add missing logs
Some logs were missing or not at the good place.
This commit replaces them correctly.
2024-12-06 15:23:16 +01:00
rvveber
131eefa1ac 🔨(frontend) encapsulate title component
in order to modularize in the future
the title component is encapsulated.
2024-12-06 14:16:24 +01:00
Anthony LC
b4e639cc24 ♻️(frontend) adapt Blocknote button
Last upgrade of Blocknote changes the editor
method getSelection, the blocks were not being
selected in certain cases.
We updated the methods to select the blocks
correctly.
2024-12-05 23:34:06 +01:00
Samuel Paccoud - DINUM
ba962af914 ⬆️(backend) bump openai library version as it breaks tests
This looks like an instability in the openai library's definition
of dependencies.
2024-12-05 23:34:06 +01:00
Anthony LC
76514a6e2b 🏷️(frontend) adapt typing with recent upgrade
An upgrade to @sentry/nextjs@8.42.0 changed
some typing. It is not from @sentry/types but
from @sentry/core now.
2024-12-05 23:34:06 +01:00
Anthony LC
b69a5342d9 ⬇️(dependency) downgrade workbox-webpack-plugin to 7.1.0
In the 1.8.0 we experienced issues with the service
worker not updating properly. We suspect that the
workbox-webpack-plugin is the cause of this issue.
Better to downgrade to the last version that worked
until we have time to investigate the issue.
We add workbox-webpack-plugin to the renovate.json
file to avoid future updates.
2024-12-05 23:34:06 +01:00
renovate[bot]
c25682f199 ⬆️(dependencies) update js dependencies 2024-12-05 23:34:06 +01:00
Anthony LC
eec8b4d2c3 ♻️(frontend) adapt frontend with new access types
We don't get the accesses anymore from the backeend,
instead we get the number of accesses.
We remove the list of owners in the doc header because
we don't have easily this informations anymore and
we will have to do a bigger refacto.
2024-11-28 16:02:27 +01:00
Samuel Paccoud - DINUM
1af7b797bc (backend) test interference btw documents permissions and filtering
We want to make sure that applying filters on the document view list
does not interfere with permissions.
2024-11-28 16:02:27 +01:00
Samuel Paccoud - DINUM
b5c159bf63 (backend) allow filtering on document titles
This is the minimal and fast search feature, while we are working on
a full text search based on opensearch. For the moment we only search
on the title of the document.
2024-11-28 16:02:27 +01:00
Samuel Paccoud - DINUM
bfbdfb2b5c (backend) allow filtering documents by link reach
We want to be able to limit document list views to only public documents,
or only restricted or authenticated documents.
2024-11-28 16:02:27 +01:00
Samuel Paccoud - DINUM
08bb64ddc1 (backend) allow filtering by documents marked as favorite
We recently allowed authenticated users to mark a document as favorite.
We were lacking the possibility for users to see only the documents
they marked as favorite.
2024-11-28 16:02:27 +01:00
Samuel Paccoud - DINUM
23f90156bf (backend) add creator field on document and allow filtering on it
We want to be able to limit the documents displayed on a logged-in user's
list view by the documents they created or by the documents that other
users created.

This is different from having the "owner" role on a document because this
can be acquired and even lost. What we want here is to be able to
identify documents by the user who created them so we add a new field.
2024-11-28 16:02:27 +01:00
Samuel Paccoud - DINUM
1899cff572 🐛(backend) fix flaky test by clarifying user ordering
On the user search API by similarity, we had a flaky test because
2 users had the same similarity score. Adding a secondary ordering
field makes ordering deterministic between users who share the same
similarity score.
2024-11-28 16:02:27 +01:00
Samuel Paccoud - DINUM
774c2ce248 (backend) annotate number of accesses on documents
The new UI will display the number of accesses on each document.

/!\ Once team accesses will be used, this will not represent the number
    of people with access anymore and will have to be improved by
    computing the number of people in each team.
2024-11-28 16:02:27 +01:00
Samuel Paccoud - DINUM
89d9075850 (backend) allow users to mark/unmark documents as favorite
A user can now mark/unmark documents as favorite.
This is done via a new action of the document API endpoint:
/api/v1.0/documents/{document_id}/favorite
POST to mark as favorite / DELETE to unmark
2024-11-28 16:02:27 +01:00
Samuel Paccoud - DINUM
2c915d53f4 ️(backend) optimize number of queries on document list view
I realized most of the database queries made when getting a document
list view were to include nested accesses. This detailed information
about accesses in only necessary for the document detail view.

I introduced a specific serializer for the document list view with
less fields. For a list of 20 documents with 5 accesses, we go down
from 3x5x20= 300 queries to just 3 queries.
2024-11-28 16:02:27 +01:00
Anthony LC
797d9442ac 🔖(patch) release 1.8.2
Changed:

- ♻️(SW) change strategy html caching
2024-11-28 15:31:03 +01:00
Anthony LC
573d054748 ♻️(SW) change strategy html caching
We will use the network first strategy for the html
files. This will allow us to always have the
latest version of the html files.
2024-11-28 09:30:06 +01:00
Anthony LC
2035a256f5 🔖(patch) release 1.8.1
Fixed:
🐛(frontend) link not clickable and flickering firefox
2024-11-27 17:17:35 +01:00
Anthony LC
c94f26c8b9 ⬇️(SW) workbox-webpack-plugin to 7.1.0
A recent update to the workbox-webpack-plugin
package seems to introduce strange behavior.
Better to downgrade in waiting that it is more stable.
2024-11-27 16:50:11 +01:00
Anthony LC
fc2f14b3f4 🐛(frontend) link not clickable and flickering firefox
The link in the read mode was not clickable anymore,
it was due to a attempt to not display the cursor
of anonymous users.
We changes the way to do it by rendering our own cursor,
when a user is anonymous we don't render the cursor.
By rendering our own cursor we fixed another problem,
the cursor was flickering when the user was typing
at the end of the line on the firefox browser.
2024-11-27 16:50:11 +01:00
Anthony LC
6dd1697915 🐛(frontend) use hook useTranslation
Sentry highlitghted a few errors about the
function "t" not being defined. Better to get
it from the hook useTranslation.
2024-11-27 16:50:11 +01:00
Anthony LC
79e899c301 ♻️(frontend) add hooks useUploadFile
Move upload file logic to hooks useUploadFile.
It will be more readable and easy to reuse.
2024-11-27 16:50:11 +01:00
Anthony LC
2194301716 🔖(minor) release 1.8.0
Added:
- 🌐(backend) add german translation
- 🌐(frontend) Add German translation
- (frontend) Add a broadcast store
- (backend) whitelist pod's IP address
- (backend) config endpoint
- (frontend) config endpoint
- (frontend) add sentry
- (frontend) add crisp chatbot

Changed:
- 🚸(backend) improve users similarity search and
sort results
- ♻️(frontend) simplify stores
- (frontend) update $css Box props type to add
styled components RuleSet
- (CI) trivy continue on error

Fixed:
- 🔧(backend) fix logging for docker and make it
configurable by envar
- 🦺(backend) add comma to sub regex
- 🐛(editor) collaborative user tag hidden when
read only
- 🐛(frontend) users have view access when revoked
- 🐛(frontend) fix placeholder editable when double clicks
2024-11-27 09:47:42 +01:00
Anthony LC
0348894ab8 🐛(frontend) fix rerender title with broadcasting
The title was not rerendering on other clients
when the title was updated by one client.
This commit fixes the issue.
We set a min width for the title as well, it
will fix the issue with strange behavior when
people were double clicking.
2024-11-26 18:15:18 +01:00
Anthony LC
9b17d8bea1 🚨(frontend) remove Crisp warning
Remove the Crisp warning that was being displayed
on the console in our environments.
2024-11-26 18:15:18 +01:00
Anthony LC
69d6b6f934 (CI) trivy continue on error
Trivy is extremly flaky,
we need to continue on error to avoid
blocking the pipeline.
We still keep the check, to see if there are any
vulnerabilities, but we don't want to block
the pipeline.
2024-11-26 11:53:11 +01:00
Anthony LC
6c106374fa (frontend) add crisp chatbot
Integrate Crisp chatbot for immediate user support access.

This enables real-time interaction, enhancing user experience
by providing quick assistance.
2024-11-25 17:06:02 +01:00
Anthony LC
af039d045d 🔧(backend) add CRISP_WEBSITE_ID setting
Add setting CRISP_WEBSITE_ID. This setting is
used to configure the Crisp chat widget.
It will be available to the conf endpoint, to
be used by the frontend.
2024-11-25 17:06:02 +01:00
Anthony LC
4c9caf09ba ⬆️(CI) upgrade upload-artifact@v3 to v4
Upload artifact v3 is deprecated soon, so we need
to upgrade it to v4.
2024-11-25 13:16:06 +01:00
Anthony LC
3fd02adbec 💄(frontend) remove Blocknote fix
A recent upgrade of Blocknote to 0.19.2 fixed
a issue that we were solving. We removed our
fix as it is no longer needed.
2024-11-25 13:16:06 +01:00
Anthony LC
90dac3cd15 🏷️(frontend) update typescript types
We updated typescript to 5.7.2.
Some types were deprecated and we had to update them.
2024-11-25 13:16:06 +01:00
Anthony LC
d0307ee6d9 ⬆️(dependencies) update js dependencies 2024-11-25 13:16:06 +01:00
Anthony LC
09d02b7ced 🚚(frontend) move conf api urls to api folder
Previous refacto let only the api urls in the conf
file, so better to move it to the api folder.
2024-11-25 09:46:14 +01:00
Anthony LC
56a26d9663 🧪(CI) pass trivy security
The trivy security blocked the deploiement.
It says that we have a vulnerability because
we are using the cross-spawn@7.0.3 package, but
we are not, we are using the cross-spawn@7.0.6 package.
We will bypass this security check in the docker-hub.yml
file in waiting for another solution.
2024-11-25 09:46:14 +01:00
Anthony LC
42f809f6d4 ♻️(frontend) get collaboration server url from config endpoint
We centralized the configuration on the backend
side, it is easier to manage and we can change
the configuration without having to rebuild the
frontend.
We now use the config endpoint to get the collaboration
server url, we refacto to remove the frontend env
occurences and to adapt with the new way to get the
collaboration server url.
2024-11-25 09:46:14 +01:00
Anthony LC
7d64c82987 ♻️(frontend) get media url from config endpoint
We centralized the configuration on the backend
side, it is easier to manage and we can change
the configuration without having to rebuild the
frontend.
We now use the config endpoint to get the media url,
we refacto to remove the frontend env occurences
and to adapt with the new way to get the media url.
2024-11-25 09:46:14 +01:00
Anthony LC
6252227bb6 ♻️(frontend) get theme from config endpoint
We centralized the configuration on the backend
side, it is easier to manage and we can change
the configuration without having to rebuild the
frontend.
We now use the config endpoint to get the theme,
we refacto to remove the frontend env occurences
and to adapt with the new way to get the theme.
2024-11-25 09:46:14 +01:00
Anthony LC
e9ac393a8f (frontend) add sentry
In order to monitor the frontend, we are adding
sentry.
2024-11-25 09:46:14 +01:00
Anthony LC
5b1745f991 (frontend) add config provider
Add a ConfigProvider to the frontend to provide
configuration to the app.
The configuration is loaded from the config
endpoint, we will use react-query cache capabilities
to store the configuration.
2024-11-25 09:46:14 +01:00
Anthony LC
0e55bf5c43 🔒️(helm) allow server host and whitelist pod IP for health checks
In a Kubernetes environment, we need to whitelist the pod's IP address
to allow health checks to pass. This ensures that Kubernetes liveness and
readiness probes can access the application to verify its health.
2024-11-22 13:01:55 +01:00
Samuel Paccoud - DINUM
9f66f73501 🔧(backend) fix logging for docker and make it configurable by envar
Logs were not made to the console so it was hard to debug in k8s.
We propose a ready made logging configuration that sends everything
to the console and allow adjusting log levels with environment
variables.
2024-11-20 11:51:20 +01:00
Samuel Paccoud - DINUM
c3da28b07f ️(helm) bring back helm chart
This is a revert of 1da5a removing actual deployments and keeping
only the dev environment in Tilt.

The clean-up was a bit heavy handed. We should keep the Helm
chart to the development repository and move away only the
deployment configuration.
2024-11-20 11:51:20 +01:00
Anthony LC
b035b96dec ⬆️(CI) bump python version in backend test
We were testing the backend with python 3.10.0, but
actually the backend was running with python 3.12.6.
We bump the python version in the backend test to match
the running version of the backend.
2024-11-20 09:51:08 +01:00
Anthony LC
9623ac4141 🩹(backend) get current release from pyproject.toml
"get_release" was returning NA, we fixed it by
getting the version from pyproject.toml, to do so we
use tomllib
Since tomllib is a native library from Python 3.11,
we bump the required version to 3.11 on the pyproject.toml.
2024-11-20 09:51:08 +01:00
Anthony LC
c8edbd285b 🔧(backend) add FRONTEND_THEME setting
The frontend need to know the theme to be used,
so we need to add a new setting to the backend,
in order to expose this value to the frontend.
2024-11-20 09:51:08 +01:00
Anthony LC
016597d5a2 🔧(backend) add COLLABORATION_SERVER_URL setting
The frontend need to know the collab server url,
so we need to add a new setting to the backend,
in order to expose this value to the frontend.
If the setting is not defined, the frontend current
domain will be used as the base url.
In production this setting do not need to be defined
since we have nginx capturing the ws requests,
but in development we need to define it to target
the collaboration server.
2024-11-20 09:51:08 +01:00
Anthony LC
52dea8fa2f 🔧(backend) add MEDIA_BASE_URL setting
The frontend need to know the base url for the
media files, so we need to add a new setting
to the backend, in order to expose this value
to the frontend.
If the setting is not defined, the frontend current
domain will be used as the base url.
In production this setting do not need to be defined
since we have nginx capturing the media requests,
but in development we need to define it to target
the nginx server.
2024-11-20 09:51:08 +01:00
Anthony LC
0a37a8ea6d (backend) add public endpoint /api/v1.0/config/
Add public endpoint /api/v1.0/config/ to
share some public configuration values.
2024-11-20 09:51:08 +01:00
Anthony LC
c1404ef904 ⬆️(dependencies) bump cross-spawn from 7.0.3 to 7.0.6
Bumps cross-spawn from 7.0.3 to 7.0.6.

---
updated-dependencies:
- dependency-name: cross-spawn
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-11-20 09:24:34 +01:00
renovate[bot]
2c0fce61df ⬆️(dependencies) update js dependencies 2024-11-18 17:25:16 +01:00
Nathan Panchout
bbe9b6b6cf (frontend) add styledCss props to Box component
In order to facilitate DX and not to use a string in the code for the css.
We add the $styledCss props to the Box component.
This object comes from Styled component
2024-11-15 10:33:56 +01:00
Anthony LC
23231563c9 💄(frontend) text color on Blocknote code block options
The options for the code block in the Blocknote
editor was not visible. We changed the text color
to make it visible.
A fix will be made to the code block options in the
next blocknote release.
2024-11-14 17:36:11 +01:00
Anthony LC
d75c8668c5 🚨(frontend) blocknote cast to Dictionnary
The last Blocknote upgrade (0.19.0) gives us
a warning with the dictionnary typing.
We cast it to the correct type to remove the warning.
2024-11-14 17:36:11 +01:00
Anthony LC
f266232b5a ♻️(frontend) use next/router instead of next/navigation
The last upgrade of next.js gives a warning
when we were using next/navigation with the
pages router.
This commit fixes this issue.
2024-11-14 17:36:11 +01:00
Anthony LC
a8362e8e88 ⬆️(dependencies) update js dependencies 2024-11-14 17:36:11 +01:00
Anthony LC
e4dfae1905 ♻️(frontend) simplify useDocStore
We moved the editor store to its own store in the previous
commit. This change allow us to simplify useDocStore.
2024-11-13 15:25:29 +01:00
Anthony LC
a09e740648 ♻️(frontend) move editor store to useEditorStore
Previous changes migrated the editor store to
doc-management, we move it back doc-editor and
simplify it.
2024-11-13 15:25:29 +01:00
Anthony LC
5ee6a43f08 (frontend) add useBroadcastStore
Add the useBroadcastStore.
It will give us the ability to easily
broadcast actions to all connected clients.

In this case, we requery the doc to everyone
when a change relative to the doc rights is made.
2024-11-09 10:21:24 +01:00
Anthony LC
8bd83cbfcd 🚚(frontend) move useDocStore to doc-management
We want to make more accessible the doc store
to every feature, so we move it to the
doc-management folder.
2024-11-09 10:21:24 +01:00
Anthony LC
bc14d1d0f8 🐛(editor) collaborative user tag hidden when read only
When the user was in read-only mode, the user
tag could be displayed when they were touching the
doc. This commit fixes this issue.
We add the full name instead of the email in the
cursor tag.
2024-11-08 12:01:23 +01:00
Anthony LC
526e649f06 🦺(backend) add comma to sub regex
Some sub have comma, the regex was a bit too strict
and didn't allow it, this commit fixes that.
2024-11-08 10:53:53 +01:00
Anthony LC
ac40eb8f7c 🌐(frontend) add German translation
- Add the german translation to Docs
- Add the german language to the frontend
language picker
2024-11-07 15:58:49 +01:00
lindenb1
c750cf10a8 🌐(backend) adding de_DE translation for the backend
This adds German translation to the backend and
adjusts the .po file sequence by priority.

Signed-off-by: lindenb1 <linden@b1-systems.de>
2024-11-07 11:49:41 +01:00
Samuel Paccoud - DINUM
4f4951cdcd 🚸(backend) improve users similarity search and sort results
In some edge cases, the domain part the email addresse is
longer than the name part. Users searches by email similarity
then return a lot of unsorted results.

We can improve this by being more demanding on similarity when
the query looks like an email. Sorting results by the similarity
score is also an obvious improvement.

At the moment, we still think it is good to propose results with
a weak similarity on the name part because we want to avoid
as much as possible creating duplicate users by inviting one of
is many emails, a user who is already in our database.

Fixes 399
2024-11-06 08:27:18 +01:00
Anthony LC
50891afd05 🔖(minor) release 1.7.0
Added:
- 📝Contributing.md
- 🌐(frontend) add localization to editor
- Public and restricted doc editable
- (frontend) Add full name if available
- (backend) Add view accesses ability

Changed:
- ♻️(frontend) avoid documents indexing in search engine
- ♻️(frontend) list accesses if user has abilities
- 👔(backend) doc restricted by default

Fixed:
- 🐛(backend) require right to manage document
  accesses to see invitations
- 🐛(i18n) same frontend and backend language using
  shared cookies
- 🐛(frontend) add default toolbar buttons
- 🐛(frontend) throttle error correctly display

Removed:
- 🔥(helm) remove infra related codes
2024-10-25 14:41:48 +02:00
Anthony LC
cbb6fc740a 👔(backend) doc restricted by default
By default a created document was in "authenticated"
mode, we switch to "restricted" by default.
2024-10-25 14:25:48 +02:00
Anthony LC
31c3dd6119 🛂(frontend) show member list depend ability
We integrate the new ability "accesses_view" that
tells if a user can view the accesses of a document.
2024-10-24 17:31:34 +02:00
Samuel Paccoud - DINUM
15700ddd8d (backend) add new ability on document "accesses_view"
We need this ability in the frontend to know whether we should try
to display the list of users who have document accesses. If this
ability is False (e.g for anonymous users), we should only show
the link reach and link role when clicking on the "Share" button.
2024-10-24 17:31:34 +02:00
392 changed files with 26487 additions and 9874 deletions

76
.github/workflows/crowdin_download.yml vendored Normal file
View File

@@ -0,0 +1,76 @@
name: Download translations from Crowdin
on:
workflow_dispatch:
push:
branches:
- 'release/**'
jobs:
install-front:
uses: ./.github/workflows/front-dependencies-installation.yml
with:
node_version: '20.x'
synchronize-with-crowdin:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Create empty source files
run: |
touch src/backend/locale/django.pot
mkdir -p src/frontend/packages/i18n/locales/impress/
touch src/frontend/packages/i18n/locales/impress/translations-crowdin.json
# crowdin workflow
- name: crowdin action
uses: crowdin/github-action@v2
with:
config: crowdin/config.yml
upload_sources: false
upload_translations: false
download_translations: true
create_pull_request: false
push_translations: false
push_sources: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# A numeric ID, found at https://crowdin.com/project/<projectName>/tools/api
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
# Visit https://crowdin.com/settings#api-key to create this token
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
CROWDIN_BASE_PATH: "../src/"
# frontend i18n
- name: Restore the frontend cache
uses: actions/cache@v4
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
fail-on-cache-miss: true
- name: generate translations files
working-directory: src/frontend
run: yarn i18n:deploy
# Create a new PR
- name: Create a new Pull Request with new translated strings
uses: peter-evans/create-pull-request@v7
with:
commit-message: |
🌐(i18n) update translated strings
Update translated files with new translations
title: 🌐(i18n) update translated strings
body: |
## Purpose
update translated strings
## Proposal
- [x] update translated strings
branch: i18n/update-translations
labels: i18n

67
.github/workflows/crowdin_upload.yml vendored Normal file
View File

@@ -0,0 +1,67 @@
name: Update crowdin sources
on:
workflow_dispatch:
push:
branches:
- main
jobs:
install-front:
uses: ./.github/workflows/front-dependencies-installation.yml
with:
node_version: '20.x'
synchronize-with-crowdin:
needs: install-front
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
# Backend i18n
- name: Install Python
uses: actions/setup-python@v3
with:
python-version: "3.12.6"
- name: Upgrade pip and setuptools
run: pip install --upgrade pip setuptools
- name: Install development dependencies
run: pip install --user .
working-directory: src/backend
- name: Install gettext
run: |
sudo apt-get update
sudo apt-get install -y gettext pandoc
- name: generate pot files
working-directory: src/backend
run: |
DJANGO_CONFIGURATION=Build python manage.py makemessages -a --keep-pot
# frontend i18n
- name: Restore the frontend cache
uses: actions/cache@v4
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
fail-on-cache-miss: true
- name: generate source translation file
working-directory: src/frontend
run: yarn i18n:extract
# crowdin workflow
- name: crowdin action
uses: crowdin/github-action@v2
with:
config: crowdin/config.yml
upload_sources: true
upload_translations: false
download_translations: false
create_pull_request: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# A numeric ID, found at https://crowdin.com/project/<projectName>/tools/api
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
# Visit https://crowdin.com/settings#api-key to create this token
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
CROWDIN_BASE_PATH: "../src/"

View File

@@ -8,6 +8,9 @@ on:
- 'main'
tags:
- 'v*'
pull_request:
branches:
- 'main'
env:
DOCKER_USER: 1001:127
@@ -16,26 +19,9 @@ jobs:
build-and-push-backend:
runs-on: ubuntu-latest
steps:
-
uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: "impress,secrets"
-
name: Checkout repository
uses: actions/checkout@v2
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
-
name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: secrets/numerique-gouv/impress/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
uses: actions/checkout@v4
-
name: Docker meta
id: meta
@@ -45,13 +31,14 @@ jobs:
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '--target backend-production -f Dockerfile'
docker-image-name: 'docker.io/lasuite/impress-backend:${{ github.sha }}'
continue-on-error: true
-
name: Build and push
uses: docker/build-push-action@v6
@@ -66,26 +53,9 @@ jobs:
build-and-push-frontend:
runs-on: ubuntu-latest
steps:
-
uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: "impress,secrets"
-
name: Checkout repository
uses: actions/checkout@v2
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
-
name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: secrets/numerique-gouv/impress/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
uses: actions/checkout@v4
-
name: Docker meta
id: meta
@@ -95,13 +65,14 @@ jobs:
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
docker-image-name: 'docker.io/lasuite/impress-frontend:${{ github.sha }}'
continue-on-error: true
-
name: Build and push
uses: docker/build-push-action@v6
@@ -117,26 +88,9 @@ jobs:
build-and-push-y-provider:
runs-on: ubuntu-latest
steps:
-
uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: "impress,secrets"
-
name: Checkout repository
uses: actions/checkout@v2
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
-
name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: secrets/numerique-gouv/impress/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
uses: actions/checkout@v4
-
name: Docker meta
id: meta
@@ -146,19 +100,20 @@ jobs:
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '-f src/frontend/Dockerfile --target y-provider'
docker-build-args: '-f src/frontend/servers/y-provider/Dockerfile --target y-provider'
docker-image-name: 'docker.io/lasuite/impress-frontend:${{ github.sha }}'
continue-on-error: true
-
name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: ./src/frontend/Dockerfile
file: ./src/frontend/servers/y-provider/Dockerfile
target: y-provider
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' }}
@@ -173,29 +128,12 @@ jobs:
if: |
github.event_name != 'pull_request'
steps:
-
uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: "impress,secrets"
-
name: Checkout repository
uses: actions/checkout@v2
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
-
name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: secrets/numerique-gouv/impress/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
uses: actions/checkout@v4
-
name: Call argocd github webhook
run: |
data='{"ref": "'$GITHUB_REF'","repository": {"html_url":"'$GITHUB_SERVER_URL'/'$GITHUB_REPOSITORY'"}}'
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${ARGOCD_WEBHOOK_SECRET}'' | awk '{print "X-Hub-Signature: sha1="$2}')
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" $ARGOCD_WEBHOOK_URL
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${{ secrets.ARGOCD_PREPROD_WEBHOOK_SECRET}}'' | awk '{print "X-Hub-Signature: sha1="$2}')
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" ${{ vars.ARGOCD_PREPROD_WEBHOOK_URL }}

View File

@@ -0,0 +1,36 @@
name: Install frontend installation reusable workflow
on:
workflow_call:
inputs:
node_version:
required: false
default: '20.x'
type: string
jobs:
front-dependencies-installation:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Restore the frontend cache
uses: actions/cache@v4
id: front-node_modules
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
- name: Setup Node.js
if: steps.front-node_modules.outputs.cache-hit != 'true'
uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node_version }}
- name: Install dependencies
if: steps.front-node_modules.outputs.cache-hit != 'true'
run: cd src/frontend/ && yarn install --frozen-lockfile
- name: Cache install frontend
if: steps.front-node_modules.outputs.cache-hit != 'true'
uses: actions/cache@v4
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}

30
.github/workflows/helmfile-linter.yaml vendored Normal file
View File

@@ -0,0 +1,30 @@
name: Helmfile lint
run-name: Helmfile lint
on:
push:
pull_request:
branches:
- 'main'
jobs:
helmfile-lint:
runs-on: ubuntu-latest
container:
image: ghcr.io/helmfile/helmfile:latest
steps:
-
name: Checkout repository
uses: actions/checkout@v4
-
name: Helmfile lint
shell: bash
run: |
set -e
HELMFILE=src/helm/helmfile.yaml
environments=$(awk '/environments:/ {flag=1; next} flag && NF {print} !NF {flag=0}' "$HELMFILE" | grep -E '^[[:space:]]{2}[a-zA-Z]+' | sed 's/^[[:space:]]*//;s/:.*//')
for env in $environments; do
echo "################### $env lint ###################"
helmfile -e $env -f $HELMFILE lint || exit 1
echo -e "\n"
done

View File

@@ -9,9 +9,15 @@ on:
- "*"
jobs:
install-front:
runs-on: ubuntu-latest
install-front:
uses: ./.github/workflows/front-dependencies-installation.yml
with:
node_version: '20.x'
test-front:
needs: install-front
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -19,42 +25,17 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "18.x"
node-version: "20.x"
- name: Restore the frontend cache
uses: actions/cache@v4
id: front-node_modules
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
- name: Install dependencies
if: steps.front-node_modules.outputs.cache-hit != 'true'
run: cd src/frontend/ && yarn install --frozen-lockfile
- name: Cache install frontend
if: steps.front-node_modules.outputs.cache-hit != 'true'
uses: actions/cache@v4
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
test-front:
runs-on: ubuntu-latest
needs: install-front
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Restore the frontend cache
uses: actions/cache@v4
id: front-node_modules
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
fail-on-cache-miss: true
- name: Test App
run: cd src/frontend/ && yarn app:test
run: cd src/frontend/ && yarn test
lint-front:
runs-on: ubuntu-latest
@@ -63,43 +44,75 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20.x"
- name: Restore the frontend cache
uses: actions/cache@v4
id: front-node_modules
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
fail-on-cache-miss: true
- name: Check linting
run: cd src/frontend/ && yarn lint
test-e2e-chromium:
runs-on: ubuntu-latest
needs: install-front
timeout-minutes: 20
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20.x"
- name: Restore the frontend cache
uses: actions/cache@v4
id: front-node_modules
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
fail-on-cache-miss: true
- name: Set e2e env variables
run: cat env.d/development/common.e2e.dist >> env.d/development/common.dist
- name: Install Playwright Browsers
run: cd src/frontend/apps/e2e && yarn install --frozen-lockfile && yarn install-playwright chromium
- name: Start Docker services
run: make bootstrap FLUSH_ARGS='--no-input' cache=
- name: Install Playwright Browsers
run: cd src/frontend/apps/e2e && yarn install-playwright chromium
# Tool to wait for a service to be ready
- name: Install Dockerize
run: |
curl -sSL https://github.com/jwilder/dockerize/releases/download/v0.8.0/dockerize-linux-amd64-v0.8.0.tar.gz | sudo tar -C /usr/local/bin -xzv
- name: Wait for services to be ready
run: |
printf "Minio check...\n"
dockerize -wait tcp://localhost:9000 -timeout 20s
printf "Keyclock check...\n"
dockerize -wait tcp://localhost:8080 -timeout 20s
printf "Server collaboration check...\n"
dockerize -wait tcp://localhost:4444 -timeout 20s
printf "Ngnix check...\n"
dockerize -wait tcp://localhost:8083 -timeout 20s
printf "DRF check...\n"
dockerize -wait tcp://localhost:8071 -timeout 20s
printf "Postgres Keyclock check...\n"
dockerize -wait tcp://localhost:5433 -timeout 20s
printf "Postgres back check...\n"
dockerize -wait tcp://localhost:15432 -timeout 20s
- name: Run e2e tests
run: cd src/frontend/ && yarn e2e:test --project='chromium'
- uses: actions/upload-artifact@v3
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-chromium-report
@@ -114,26 +127,22 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
- name: Restore the frontend cache
uses: actions/cache@v4
id: front-node_modules
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
- name: Install frontend dependencies
uses: ./.github/workflows/front-dependencies-installation.yml
- name: Set e2e env variables
run: cat env.d/development/common.e2e.dist >> env.d/development/common.dist
- name: Install Playwright Browsers
run: cd src/frontend/apps/e2e && yarn install --frozen-lockfile && yarn install-playwright firefox webkit chromium
- name: Start Docker services
run: make bootstrap FLUSH_ARGS='--no-input' cache=
- name: Install Playwright Browsers
run: cd src/frontend/apps/e2e && yarn install-playwright firefox webkit chromium
- name: Run e2e tests
run: cd src/frontend/ && yarn e2e:test --project=firefox --project=webkit
- uses: actions/upload-artifact@v3
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-other-report

View File

@@ -107,7 +107,9 @@ jobs:
- name: Install Python
uses: actions/setup-python@v3
with:
python-version: "3.10"
python-version: "3.12.6"
- name: Upgrade pip and setuptools
run: pip install --upgrade pip setuptools
- name: Install development dependencies
run: pip install --user .[dev]
- name: Check code formatting with ruff
@@ -199,15 +201,16 @@ jobs:
- name: Install Python
uses: actions/setup-python@v3
with:
python-version: "3.10"
python-version: "3.12.6"
- 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

View File

@@ -0,0 +1,34 @@
name: Release Chart
run-name: Release Chart
on:
push:
paths:
- src/helm/impress/**
jobs:
release:
# depending on default permission settings for your org (contents being read-only or read-write for workloads), you will have to add permissions
# see: https://docs.github.com/en/actions/security-guides/automatic-token-authentication#modifying-the-permissions-for-the-github_token
permissions:
contents: write
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Cleanup
run: rm -rf ./src/helm/extra
- name: Install Helm
uses: azure/setup-helm@v4
env:
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
- name: Publish Helm charts
uses: numerique-gouv/helm-gh-pages@add-overwrite-option
with:
charts_dir: ./src/helm
token: ${{ secrets.GITHUB_TOKEN }}

2
.gitignore vendored
View File

@@ -30,6 +30,7 @@ MANIFEST
.next/
# Translations # Translations
*.mo
*.pot
# Environments
@@ -40,6 +41,7 @@ ENV/
env.bak/
venv.bak/
env.d/development/*
env.d/production/*
!env.d/development/*.dist
env.d/terraform

3
.gitmodules vendored
View File

@@ -1,3 +0,0 @@
[submodule "secrets"]
path = secrets
url = ../secrets

View File

@@ -9,6 +9,140 @@ and this project adheres to
## [Unreleased]
## Added
- github actions to managed Crowdin workflow
- 📈Integrate Posthog #540
- 🏷️(backend) add content-type to uploaded files #552
## Changed
- 💄(frontend) add abilities on doc row #581
## [2.0.1] - 2025-01-17
## Fixed
-🐛(frontend) share modal is shown when you don't have the abilities #557
-🐛(frontend) title copy break app #564
## [2.0.0] - 2025-01-13
## Added
- 🔧(backend) add option to configure list of essential OIDC claims #525 & #531
- 🔧(helm) add option to disable default tls setting by @dominikkaminski #519
- 💄(frontend) Add left panel #420
- 💄(frontend) add filtering to left panel #475
- ✨(frontend) new share modal ui #489
- ✨(frontend) add favorite feature #515
## Changed
- 🏗️(yjs-server) organize yjs server #528
- ♻️(frontend) better separation collaboration process #528
- 💄(frontend) updating the header and leftpanel for responsive #421
- 💄(frontend) update DocsGrid component #431
- 💄(frontend) update DocsGridOptions component #432
- 💄(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
## Fixed
- 🐛(backend) fix create document via s2s if sub unknown but email found #543
- 🐛(frontend) hide search and create doc button if not authenticated #555
- 🐛(backend) race condition creation issue #556
## [1.10.0] - 2024-12-17
## Added
- ✨(backend) add server-to-server API endpoint to create documents #467
- ✨(email) white brand email #412
- ✨(y-provider) create a markdown converter endpoint #488
## Changed
- ⚡️(docker) improve y-provider image #422
## Fixed
- ⚡️(e2e) reduce flakiness on e2e tests #511
## Fixed
- 🐛(frontend) update doc editor height #481
- 💄(frontend) add doc search #485
## [1.9.0] - 2024-12-11
## Added
- ✨(backend) annotate number of accesses on documents in list view #429
- ✨(backend) allow users to mark/unmark documents as favorite #429
## Changed
- 🔒️(collaboration) increase collaboration access security #472
- 🔨(frontend) encapsulated title to its own component #474
- ⚡️(backend) optimize number of queries on document list view #429
- ♻️(frontend) stop to use provider with version #480
- 🚚(collaboration) change the websocket key name #480
## Fixed
- 🐛(frontend) fix initial content with collaboration #484
- 🐛(frontend) Fix hidden menu on Firefox #468
- 🐛(backend) fix sanitize problem IA #490
## [1.8.2] - 2024-11-28
## Changed
- ♻️(SW) change strategy html caching #460
## [1.8.1] - 2024-11-27
## Fixed
- 🐛(frontend) link not clickable and flickering firefox #457
## [1.8.0] - 2024-11-25
## Added
- 🌐(backend) add German translation #259
- 🌐(frontend) add German translation #255
- ✨(frontend) add a broadcast store #387
- ✨(backend) whitelist pod's IP address #443
- ✨(backend) config endpoint #425
- ✨(frontend) config endpoint #424
- ✨(frontend) add sentry #424
- ✨(frontend) add crisp chatbot #450
## Changed
- 🚸(backend) improve users similarity search and sort results #391
- ♻️(frontend) simplify stores #402
- ✨(frontend) update $css Box props type to add styled components RuleSet #423
- ✅(CI) trivy continue on error #453
## Fixed
- 🔧(backend) fix logging for docker and make it configurable by envar #427
- 🦺(backend) add comma to sub regex #408
- 🐛(editor) collaborative user tag hidden when read only #385
- 🐛(frontend) users have view access when revoked #387
- 🐛(frontend) fix placeholder editable when double clicks #454
## [1.7.0] - 2024-10-24
## Added
@@ -17,10 +151,13 @@ and this project adheres to
- 🌐(frontend) add localization to editor #368
- ✨Public and restricted doc editable #357
- ✨(frontend) Add full name if available #380
- ✨(backend) Add view accesses ability #376
## Changed
- ♻️(frontend) list accesses if user has abilities #376
- ♻️(frontend) avoid documents indexing in search engine #372
- 👔(backend) doc restricted by default #388
## Fixed
@@ -235,7 +372,14 @@ and this project adheres to
- 🚀 Impress, project to manage your documents easily and collaboratively.
[unreleased]: https://github.com/numerique-gouv/impress/compare/v1.7.0...main
[unreleased]: https://github.com/numerique-gouv/impress/compare/v2.0.1...main
[v2.0.1]: https://github.com/numerique-gouv/impress/releases/v2.0.1
[v2.0.0]: https://github.com/numerique-gouv/impress/releases/v2.0.0
[v1.10.0]: https://github.com/numerique-gouv/impress/releases/v1.10.0
[v1.9.0]: https://github.com/numerique-gouv/impress/releases/v1.9.0
[v1.8.2]: https://github.com/numerique-gouv/impress/releases/v1.8.2
[v1.8.1]: https://github.com/numerique-gouv/impress/releases/v1.8.1
[v1.8.0]: https://github.com/numerique-gouv/impress/releases/v1.8.0
[v1.7.0]: https://github.com/numerique-gouv/impress/releases/v1.7.0
[v1.6.0]: https://github.com/numerique-gouv/impress/releases/v1.6.0
[1.5.1]: https://github.com/numerique-gouv/impress/releases/v1.5.1

View File

@@ -51,7 +51,7 @@ COPY ./src/backend /app/
WORKDIR /app
# collectstatic
RUN DJANGO_CONFIGURATION=Build DJANGO_JWT_PRIVATE_SIGNING_KEY=Dummy \
RUN DJANGO_CONFIGURATION=Build \
python manage.py collectstatic --noinput
# Replace duplicated file by a symlink to decrease the overall size of the
@@ -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
@@ -92,6 +94,11 @@ COPY ./src/backend /app/
WORKDIR /app
# Generate compiled translation messages
RUN DJANGO_CONFIGURATION=Build \
python manage.py compilemessages
# We wrap commands run in this container by the following entrypoint that
# creates a user on-the-fly with the container user ID (see USER) and root group
# ID.

View File

@@ -38,13 +38,13 @@ DB_PORT = 5432
DOCKER_UID = $(shell id -u)
DOCKER_GID = $(shell id -g)
DOCKER_USER = $(DOCKER_UID):$(DOCKER_GID)
COMPOSE = DOCKER_USER=$(DOCKER_USER) docker compose
COMPOSE = DOCKER_USER=$(DOCKER_USER) ./bin/compose
COMPOSE_PRODUCTION = DOCKER_USER=$(DOCKER_USER) COMPOSE_FILE=compose.production.yaml ./bin/compose
COMPOSE_EXEC = $(COMPOSE) exec
COMPOSE_EXEC_APP = $(COMPOSE_EXEC) app-dev
COMPOSE_RUN = $(COMPOSE) run --rm
COMPOSE_RUN_APP = $(COMPOSE_RUN) app-dev
COMPOSE_RUN_CROWDIN = $(COMPOSE_RUN) crowdin crowdin
WAIT_DB = @$(COMPOSE_RUN) dockerize -wait tcp://$(DB_HOST):$(DB_PORT) -timeout 60s
# -- Backend
MANAGE = $(COMPOSE_RUN_APP) python manage.py
@@ -65,6 +65,19 @@ data/media:
data/static:
@mkdir -p data/static
# -- production volumes
data/production/media:
@mkdir -p data/production/media
data/production/certs:
@mkdir -p data/production/certs
data/production/databases/backend:
@mkdir -p data/production/databases/backend
data/production/databases/keycloak:
@mkdir -p data/production/databases/keycloak
# -- Project
create-env-files: ## Copy the dist env files to env files
@@ -89,6 +102,27 @@ bootstrap: \
mails-build
.PHONY: bootstrap
bootstrap-production: ## Prepare project to run in production mode using docker compose
bootstrap-production: \
env.d/production \
data/production/media \
data/production/certs \
data/production/databases/backend \
data/production/databases/keycloak
bootstrap-production:
@echo 'Environment files created in env.d/production'
@echo 'Edit them to set good value for your production environment'
.PHONY: bootstrap-production
run-production: ## Run compose project in production mode
@$(COMPOSE_PRODUCTION) up -d ingress
.PHONY: run-production
stop-production: ## Stop compose project in production mode
@$(COMPOSE_PRODUCTION) stop
.PHONY: stop-production
# -- Docker/compose
build: cache ?= --no-cache
build: ## build the project containers
@@ -122,10 +156,8 @@ logs: ## display app-dev logs (follow mode)
run: ## start the wsgi (production) and development server
@$(COMPOSE) up --force-recreate -d celery-dev
@$(COMPOSE) up --force-recreate -d nginx
@$(COMPOSE) up --force-recreate -d y-provider
@echo "Wait for postgresql to be up..."
@$(WAIT_DB)
@$(COMPOSE) up --force-recreate -d nginx
.PHONY: run
run-with-frontend: ## Start all the containers needed (backend to frontend)
@@ -188,14 +220,12 @@ test-back-parallel: ## run all back-end tests in parallel
makemigrations: ## run django makemigrations for the impress project.
@echo "$(BOLD)Running makemigrations$(RESET)"
@$(COMPOSE) up -d postgresql
@$(WAIT_DB)
@$(MANAGE) makemigrations
.PHONY: makemigrations
migrate: ## run django migrations for the impress project.
@echo "$(BOLD)Running migrations$(RESET)"
@$(COMPOSE) up -d postgresql
@$(WAIT_DB)
@$(MANAGE) migrate
.PHONY: migrate
@@ -229,6 +259,8 @@ resetdb: ## flush database and create a superuser "admin"
@${MAKE} superuser
.PHONY: resetdb
# -- Environment variable files
env.d/development/common:
cp -n env.d/development/common.dist env.d/development/common
@@ -238,6 +270,9 @@ env.d/development/postgresql:
env.d/development/kc_postgresql:
cp -n env.d/development/kc_postgresql.dist env.d/development/kc_postgresql
env.d/production:
cp -rnf env.d/production.dist env.d/production
# -- Internationalization
env.d/development/crowdin:

160
README.md
View File

@@ -1,113 +1,171 @@
# Impress
<p align="center">
<a href="https://github.com/suitenumerique/docs">
<img alt="Docs" src="/docs/assets/logo-docs.png" width="300" />
</a>
</p>
Impress is a web application for real-time collaborative text editing with user and role based access rights.
Features include :
- User authentication through OIDC
- BlocNote.js text editing experience (markdown support, dynamic conversion, block structure, slash commands for block creation)
- Document export to pdf and docx from predefined templates
- Granular document permissions
- Public link sharing
- Offline mode
<p align="center">
Welcome to Docs! The open source document editor where your notes can become knowledge through live collaboration
</p>
Impress is built on top of [Django Rest Framework](https://www.django-rest-framework.org/), [Next.js](https://nextjs.org/) and [BlocNote.js](https://www.blocknotejs.org/)
<p align="center">
<a href="https://matrix.to/#/#docs-official:matrix.org">
Chat on Matrix
</a> - <a href="/docs/">
Documentation
</a> - <a href="#getting-started">
Getting started
</a>
</p>
## Getting started
<img src="/docs/assets/docs_live_collaboration_light.gif" width="100%" align="center"/>
### Prerequisite
## Why use Docs ❓
Docs is a collaborative text editor designed to address common challenges in knowledge building and sharing.
Make sure you have a recent version of Docker and [Docker
Compose](https://docs.docker.com/compose/install) installed on your laptop:
### Write
* 😌 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 😅).
* ✨ Save time thanks to our AI actions (generate, sum up, correct, translate)
```bash
### Collaborate
* 🤝 Collaborate in realtime with your team mates
* 🔒 Granular access control to keep your information secure and shared with the right people
* 📑 Professional document exports in multiple formats (.odt, .doc, .pdf) with customizable templates
* 📚 Built-in wiki functionality to transform your team's collaborative work into organized knowledge `ETA 02/2025`
### Self-host
* 🚀 Easy to install, scalable and secure alternative to Notion, Outline or Confluence
## Getting started 🔧
### Test it
Test Docs on your browser by logging in on this [environment](https://impress-preprod.beta.numerique.gouv.fr/docs/0aa856e9-da41-4d59-b73d-a61cb2c1245f/)
```
email: test.docs@yopmail.com
password: I'd<3ToTestDocs
```
### Run it locally
**Prerequisite**
Make sure you have a recent version of Docker and [Docker Compose](https://docs.docker.com/compose/install) installed on your laptop:
```shellscript
$ docker -v
Docker version 20.10.2, build 2291f61
Docker version 27.4.1, build b9d17ea
$ docker compose -v
docker compose version 1.27.4, build 40524192
$ docker compose version
Docker Compose version v2.32.1
```
> ⚠️ You may need to run the following commands with `sudo` but this can be
> avoided by assigning your user to the `docker` group.
### Project bootstrap
> ⚠️ You may need to run the following commands with sudo but this can be avoided by assigning your user to the `docker` group.
**Project bootstrap**
The easiest way to start working on the project is to use GNU Make:
```bash
```shellscript
$ make bootstrap FLUSH_ARGS='--no-input'
```
This command builds the `app` container, installs dependencies, performs
database migrations and compile translations. It's a good idea to use this
command each time you are pulling code from the project repository to avoid
dependency-releated or migration-releated issues.
This command builds the `app` container, installs dependencies, performs database migrations and compile translations. It's a good idea to use this
command each time you are pulling code from the project repository to avoid dependency-releated or migration-releated issues.
Your Docker services should now be up and running 🎉
You can access to the project by going to http://localhost:3000.
You can access to the project by going to <http://localhost:3000>.
You will be prompted to log in, the default credentials are:
```bash
```shellscript
username: impress
password: impress
```
📝 Note that if you need to run them afterwards, you can use the eponym Make rule:
```bash
```shellscript
$ make run-with-frontend
```
---
⚠️ For the frontend developper, it is often better to run the frontend in development mode locally.
⚠️ For the frontend developper, it is often better to run the frontend in development mode locally.
To do so, install the frontend dependencies with the following command:
```bash
```shellscript
$ make frontend-install
```
And run the frontend locally in development mode with the following command:
```bash
```shellscript
$ make run-frontend-development
```
To start all the services, except the frontend container, you can use the following command:
```bash
```shellscript
$ make run
```
---
### Adding content
**Adding content**
You can create a basic demo site by running:
$ make demo
```shellscript
$ make demo
```
Finally, you can check all available Make rules using:
```bash
```shellscript
$ make help
```
### Django admin
**Django admin**
You can access the Django admin site at
[http://localhost:8071/admin](http://localhost:8071/admin).
<http://localhost:8071/admin>.
You first need to create a superuser account:
```bash
```shellscript
$ make superuser
```
## Contributing
## Feedback 🙋‍♂️🙋‍♀️
We'd love to hear your thoughts and hear about your experiments, so come and say hi on [Matrix](https://matrix.to/#/#docs-official:matrix.org).
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.
## Roadmap
Want to know where the project is headed? [🗺️ Checkout our roadmap](https://github.com/orgs/numerique-gouv/projects/13/views/11)
## License
## Licence 📝
This work is released under the MIT License (see [LICENSE](https://github.com/suitenumerique/docs/blob/main/LICENSE)).
This work is released under the MIT License (see [LICENSE](./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.
## 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.
If you intend to make pull requests see CONTRIBUTING for guidelines.
Directory structure:
```markdown
docs
├── bin - executable scripts or binaries that are used for various tasks, such as setup scripts, utility scripts, or custom commands.
├── crowdin - for crowdin translations, a tool or service that helps manage translations for the project.
├── docker - Dockerfiles and related configuration files used to build Docker images for the project. These images can be used for development, testing, or production environments.
├── docs - documentation for the project, including user guides, API documentation, and other helpful resources.
├── env.d/development - environment-specific configuration files for the development environment. These files might include environment variables, configuration settings, or other setup files needed for development.
├── gitlint - configuration files for `gitlint`, a tool that enforces commit message guidelines to ensure consistency and quality in commit messages.
├── playground - experimental or temporary code, where developers can test new features or ideas without affecting the main codebase.
└── src - main source code directory, containing the core application code, libraries, and modules of the project.
```
## 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/)
### 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.

View File

@@ -20,7 +20,7 @@ docker_build(
docker_build(
'localhost:5001/impress-y-provider:latest',
context='..',
dockerfile='../src/frontend/Dockerfile',
dockerfile='../src/frontend/servers/y-provider/Dockerfile',
only=['./src/frontend/', './docker/', './.dockerignore'],
target = 'y-provider',
live_update=[

View File

@@ -6,9 +6,9 @@ REPO_DIR="$(cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd)"
UNSET_USER=0
TERRAFORM_DIRECTORY="./env.d/terraform"
COMPOSE_FILE="${REPO_DIR}/docker-compose.yml"
COMPOSE_PROJECT="impress"
if [ -z ${COMPOSE_FILE+x} ]; then
COMPOSE_FILE="${REPO_DIR}/compose.yaml"
fi
# _set_user: set (or unset) default user id used to run docker commands
#
@@ -40,9 +40,8 @@ function _set_user() {
# ARGS : docker compose command arguments
function _docker_compose() {
echo "🐳(compose) project: '${COMPOSE_PROJECT}' file: '${COMPOSE_FILE}'"
echo "🐳(compose) project, file: '${COMPOSE_FILE}'"
docker compose \
-p "${COMPOSE_PROJECT}" \
-f "${COMPOSE_FILE}" \
--project-directory "${REPO_DIR}" \
"$@"

View File

@@ -1,103 +1,2 @@
#!/bin/sh
set -o errexit
CURRENT_DIR=$(pwd)
echo "0. Create ca"
# 0. Create ca
mkcert -install
cd /tmp
mkcert "127.0.0.1.nip.io" "*.127.0.0.1.nip.io"
cd $CURRENT_DIR
echo "1. Create registry container unless it already exists"
# 1. Create registry container unless it already exists
reg_name='kind-registry'
reg_port='5001'
if [ "$(docker inspect -f '{{.State.Running}}' "${reg_name}" 2>/dev/null || true)" != 'true' ]; then
docker run \
-d --restart=unless-stopped -p "127.0.0.1:${reg_port}:5000" --network bridge --name "${reg_name}" \
registry:2
fi
echo "2. Create kind cluster with containerd registry config dir enabled"
# 2. Create kind cluster with containerd registry config dir enabled
# TODO: kind will eventually enable this by default and this patch will
# be unnecessary.
#
# See:
# https://github.com/kubernetes-sigs/kind/issues/2875
# https://github.com/containerd/containerd/blob/main/docs/cri/config.md#registry-configuration
# See: https://github.com/containerd/containerd/blob/main/docs/hosts.md
cat <<EOF | kind create cluster --config=-
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
containerdConfigPatches:
- |-
[plugins."io.containerd.grpc.v1.cri".registry]
config_path = "/etc/containerd/certs.d"
nodes:
- role: control-plane
image: kindest/node:v1.27.3
kubeadmConfigPatches:
- |
kind: InitConfiguration
nodeRegistration:
kubeletExtraArgs:
node-labels: "ingress-ready=true"
extraPortMappings:
- containerPort: 80
hostPort: 80
protocol: TCP
- containerPort: 443
hostPort: 443
protocol: TCP
- role: worker
image: kindest/node:v1.27.3
- role: worker
image: kindest/node:v1.27.3
EOF
echo "3. Add the registry config to the nodes"
# 3. Add the registry config to the nodes
#
# This is necessary because localhost resolves to loopback addresses that are
# network-namespace local.
# In other words: localhost in the container is not localhost on the host.
#
# We want a consistent name that works from both ends, so we tell containerd to
# alias localhost:${reg_port} to the registry container when pulling images
REGISTRY_DIR="/etc/containerd/certs.d/localhost:${reg_port}"
for node in $(kind get nodes); do
docker exec "${node}" mkdir -p "${REGISTRY_DIR}"
cat <<EOF | docker exec -i "${node}" cp /dev/stdin "${REGISTRY_DIR}/hosts.toml"
[host."http://${reg_name}:5000"]
EOF
done
echo "4. Connect the registry to the cluster network if not already connected"
# 4. Connect the registry to the cluster network if not already connected
# This allows kind to bootstrap the network but ensures they're on the same network
if [ "$(docker inspect -f='{{json .NetworkSettings.Networks.kind}}' "${reg_name}")" = 'null' ]; then
docker network connect "kind" "${reg_name}"
fi
echo "5. Document the local registry"
# 5. Document the local registry
# https://github.com/kubernetes/enhancements/tree/master/keps/sig-cluster-lifecycle/generic/1755-communicating-a-local-registry
cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: ConfigMap
metadata:
name: local-registry-hosting
namespace: kube-public
data:
localRegistryHosting.v1: |
host: "localhost:${reg_port}"
help: "https://kind.sigs.k8s.io/docs/user/local-registry/"
EOF
echo "6. Install ingress-nginx"
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/kind/deploy.yaml
kubectl -n ingress-nginx create secret tls mkcert --key /tmp/127.0.0.1.nip.io+1-key.pem --cert /tmp/127.0.0.1.nip.io+1.pem
kubectl -n ingress-nginx patch deployments.apps ingress-nginx-controller --type 'json' -p '[{"op": "add", "path": "/spec/template/spec/containers/0/args/-", "value":"--default-ssl-certificate=ingress-nginx/mkcert"}]'
curl https://raw.githubusercontent.com/numerique-gouv/tools/refs/heads/main/kind/create_cluster.sh | bash -s -- impress

17
bin/update_app_cacert.sh Executable file
View File

@@ -0,0 +1,17 @@
#!/bin/sh
set -o errexit
# The script is pretty simple. It downloads the latest cacert.pem file from the certifi package and appends the root certificate from mkcert to it. Then it copies the updated cacert.pem file to the container.
# The script is executed with the following command:
# $ bin/update_app_cacert.sh docs-production-backend-1
CONTAINER_NAME=${1:-"docs-production-backend-1"}
echo "updating cacert.pem for certifi package in ${CONTAINER_NAME}"
curl --create-dirs https://raw.githubusercontent.com/certifi/python-certifi/refs/heads/master/certifi/cacert.pem -o /tmp/certifi/cacert.pem
cat "$(mkcert -CAROOT)/rootCA.pem" >> /tmp/certifi/cacert.pem
docker cp /tmp/certifi/cacert.pem ${CONTAINER_NAME}:/usr/local/lib/python3.12/site-packages/certifi/cacert.pem
echo "end patching cacert.pem in ${CONTAINER_NAME}"

167
compose.production.yaml Normal file
View File

@@ -0,0 +1,167 @@
name: docs-production
services:
postgresql:
image: postgres:16
healthcheck:
test: ["CMD", "pg_isready", "-q", "-U", "docs", "-d", "docs"]
interval: 1s
timeout: 2s
retries: 300
env_file:
- env.d/production/postgresql
environment:
- PGDATA=/var/lib/postgresql/data/pgdata
volumes:
- ./data/production/databases/backend:/var/lib/postgresql/data/pgdata
redis:
image: redis:5
backend-migration:
image: lasuite/impress-backend:latest
user: ${DOCKER_USER:-1000}
command: ["python", "manage.py", "migrate", "--noinput"]
environment:
- DJANGO_CONFIGURATION=Production
env_file:
- env.d/production/backend
- env.d/production/postgresql
- env.d/production/yprovider
depends_on:
postgresql:
condition: service_healthy
restart: true
redis:
condition: service_started
minio:
condition: service_started
backend:
image: lasuite/impress-backend:latest
user: ${DOCKER_USER:-1000}
restart: always
environment:
- DJANGO_CONFIGURATION=Production
env_file:
- env.d/production/backend
- env.d/production/postgresql
- env.d/production/yprovider
healthcheck:
test: ["CMD", "python", "manage.py", "check"]
interval: 15s
timeout: 30s
retries: 20
start_period: 10s
depends_on:
postgresql:
condition: service_healthy
restart: true
backend-migration:
condition: service_completed_successfully
redis:
condition: service_started
minio:
condition: service_started
minio-bootstrap:
condition: service_completed_successfully
celery:
user: ${DOCKER_USER:-1000}
image: lasuite/impress-backend:latest
command: ["celery", "-A", "impress.celery_app", "worker", "-l", "INFO"]
environment:
- DJANGO_CONFIGURATION=Production
env_file:
- env.d/production/backend
- env.d/production/postgresql
- env.d/production/yprovider
depends_on:
- backend
frontend:
image: lasuite/impress-frontend:latest
user: ${DOCKER_USER:-1000}
y-provider:
image: lasuite/impress-y-provider:latest
user: ${DOCKER_USER:-1000}
env_file:
- env.d/production/yprovider
kc_postgresql:
image: postgres:16
healthcheck:
test: ["CMD", "pg_isready", "-q", "-U", "keycloak", "-d", "keycloak"]
interval: 1s
timeout: 2s
retries: 300
env_file:
- env.d/production/kc_postgresql
environment:
- PGDATA=/var/lib/postgresql/data/pgdata
volumes:
- ./data/production/databases/keycloak:/var/lib/postgresql/data/pgdata
keycloak:
image: quay.io/keycloak/keycloak:26.1.0
command: ["start"]
env_file:
- env.d/production/keycloak
- env.d/production/kc_postgresql
ports:
- "8443:8443"
volumes:
- ${DOCS_PROD_KEYCLOAK_CERT_FOLDER:-./data/production/certs}:/etc/ssl/certs:ro
depends_on:
kc_postgresql:
condition: service_healthy
restart: true
minio-bootstrap:
image: minio/mc
env_file:
- env.d/production/minio
depends_on:
minio:
condition: service_healthy
restart: true
entrypoint: >
sh -c "
/usr/bin/mc alias set docs http://minio:9000 $${MINIO_ROOT_USER} $${MINIO_ROOT_PASSWORD} && \
/usr/bin/mc mb --ignore-existing docs/docs-media-storage && \
/usr/bin/mc version enable docs/docs-media-storage && \
exit 0;"
minio:
user: ${DOCKER_USER:-1000}
image: minio/minio
env_file:
- env.d/production/minio
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 1s
timeout: 20s
retries: 300
entrypoint: ""
command: minio server /data
volumes:
- ./data/production/media:/data
ingress:
image: nginx:1.27
ports:
- "${DOCS_PROD_NGING_PORT:-443}:8083"
volumes:
- ./docker/files/production/etc/nginx/conf.d:/etc/nginx/conf.d:ro
- ${DOCS_PROD_NGINX_CERT_FOLDER:-./data/production/certs}:/etc/nginx/ssl:ro
depends_on:
frontend:
condition: service_started
y-provider:
condition: service_started
keycloak:
condition: service_started
backend:
condition: service_healthy
restart: true

View File

@@ -1,6 +1,13 @@
name: docs
services:
postgresql:
image: postgres:16
healthcheck:
test: ["CMD-SHELL", "pg_isready"]
interval: 1s
timeout: 2s
retries: 300
env_file:
- env.d/development/postgresql
ports:
@@ -23,6 +30,11 @@ services:
ports:
- '9000:9000'
- '9001:9001'
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 1s
timeout: 20s
retries: 300
entrypoint: ""
command: minio server --console-address :9001 /data
volumes:
@@ -31,7 +43,9 @@ services:
createbuckets:
image: minio/mc
depends_on:
- minio
minio:
condition: service_healthy
restart: true
entrypoint: >
sh -c "
/usr/bin/mc alias set impress http://minio:9000 impress password && \
@@ -59,10 +73,15 @@ services:
- ./src/backend:/app
- ./data/static:/data/static
depends_on:
- postgresql
- mailcatcher
- redis
- createbuckets
postgresql:
condition: service_healthy
restart: true
mailcatcher:
condition: service_started
redis:
condition: service_started
createbuckets:
condition: service_started
celery-dev:
user: ${DOCKER_USER:-1000}
@@ -93,9 +112,13 @@ services:
- env.d/development/common
- env.d/development/postgresql
depends_on:
- postgresql
- redis
- minio
postgresql:
condition: service_healthy
restart: true
redis:
condition: service_started
minio:
condition: service_started
celery:
user: ${DOCKER_USER:-1000}
@@ -118,6 +141,7 @@ services:
depends_on:
- keycloak
- app-dev
- y-provider
frontend-dev:
user: "${DOCKER_USER:-1000}"
@@ -134,9 +158,6 @@ services:
ports:
- "3000:3000"
dockerize:
image: jwilder/dockerize
crowdin:
image: crowdin/cli:3.16.0
volumes:
@@ -150,7 +171,7 @@ services:
image: node:18
user: "${DOCKER_USER:-1000}"
environment:
HOME: /tmp
HOME: /tmp
volumes:
- ".:/app"
@@ -158,18 +179,21 @@ services:
user: ${DOCKER_USER:-1000}
build:
context: .
dockerfile: ./src/frontend/Dockerfile
dockerfile: ./src/frontend/servers/y-provider/Dockerfile
target: y-provider
restart: unless-stopped
env_file:
- env.d/development/common
ports:
- "4444:4444"
volumes:
- ./src/frontend/servers/y-provider:/home/frontend/servers/y-provider
- /home/frontend/servers/y-provider/node_modules/
- /home/frontend/servers/y-provider/dist/
kc_postgresql:
image: postgres:14.3
healthcheck:
test: ["CMD-SHELL", "pg_isready"]
interval: 1s
timeout: 2s
retries: 300
ports:
- "5433:5432"
env_file:
@@ -201,4 +225,6 @@ services:
ports:
- "8080:8080"
depends_on:
- kc_postgresql
kc_postgresql:
condition: service_healthy
restart: true

View File

@@ -1,7 +1,7 @@
#
# Your crowdin's credentials
#
api_token_env: CROWDIN_API_TOKEN
api_token_env: CROWDIN_PERSONAL_TOKEN
project_id_env: CROWDIN_PROJECT_ID
base_path_env: CROWDIN_BASE_PATH
@@ -15,11 +15,11 @@ preserve_hierarchy: true
# Files configuration
#
files: [
{
source : "/backend/locale/django.pot",
dest: "/backend-impress.pot",
translation : "/backend/locale/%locale_with_underscore%/LC_MESSAGES/django.po"
},
{
source : "/backend/locale/django.pot",
dest: "/backend-impress.pot",
translation : "/backend/locale/%locale_with_underscore%/LC_MESSAGES/django.po"
},
{
source: "/frontend/packages/i18n/locales/impress/translations-crowdin.json",
dest: "/frontend-impress.json",

View File

@@ -4,9 +4,58 @@ server {
server_name localhost;
charset utf-8;
# Proxy auth for collaboration server
location /collaboration/ws/ {
# Collaboration Auth request configuration
auth_request /collaboration-auth;
auth_request_set $authHeader $upstream_http_authorization;
auth_request_set $canEdit $upstream_http_x_can_edit;
auth_request_set $userId $upstream_http_x_user_id;
# Pass specific headers from the auth response
proxy_set_header Authorization $authHeader;
proxy_set_header X-Can-Edit $canEdit;
proxy_set_header X-User-Id $userId;
# Ensure WebSocket upgrade
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
# Collaboration server
proxy_pass http://y-provider:4444;
# Set appropriate timeout for WebSocket
proxy_read_timeout 86400;
proxy_send_timeout 86400;
# Preserve original host and additional headers
proxy_set_header Host $host;
}
location /collaboration-auth {
proxy_pass http://app-dev:8000/api/v1.0/documents/collaboration-auth/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Original-URL $request_uri;
# Prevent the body from being passed
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header X-Original-Method $request_method;
}
location /collaboration/api/ {
# Collaboration server
proxy_pass http://y-provider:4444;
proxy_set_header Host $host;
}
# Proxy auth for media
location /media/ {
# Auth request configuration
auth_request /auth;
auth_request /media-auth;
auth_request_set $authHeader $upstream_http_authorization;
auth_request_set $authDate $upstream_http_x_amz_date;
auth_request_set $authContentSha256 $upstream_http_x_amz_content_sha256;
@@ -21,8 +70,8 @@ server {
proxy_set_header Host minio:9000;
}
location /auth {
proxy_pass http://app-dev:8000/api/v1.0/documents/retrieve-auth/;
location /media-auth {
proxy_pass http://app-dev:8000/api/v1.0/documents/media-auth/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

View File

@@ -0,0 +1,132 @@
upstream docs_backend {
server backend:8000 fail_timeout=0;
}
upstream docs_frontend {
server frontend:8080 fail_timeout=0;
}
server {
listen 8083 ssl;
server_name localhost;
# Disables server version feedback on pages and in headers
server_tokens off;
ssl_certificate /etc/nginx/ssl/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/privkey.pem;
location @proxy_to_docs_backend {
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_redirect off;
proxy_pass http://docs_backend;
}
location @proxy_to_docs_frontend {
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_redirect off;
proxy_pass http://docs_frontend;
}
location / {
try_files $uri @proxy_to_docs_frontend;
}
location /api {
try_files $uri @proxy_to_docs_backend;
}
location /admin {
try_files $uri @proxy_to_docs_backend;
}
# Proxy auth for collaboration server
location /collaboration/ws/ {
# Collaboration Auth request configuration
auth_request /collaboration-auth;
proxy_set_header X-Forwarded-Proto https;
auth_request_set $authHeader $upstream_http_authorization;
auth_request_set $canEdit $upstream_http_x_can_edit;
auth_request_set $userId $upstream_http_x_user_id;
# Pass specific headers from the auth response
proxy_set_header Authorization $authHeader;
proxy_set_header X-Can-Edit $canEdit;
proxy_set_header X-User-Id $userId;
# Ensure WebSocket upgrade
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
# Collaboration server
proxy_pass http://y-provider:4444;
# Set appropriate timeout for WebSocket
proxy_read_timeout 86400;
proxy_send_timeout 86400;
# Preserve original host and additional headers
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Origin $http_origin;
proxy_set_header Host $host;
}
location /collaboration-auth {
proxy_pass http://docs_backend/api/v1.0/documents/collaboration-auth/;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Original-URL $request_uri;
# Prevent the body from being passed
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header X-Original-Method $request_method;
}
location /collaboration/api/ {
# Collaboration server
proxy_pass http://y-provider:4444;
proxy_set_header Host $host;
}
# Proxy auth for media
location /media/ {
# Auth request configuration
auth_request /media-auth;
auth_request_set $authHeader $upstream_http_authorization;
auth_request_set $authDate $upstream_http_x_amz_date;
auth_request_set $authContentSha256 $upstream_http_x_amz_content_sha256;
# Pass specific headers from the auth response
proxy_set_header Authorization $authHeader;
proxy_set_header X-Amz-Date $authDate;
proxy_set_header X-Amz-Content-SHA256 $authContentSha256;
# Get resource from Minio
proxy_pass http://minio:9000/docs-media-storage/;
proxy_set_header Host minio:9000;
}
location /media-auth {
proxy_pass http://docs_backend/api/v1.0/documents/media-auth/;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Original-URL $request_uri;
# Prevent the body from being passed
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header X-Original-Method $request_method;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 MiB

BIN
docs/assets/logo-docs.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

View File

@@ -0,0 +1,163 @@
image:
repository: lasuite/impress-backend
pullPolicy: Always
tag: "latest"
backend:
replicas: 1
envVars:
COLLABORATION_API_URL: https://impress.127.0.0.1.nip.io/collaboration/api/
COLLABORATION_SERVER_SECRET: my-secret
DJANGO_CSRF_TRUSTED_ORIGINS: https://impress.127.0.0.1.nip.io
DJANGO_CONFIGURATION: Feature
DJANGO_ALLOWED_HOSTS: impress.127.0.0.1.nip.io
DJANGO_SERVER_TO_SERVER_API_TOKENS: secret-api-key
DJANGO_SECRET_KEY: AgoodOrAbadKey
DJANGO_SETTINGS_MODULE: impress.settings
DJANGO_SUPERUSER_PASSWORD: admin
DJANGO_EMAIL_BRAND_NAME: "La Suite Numérique"
DJANGO_EMAIL_HOST: "mailcatcher"
DJANGO_EMAIL_LOGO_IMG: https://impress.127.0.0.1.nip.io/assets/logo-suite-numerique.png
DJANGO_EMAIL_PORT: 1025
DJANGO_EMAIL_USE_SSL: False
LOGGING_LEVEL_HANDLERS_CONSOLE: ERROR
LOGGING_LEVEL_LOGGERS_ROOT: INFO
LOGGING_LEVEL_LOGGERS_APP: INFO
OIDC_OP_JWKS_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/certs
OIDC_OP_AUTHORIZATION_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/auth
OIDC_OP_TOKEN_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/token
OIDC_OP_USER_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/userinfo
OIDC_OP_LOGOUT_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/session/end
OIDC_RP_CLIENT_ID: impress
OIDC_RP_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly
OIDC_RP_SIGN_ALGO: RS256
OIDC_RP_SCOPES: "openid email"
OIDC_VERIFY_SSL: False
USER_OIDC_FIELD_TO_SHORTNAME: "given_name"
USER_OIDC_FIELDS_TO_FULLNAME: "given_name,usual_name"
OIDC_REDIRECT_ALLOWED_HOSTS: https://impress.127.0.0.1.nip.io
OIDC_AUTH_REQUEST_EXTRA_PARAMS: "{'acr_values': 'eidas1'}"
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
DB_PASSWORD: pass
DB_PORT: 5432
POSTGRES_DB: impress
POSTGRES_USER: dinum
POSTGRES_PASSWORD: pass
REDIS_URL: redis://default:pass@redis-master:6379/1
AWS_S3_ENDPOINT_URL: http://minio.impress.svc.cluster.local:9000
AWS_S3_ACCESS_KEY_ID: root
AWS_S3_SECRET_ACCESS_KEY: password
AWS_STORAGE_BUCKET_NAME: impress-media-storage
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
Y_PROVIDER_API_BASE_URL: http://impress-y-provider:443/api/
Y_PROVIDER_API_KEY: my-secret
migrate:
command:
- "/bin/sh"
- "-c"
- |
python manage.py migrate --no-input &&
python manage.py create_demo --force
restartPolicy: Never
command:
- "gunicorn"
- "-c"
- "/usr/local/etc/gunicorn/impress.py"
- "impress.wsgi:application"
- "--reload"
createsuperuser:
command:
- "/bin/sh"
- "-c"
- |
python manage.py createsuperuser --email admin@example.com --password admin
restartPolicy: Never
# Exra volume to manage our local custom CA and avoid to set ssl_verify: false
extraVolumeMounts:
- name: certs
mountPath: /usr/local/lib/python3.12/site-packages/certifi/cacert.pem
subPath: cacert.pem
# Exra volume to manage our local custom CA and avoid to set ssl_verify: false
extraVolumes:
- name: certs
configMap:
name: certifi
items:
- key: cacert.pem
path: cacert.pem
frontend:
envVars:
PORT: 8080
NEXT_PUBLIC_API_ORIGIN: https://impress.127.0.0.1.nip.io
replicas: 1
image:
repository: lasuite/impress-frontend
pullPolicy: Always
tag: "latest"
yProvider:
replicas: 1
image:
repository: lasuite/impress-y-provider
pullPolicy: Always
tag: "latest"
envVars:
COLLABORATION_LOGGING: true
COLLABORATION_SERVER_ORIGIN: https://impress.127.0.0.1.nip.io
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
ingressCollaborationWS:
enabled: true
host: impress.127.0.0.1.nip.io
annotations:
nginx.ingress.kubernetes.io/auth-url: https://impress.127.0.0.1.nip.io/api/v1.0/documents/collaboration-auth/
ingressCollaborationApi:
enabled: true
host: impress.127.0.0.1.nip.io
ingressAdmin:
enabled: true
host: impress.127.0.0.1.nip.io
ingressMedia:
enabled: true
host: impress.127.0.0.1.nip.io
annotations:
nginx.ingress.kubernetes.io/auth-url: https://impress.127.0.0.1.nip.io/api/v1.0/documents/media-auth/
nginx.ingress.kubernetes.io/auth-response-headers: "Authorization, X-Amz-Date, X-Amz-Content-SHA256"
nginx.ingress.kubernetes.io/upstream-vhost: minio.impress.svc.cluster.local:9000
nginx.ingress.kubernetes.io/rewrite-target: /impress-media-storage/$1
serviceMedia:
host: minio.impress.svc.cluster.local
port: 9000

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,8 @@
auth:
rootUser: root
rootPassword: password
provisioning:
enabled: true
buckets:
- name: impress-media-storage
versioning: true

View File

@@ -0,0 +1,7 @@
auth:
username: dinum
password: pass
database: impress
tls:
enabled: true
autoGenerated: true

View File

@@ -0,0 +1,4 @@
auth:
password: pass
architecture: standalone

View File

@@ -0,0 +1,66 @@
# Installation with docker compose
We provide a configuration for running Docs in production using docker compose. This configuration is experimental, the official way to deploy Docs in production is to use [k8s](docs/installation/k8s.md)
## Requirements
- A modern version of Docker and its Compose plugin.
- SSL certificates for Docs domain and Keycloak.
- Two domain name. One for the Docs application and an other one for Keycloak. Both can be a subdomain of a common domain. (example: docs.domain.tld and keycloak.domain.tld)
## Installation
- Clone this repository: `git clone https://github.com/suitenumerique/docs.git`
- Then in the clone directory you can run the following command: `make bootsrap-production`
## Configure your ssl certificates
You have to provide the ssl certificates. The easiest way is to use [certbot](https://certbot.eff.org/), generate the certificates with it (both for Docs and Keycloak) and then mount them in ingress and keycloak containers. Two environment variables can be used for that:
- `DOCS_PROD_NGINX_CERT_FOLDER` path to the folder containing the certificates for Docs. This folder will be mounted in `/etc/nginx/ssl` in the container. You have to adapt the certificates name in the file `docker/files/production/etc/nginx/conf.d/default.conf` accordingly with the certificates name you have (see `ssl_certificate` and `ssl_certificate_key` directives).
- `DOCS_PROD_KEYCLOAK_CERT_FOLDER` path to the folder containing the certificates for Keycloak. This folder will be mounted in `/etc/ssl/certs` in the container. You have to adapt the certificates name in the configuration file in `env.d/production/keycloak` to add the correct path for environment variables `KC_HTTPS_CERTIFICATE_FILE` and `KC_HTTPS_CERTIFICATE_KEY_FILE`.
### Configuration
All the configuration files are in the directory `env.d/production`. You have to edit all the files to complete them. For the OIDC information you will have them once Keycloak will be running and you have configured your own realm on it.
#### env.d/production/minio
All the settings related to Minio. You have to set a username and a password to manage the minio cluster. You will need them later in the `env.d/production/backend` file.
#### env.d/production/postgresql
All the settings related to the Postgresql database used by the Django application.
#### env.d/production/yprovider
All the settings related to the collaboration server. All the secret and api key must be generated.
#### env.d/production/kc_postgresql
All the settings related to the Postgresql database used by keycloak.
#### env.d/production/keycloak
All the settings related to the Keycloak application.
#### env.d/production/backend
All the settings related to the Django application. Only the settings you don't have for now are all the one related to OIDC. You will have them once the compose started and you can access to Keycloak.
## Run the compose configuration
The compose configuration can be run with the following command: `make run-production`. The first start can be a little bit long, lots of things are created. Once started you can check that everything is running with the following command: `COMPOSE_FILE=compose.production.yaml ./bin/compose ps`
## Configure keycloak
You have to create a new realm in your Keycloak and once created you have to create a new OIDC client in it. You will use this client to configure the OIDC part in `env.d/production/backend`. This is the last missing part to complete the Django application configuration.
Once the client information set in `env.d/production/backend` you have to start the containers again by running the commande `make run-production`. The command will recreate the containers with the good configuration.
### Helpers
there is a helper script to control the `docker compose` command. You can export the variable `COMPOSE_FILE` with the compose filename (`export COMPOSE_FILE=compose.production.yaml`). After you can run `./bin/compose` to run the docker compose command line.
Makefile commands available:
- `make bootstrap-production`: create the configuration files in `env.d/production`, create the directories : `data/production`. Both directories must be backup, if you loose them you loose all the data related to the application.
- `make run-production`: up the ingress containers. Will start all the containers needed in cascade.
- `make stop-production`: stop all the containers.

231
docs/installation/k8s.md Normal file
View File

@@ -0,0 +1,231 @@
# Installation on a k8s cluster
This document is a step-by-step guide that describes how to install Docs on a k8s cluster without AI features. It's a teaching document to learn how it's work. It needs to be adapt for production environment.
## Prerequisites
- k8s cluster with an nginx-ingress controller
- an OIDC provider (if you don't have one, we will provide an example)
- a PostgreSQL server (if you don't have one, we will provide an example)
- a Memcached server (if you don't have one, we will provide an example)
- a S3 bucket (if you don't have one, we will provide an example)
### Test cluster
If you do not have a test cluster, you can install everything on a local kind cluster. In this case, the simplest way is to use our script **bin/start-kind.sh**.
To be able to use the script, you will need to install:
- Docker (https://docs.docker.com/desktop/)
- Kind (https://kind.sigs.k8s.io/docs/user/quick-start/#installation)
- Mkcert (https://github.com/FiloSottile/mkcert#installation)
- Helm (https://helm.sh/docs/intro/quickstart/#install-helm)
```
./bin/start-kind.sh
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 4700 100 4700 0 0 92867 0 --:--:-- --:--:-- --:--:-- 94000
0. Create ca
The local CA is already installed in the system trust store! 👍
The local CA is already installed in the Firefox and/or Chrome/Chromium trust store! 👍
Created a new certificate valid for the following names 📜
- "127.0.0.1.nip.io"
- "*.127.0.0.1.nip.io"
Reminder: X.509 wildcards only go one level deep, so this won't match a.b.127.0.0.1.nip.io
The certificate is at "./127.0.0.1.nip.io+1.pem" and the key at "./127.0.0.1.nip.io+1-key.pem" ✅
It will expire on 24 March 2027 🗓
1. Create registry container unless it already exists
2. Create kind cluster with containerd registry config dir enabled
Creating cluster "suite" ...
✓ Ensuring node image (kindest/node:v1.27.3) 🖼
✓ Preparing nodes 📦
✓ Writing configuration 📜
✓ Starting control-plane 🕹️
✓ Installing CNI 🔌
✓ Installing StorageClass 💾
Set kubectl context to "kind-suite"
You can now use your cluster with:
kubectl cluster-info --context kind-suite
Thanks for using kind! 😊
3. Add the registry config to the nodes
4. Connect the registry to the cluster network if not already connected
5. Document the local registry
configmap/local-registry-hosting created
Warning: resource configmaps/coredns is missing the kubectl.kubernetes.io/last-applied-configuration annotation which is required by kubectl apply. kubectl apply should only be used on resources created declaratively by either kubectl create --save-config or kubectl apply. The missing annotation will be patched automatically.
configmap/coredns configured
deployment.apps/coredns restarted
6. Install ingress-nginx
namespace/ingress-nginx created
serviceaccount/ingress-nginx created
serviceaccount/ingress-nginx-admission created
role.rbac.authorization.k8s.io/ingress-nginx created
role.rbac.authorization.k8s.io/ingress-nginx-admission created
clusterrole.rbac.authorization.k8s.io/ingress-nginx created
clusterrole.rbac.authorization.k8s.io/ingress-nginx-admission created
rolebinding.rbac.authorization.k8s.io/ingress-nginx created
rolebinding.rbac.authorization.k8s.io/ingress-nginx-admission created
clusterrolebinding.rbac.authorization.k8s.io/ingress-nginx created
clusterrolebinding.rbac.authorization.k8s.io/ingress-nginx-admission created
configmap/ingress-nginx-controller created
service/ingress-nginx-controller created
service/ingress-nginx-controller-admission created
deployment.apps/ingress-nginx-controller created
job.batch/ingress-nginx-admission-create created
job.batch/ingress-nginx-admission-patch created
ingressclass.networking.k8s.io/nginx created
validatingwebhookconfiguration.admissionregistration.k8s.io/ingress-nginx-admission created
secret/mkcert created
deployment.apps/ingress-nginx-controller patched
7. Setup namespace
namespace/impress created
Context "kind-suite" modified.
secret/mkcert created
$ kubectl -n ingress-nginx get po
NAME READY STATUS RESTARTS AGE
ingress-nginx-admission-create-t55ph 0/1 Completed 0 2m56s
ingress-nginx-admission-patch-94dvt 0/1 Completed 1 2m56s
ingress-nginx-controller-57c548c4cd-2rx47 1/1 Running 0 2m56s
```
When your k8s cluster is ready (the ingress nginx controller is up), you can start the deployment. This cluster is special because it uses the *.127.0.0.1.nip.io domain and mkcert certificates to have full HTTPS support and easy domain name management.
Please remember that *.127.0.0.1.nip.io will always resolve to 127.0.0.1, except in the k8s cluster where we configure CoreDNS to answer with the ingress-nginx service IP.
## Preparation
### What will you use to authenticate your users ?
Docs uses OIDC, so if you already have an OIDC provider, obtain the necessary information to use it. In the next step, we will see how to configure Django (and thus Docs) to use it. If you do not have a provider, we will show you how to deploy a local Keycloak instance (this is not a production deployment, just a demo).
```
$ kubectl create namespace impress
$ kubectl config set-context --current --namespace=impress
$ helm install keycloak oci://registry-1.docker.io/bitnamicharts/keycloak -f examples/keycloak.values.yaml
$ #wait until
$ kubectl get po
NAME READY STATUS RESTARTS AGE
keycloak-0 1/1 Running 0 6m48s
keycloak-postgresql-0 1/1 Running 0 6m48s
```
From here the important informations you will need are :
```
OIDC_OP_JWKS_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/certs
OIDC_OP_AUTHORIZATION_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/auth
OIDC_OP_TOKEN_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/token
OIDC_OP_USER_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/userinfo
OIDC_OP_LOGOUT_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/session/end
OIDC_RP_CLIENT_ID: impress
OIDC_RP_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly
OIDC_RP_SIGN_ALGO: RS256
OIDC_RP_SCOPES: "openid email"
```
You can find these values in **examples/keycloak.values.yaml**
### Find redis server connexion values
Impress need a redis so we will start by deploying a redis :
```
$ helm install redis oci://registry-1.docker.io/bitnamicharts/redis -f examples/redis.values.yaml
$ kubectl get po
NAME READY STATUS RESTARTS AGE
keycloak-0 1/1 Running 0 26m
keycloak-postgresql-0 1/1 Running 0 26m
redis-master-0 1/1 Running 0 35s
```
### Find postgresql connexion values
Impress uses a postgresql db as backend so if you have a provider, obtain the necessary information to use it. If you do not have, you can install a postgresql testing environment as follow:
```
$ helm install postgresql oci://registry-1.docker.io/bitnamicharts/postgresql -f examples/postgresql.values.yaml
$ kubectl get po
NAME READY STATUS RESTARTS AGE
keycloak-0 1/1 Running 0 28m
keycloak-postgresql-0 1/1 Running 0 28m
postgresql-0 1/1 Running 0 14m
redis-master-0 1/1 Running 0 42s
```
From here important informations you will need are :
```
DB_HOST: postgres-postgresql
DB_NAME: impress
DB_USER: dinum
DB_PASSWORD: pass
DB_PORT: 5432
POSTGRES_DB: impress
POSTGRES_USER: dinum
POSTGRES_PASSWORD: pass
```
### Find s3 bucket connexion values
Impress uses a s3 bucket to store documents so if you have a provider obtain the necessary information to use it. If you do not have, you can install a local minio testing environment as follow:
```
$ helm install minio oci://registry-1.docker.io/bitnamicharts/minio -f examples/minio.values.yaml
$ kubectl get po
NAME READY STATUS RESTARTS AGE
keycloak-0 1/1 Running 0 38m
keycloak-postgresql-0 1/1 Running 0 38m
minio-84f5c66895-bbhsk 1/1 Running 0 42s
minio-provisioning-2b5sq 0/1 Completed 0 42s
postgresql-0 1/1 Running 0 24m
redis-master-0 1/1 Running 0 10m
```
## Deployment
Now you are ready to deploy Impress without AI. AI requiered more dependancies (openai API). To deploy impress you need to provide all previous informations to the helm chart.
```
$ helm repo add impress https://suitenumerique.github.io/docs/
$ helm repo update
$ helm install impress impress/docs -f examples/impress.values.yaml
$ kubectl get po
NAME READY STATUS RESTARTS AGE
impress-docs-backend-96558758d-xtkbp 0/1 Running 0 79s
impress-docs-backend-createsuperuser-r7ltc 0/1 Completed 0 79s
impress-docs-backend-migrate-c949s 0/1 Completed 0 79s
impress-docs-frontend-6749f644f7-p5s42 1/1 Running 0 79s
impress-docs-y-provider-6947fd8f54-78f2l 1/1 Running 0 79s
keycloak-0 1/1 Running 0 48m
keycloak-postgresql-0 1/1 Running 0 48m
minio-84f5c66895-bbhsk 1/1 Running 0 10m
minio-provisioning-2b5sq 0/1 Completed 0 10m
postgresql-0 1/1 Running 0 34m
redis-master-0 1/1 Running 0 20m
```
## Test your deployment
In order to test your deployment you have to login to your instance. If you use exclusively our examples you can do :
```
$ kubectl get ingress
NAME CLASS HOSTS ADDRESS PORTS AGE
impress-docs <none> impress.127.0.0.1.nip.io localhost 80, 443 114s
impress-docs-admin <none> impress.127.0.0.1.nip.io localhost 80, 443 114s
impress-docs-collaboration-api <none> impress.127.0.0.1.nip.io localhost 80, 443 114s
impress-docs-media <none> impress.127.0.0.1.nip.io localhost 80, 443 114s
impress-docs-ws <none> impress.127.0.0.1.nip.io localhost 80, 443 114s
keycloak <none> keycloak.127.0.0.1.nip.io localhost 80 49m
```
You can use impress on https://impress.127.0.0.1.nip.io. The provisionning user in keycloak is impress/impress.

View File

@@ -4,13 +4,21 @@ DJANGO_SECRET_KEY=ThisIsAnExampleKeyForDevPurposeOnly
DJANGO_SETTINGS_MODULE=impress.settings
DJANGO_SUPERUSER_PASSWORD=admin
# Logging
# Set to DEBUG level for dev only
LOGGING_LEVEL_HANDLERS_CONSOLE=INFO
LOGGING_LEVEL_LOGGERS_ROOT=INFO
LOGGING_LEVEL_LOGGERS_APP=INFO
# Python
PYTHONPATH=/app
# impress settings
# Mail
DJANGO_EMAIL_BRAND_NAME="La Suite Numérique"
DJANGO_EMAIL_HOST="mailcatcher"
DJANGO_EMAIL_LOGO_IMG="http://localhost:3000/assets/logo-suite-numerique.png"
DJANGO_EMAIL_PORT=1025
# Backend url
@@ -21,6 +29,7 @@ STORAGES_STATICFILES_BACKEND=django.contrib.staticfiles.storage.StaticFilesStora
AWS_S3_ENDPOINT_URL=http://minio:9000
AWS_S3_ACCESS_KEY_ID=impress
AWS_S3_SECRET_ACCESS_KEY=password
MEDIA_BASE_URL=http://localhost:8083
# OIDC
OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/impress/protocol/openid-connect/certs
@@ -44,3 +53,12 @@ OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
AI_BASE_URL=https://openaiendpoint.com
AI_API_KEY=password
AI_MODEL=llama
# Collaboration
COLLABORATION_API_URL=http://nginx:8083/collaboration/api/
COLLABORATION_SERVER_ORIGIN=http://localhost:3000
COLLABORATION_SERVER_SECRET=my-secret
COLLABORATION_WS_URL=ws://localhost:8083/collaboration/ws/
# Frontend
FRONTEND_THEME=dsfr

View File

@@ -1,3 +1,6 @@
# For the CI job test-e2e
SUSTAINED_THROTTLE_RATES="200/hour"
BURST_THROTTLE_RATES="200/minute"
DJANGO_SERVER_TO_SERVER_API_TOKENS=test-e2e
Y_PROVIDER_API_KEY=yprovider-api-key
Y_PROVIDER_API_BASE_URL=http://y-provider:4444/api/

View File

@@ -1,3 +1,3 @@
CROWDIN_API_TOKEN=Your-Api-Token
CROWDIN_PERSONAL_TOKEN=Your-Personal-Token
CROWDIN_PROJECT_ID=Your-Project-Id
CROWDIN_BASE_PATH=/app/src

View File

@@ -0,0 +1,58 @@
## Django
DJANGO_ALLOWED_HOSTS=impress.127.0.0.1.nip.io,keycloack.127.0.0.1.nip.io
DJANGO_SECRET_KEY=ThisIsAnExampleKeyForDevPurposeOnly
DJANGO_SETTINGS_MODULE=impress.settings
DJANGO_SUPERUSER_PASSWORD=ThisIsAnExamplePassword
# Logging
# Set to DEBUG level for dev only
LOGGING_LEVEL_HANDLERS_CONSOLE=ERROR
LOGGING_LEVEL_LOGGERS_ROOT=INFO
LOGGING_LEVEL_LOGGERS_APP=INFO
# Python
PYTHONPATH=/app
# impress settings
# Mail
DJANGO_EMAIL_BRAND_NAME="La Suite Numérique"
DJANGO_EMAIL_HOST="mailcatcher"
DJANGO_EMAIL_LOGO_IMG="https://impress.127.0.0.1.nip.io/assets/logo-suite-numerique.png"
DJANGO_EMAIL_PORT=1025
# Media
STORAGES_STATICFILES_BACKEND=django.contrib.staticfiles.storage.StaticFilesStorage
AWS_S3_ENDPOINT_URL=http://minio:9000
AWS_S3_ACCESS_KEY_ID=<minio root user>
AWS_S3_SECRET_ACCESS_KEY=<minio root password>
AWS_STORAGE_BUCKET_NAME=docs-media-storage
MEDIA_BASE_URL=impress.127.0.0.1.nip.io
# OIDC
USER_OIDC_FIELD_TO_SHORTNAME="given_name"
USER_OIDC_FIELDS_TO_FULLNAME="given_name,usual_name"
OIDC_OP_JWKS_ENDPOINT=https://impress.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/certs
OIDC_OP_AUTHORIZATION_ENDPOINT=https://impress.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/auth
OIDC_OP_TOKEN_ENDPOINT=https://impress.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/token
OIDC_OP_USER_ENDPOINT=https://impress.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/userinfo
OIDC_OP_LOGOUT_ENDPOINT=https://impress.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/logout
OIDC_RP_CLIENT_ID=impress
OIDC_RP_CLIENT_SECRETThisIsAnExampleKeyForDevPurposeOnly
OIDC_RP_SIGN_ALGO=RS256
OIDC_RP_SCOPES="openid email"
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
OIDC_REDIRECT_ALLOWED_HOSTS=["https://impress.127.0.0.1.nip.io"]
OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
# AI
AI_BASE_URL=https://openaiendpoint.com
AI_API_KEY=password
AI_MODEL=llama
# Frontend
FRONTEND_THEME=dsfr

View File

@@ -0,0 +1,9 @@
# Postgresql db container configuration
POSTGRES_DB=keycloak
POSTGRES_USER=keycloak
POSTGRES_PASSWORD=<Set postgresql password>
# Keycloak database configuration
KC_DB_URL_DATABASE=keycloak
KC_DB_USERNAME=keycloak
KC_DB_PASSWORD=<Same password as above>

View File

@@ -0,0 +1,9 @@
KC_BOOTSTRAP_ADMIN_USERNAME=<Change this admin user>
KC_BOOTSTRAP_ADMIN_PASSWORD=<Change this admin password>
KC_DB=postgres
KC_DB_URL_HOST=kc_postgresql
KC_DB_SCHEMA=public
PROXY_ADDRESS_FORWARDING='true'
KC_HOSTNAME=http://localhost:8083
KC_HTTPS_CERTIFICATE_FILE=/etc/ssl/certs/docs.crt
KC_HTTPS_CERTIFICATE_KEY_FILE=/etc/ssl/private/docs.key

View File

@@ -0,0 +1,2 @@
MINIO_ROOT_USER=<Set minio root username>
MINIO_ROOT_PASSWORD=<Set minio root password>

View File

@@ -0,0 +1,11 @@
# Postgresql db container configuration
POSTGRES_DB=docs
POSTGRES_USER=docs
POSTGRES_PASSWORD=<Set postgresql password>
# App database configuration
DB_HOST=postgresql
DB_NAME=docs
DB_USER=docs
DB_PASSWORD=<Same password as above>
DB_PORT=5432

View File

@@ -0,0 +1,5 @@
COLLABORATION_LOGGING=true
Y_PROVIDER_API_KEY=<Set y provider api key>
COLLABORATION_API_URL=https://impress.127.0.0.1.nip.io/collaboration/api/
COLLABORATION_SERVER_ORIGIN=https://impress.127.0.0.1.nip.io
COLLABORATION_SERVER_SECRET=<Set collaboration secret>

View File

@@ -13,7 +13,13 @@
"enabled": false,
"groupName": "ignored js dependencies",
"matchManagers": ["npm"],
"matchPackageNames": ["fetch-mock", "node", "node-fetch", "eslint"]
"matchPackageNames": [
"fetch-mock",
"node",
"node-fetch",
"eslint",
"workbox-webpack-plugin"
]
}
]
}

Submodule secrets deleted from 38594182e8

View File

@@ -0,0 +1,69 @@
"""API filters for Impress' core application."""
from django.utils.translation import gettext_lazy as _
import django_filters
from core import models
class DocumentFilter(django_filters.FilterSet):
"""
Custom filter for filtering documents.
"""
is_creator_me = django_filters.BooleanFilter(
method="filter_is_creator_me", label=_("Creator is me")
)
is_favorite = django_filters.BooleanFilter(
method="filter_is_favorite", label=_("Favorite")
)
title = django_filters.CharFilter(
field_name="title", lookup_expr="icontains", label=_("Title")
)
class Meta:
model = models.Document
fields = ["is_creator_me", "is_favorite", "link_reach", "title"]
# pylint: disable=unused-argument
def filter_is_creator_me(self, queryset, name, value):
"""
Filter documents based on the `creator` being the current user.
Example:
- /api/v1.0/documents/?is_creator_me=true
→ Filters documents created by the logged-in user
- /api/v1.0/documents/?is_creator_me=false
→ Filters documents created by other users
"""
user = self.request.user
if not user.is_authenticated:
return queryset
if value:
return queryset.filter(creator=user)
return queryset.exclude(creator=user)
# pylint: disable=unused-argument
def filter_is_favorite(self, queryset, name, value):
"""
Filter documents based on whether they are marked as favorite by the current user.
Example:
- /api/v1.0/documents/?is_favorite=true
→ Filters documents marked as favorite by the logged-in user
- /api/v1.0/documents/?is_favorite=false
→ Filters documents not marked as favorite by the logged-in user
"""
user = self.request.user
if not user.is_authenticated:
return queryset
if value:
return queryset.filter(favorited_by_users__user=user)
return queryset.exclude(favorited_by_users__user=user)

View File

@@ -4,6 +4,7 @@ import mimetypes
from django.conf import settings
from django.db.models import Q
from django.utils.functional import lazy
from django.utils.translation import gettext_lazy as _
import magic
@@ -11,6 +12,10 @@ from rest_framework import exceptions, serializers
from core import enums, models
from core.services.ai_services import AI_ACTIONS
from core.services.converter_services import (
ConversionError,
YdocConverter,
)
class UserSerializer(serializers.ModelSerializer):
@@ -137,32 +142,69 @@ class BaseResourceSerializer(serializers.ModelSerializer):
return {}
class DocumentSerializer(BaseResourceSerializer):
"""Serialize documents."""
class ListDocumentSerializer(BaseResourceSerializer):
"""Serialize documents with limited fields for display in lists."""
content = serializers.CharField(required=False)
accesses = DocumentAccessSerializer(many=True, read_only=True)
is_favorite = serializers.BooleanField(read_only=True)
nb_accesses = serializers.IntegerField(read_only=True)
class Meta:
model = models.Document
fields = [
"id",
"content",
"title",
"accesses",
"abilities",
"content",
"created_at",
"creator",
"is_favorite",
"link_role",
"link_reach",
"created_at",
"nb_accesses",
"title",
"updated_at",
]
read_only_fields = [
"id",
"accesses",
"abilities",
"created_at",
"creator",
"is_favorite",
"link_role",
"link_reach",
"nb_accesses",
"updated_at",
]
class DocumentSerializer(ListDocumentSerializer):
"""Serialize documents with all fields for display in detail views."""
content = serializers.CharField(required=False)
class Meta:
model = models.Document
fields = [
"id",
"abilities",
"content",
"created_at",
"creator",
"is_favorite",
"link_role",
"link_reach",
"nb_accesses",
"title",
"updated_at",
]
read_only_fields = [
"id",
"abilities",
"created_at",
"creator",
"is_favorite",
"link_role",
"link_reach",
"nb_accesses",
"updated_at",
]
@@ -190,6 +232,104 @@ class DocumentSerializer(BaseResourceSerializer):
return value
class ServerCreateDocumentSerializer(serializers.Serializer):
"""
Serializer for creating a document from a server-to-server request.
Expects 'content' as a markdown string, which is converted to our internal format
via a Node.js microservice. The conversion is handled automatically, so third parties
only need to provide markdown.
Both "sub" and "email" are required because the external app calling doesn't know
if the user will pre-exist in Docs database. If the user pre-exist, we will ignore the
submitted "email" field and use the email address set on the user account in our database
"""
# Document
title = serializers.CharField(required=True)
content = serializers.CharField(required=True)
# User
sub = serializers.CharField(
required=True, validators=[models.User.sub_validator], max_length=255
)
email = serializers.EmailField(required=True)
language = serializers.ChoiceField(
required=False, choices=lazy(lambda: settings.LANGUAGES, tuple)()
)
# Invitation
message = serializers.CharField(required=False)
subject = serializers.CharField(required=False)
def create(self, validated_data):
"""Create the document and associate it with the user or send an invitation."""
language = validated_data.get("language", settings.LANGUAGE_CODE)
# Get the user on its sub (unique identifier). Default on email if allowed in settings
email = validated_data["email"]
try:
user = models.User.objects.get_user_by_sub_or_email(
validated_data["sub"], email
)
except models.DuplicateEmailError as err:
raise serializers.ValidationError({"email": [err.message]}) from err
if user:
email = user.email
language = user.language or language
try:
document_content = YdocConverter().convert_markdown(
validated_data["content"]
)
except ConversionError as err:
raise serializers.ValidationError(
{"content": ["Could not convert content"]}
) from err
document = models.Document.objects.create(
title=validated_data["title"],
content=document_content,
creator=user,
)
if user:
# Associate the document with the pre-existing user
models.DocumentAccess.objects.create(
document=document,
role=models.RoleChoices.OWNER,
user=user,
)
else:
# The user doesn't exist in our database: we need to invite him/her
models.Invitation.objects.create(
document=document,
email=email,
role=models.RoleChoices.OWNER,
)
self._send_email_notification(document, validated_data, email, language)
return document
def _send_email_notification(self, document, validated_data, email, language):
"""Notify the user about the newly created document."""
subject = validated_data.get("subject") or _(
"A new document was created on your behalf!"
)
context = {
"message": validated_data.get("message")
or _("You have been granted ownership of a new document:"),
"title": subject,
}
document.send_email(subject, [email], context, language)
def update(self, instance, validated_data):
"""
This serializer does not support updates.
"""
raise NotImplementedError("Update is not supported for this serializer.")
class LinkDocumentSerializer(BaseResourceSerializer):
"""
Serialize link configuration for documents.
@@ -248,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
@@ -255,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

View File

@@ -1,57 +1,58 @@
"""API endpoints"""
# pylint: disable=too-many-lines
import logging
import re
import uuid
from urllib.parse import urlparse
from django.conf import settings
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.search import TrigramSimilarity
from django.core.exceptions import ValidationError
from django.core.files.storage import default_storage
from django.db import models as db
from django.db.models import (
Min,
Count,
Exists,
OuterRef,
Q,
Subquery,
Value,
)
from django.http import Http404
import rest_framework as drf
from botocore.exceptions import ClientError
from rest_framework import (
decorators,
exceptions,
filters,
metadata,
mixins,
pagination,
status,
viewsets,
)
from rest_framework import (
response as drf_response,
)
from django_filters import rest_framework as drf_filters
from rest_framework import filters, status
from rest_framework import response as drf_response
from rest_framework.permissions import AllowAny
from core import enums, models
from core import authentication, enums, models
from core.services.ai_services import AIService
from core.services.collaboration_services import CollaborationService
from . import permissions, serializers, utils
from .filters import DocumentFilter
logger = logging.getLogger(__name__)
ATTACHMENTS_FOLDER = "attachments"
UUID_REGEX = (
r"[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}"
)
FILE_EXT_REGEX = r"\.[a-zA-Z]{3,4}"
MEDIA_URL_PATTERN = re.compile(
f"{settings.MEDIA_URL:s}({UUID_REGEX:s})/"
f"({ATTACHMENTS_FOLDER:s}/{UUID_REGEX:s}{FILE_EXT_REGEX:s})$"
MEDIA_STORAGE_URL_PATTERN = re.compile(
f"{settings.MEDIA_URL:s}(?P<pk>{UUID_REGEX:s})/"
f"(?P<key>{ATTACHMENTS_FOLDER:s}/{UUID_REGEX:s}{FILE_EXT_REGEX:s})$"
)
COLLABORATION_WS_URL_PATTERN = re.compile(rf"(?:^|&)room=(?P<pk>{UUID_REGEX})(?:&|$)")
# pylint: disable=too-many-ancestors
ATTACHMENTS_FOLDER = "attachments"
class NestedGenericViewSet(viewsets.GenericViewSet):
class NestedGenericViewSet(drf.viewsets.GenericViewSet):
"""
A generic Viewset aims to be used in a nested route context.
e.g: `/api/v1.0/resource_1/<resource_1_pk>/resource_2/<resource_2_pk>/`
@@ -123,7 +124,7 @@ class SerializerPerActionMixin:
return self.serializer_classes.get(self.action, self.default_serializer_class)
class Pagination(pagination.PageNumberPagination):
class Pagination(drf.pagination.PageNumberPagination):
"""Pagination to display no more than 100 objects per page sorted by creation date."""
ordering = "-created_on"
@@ -132,7 +133,7 @@ class Pagination(pagination.PageNumberPagination):
class UserViewSet(
mixins.UpdateModelMixin, viewsets.GenericViewSet, mixins.ListModelMixin
drf.mixins.UpdateModelMixin, drf.viewsets.GenericViewSet, drf.mixins.ListModelMixin
):
"""User ViewSet"""
@@ -156,11 +157,24 @@ class UserViewSet(
# 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)
queryset = queryset.annotate(
similarity=TrigramSimilarity("email", 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
queryset = queryset.filter(similarity__gt=threshold).order_by(
"-similarity", "email"
)
return queryset
@decorators.action(
@drf.decorators.action(
detail=False,
methods=["get"],
url_name="me",
@@ -172,47 +186,11 @@ class UserViewSet(
Return information on currently logged user
"""
context = {"request": request}
return drf_response.Response(
return drf.response.Response(
self.serializer_class(request.user, context=context).data
)
class ResourceViewsetMixin:
"""Mixin with methods common to all resource viewsets that are managed with accesses."""
filter_backends = [filters.OrderingFilter]
ordering_fields = ["created_at", "updated_at", "title"]
ordering = ["-created_at"]
def get_queryset(self):
"""Custom queryset to get user related resources."""
queryset = super().get_queryset()
user = self.request.user
if not user.is_authenticated:
return queryset
user_roles_query = (
self.access_model_class.objects.filter(
Q(user=user) | Q(team__in=user.teams),
**{self.resource_field_name: OuterRef("pk")},
)
.values(self.resource_field_name)
.annotate(roles_array=ArrayAgg("role"))
.values("roles_array")
)
return queryset.annotate(user_roles=Subquery(user_roles_query)).distinct()
def perform_create(self, serializer):
"""Set the current user as owner of the newly created object."""
obj = serializer.save()
self.access_model_class.objects.create(
user=self.request.user,
role=models.RoleChoices.OWNER,
**{self.resource_field_name: obj},
)
class ResourceAccessViewsetMixin:
"""Mixin with methods common to all access viewsets."""
@@ -243,7 +221,7 @@ class ResourceAccessViewsetMixin:
teams = user.teams
user_roles_query = (
queryset.filter(
Q(user=user) | Q(team__in=teams),
db.Q(user=user) | db.Q(team__in=teams),
**{self.resource_field_name: self.kwargs["resource_id"]},
)
.values(self.resource_field_name)
@@ -257,11 +235,13 @@ class ResourceAccessViewsetMixin:
# access instances pointing to the logged-in user)
queryset = (
queryset.filter(
Q(**{f"{self.resource_field_name}__accesses__user": user})
| Q(**{f"{self.resource_field_name}__accesses__team__in": teams}),
db.Q(**{f"{self.resource_field_name}__accesses__user": user})
| db.Q(
**{f"{self.resource_field_name}__accesses__team__in": teams}
),
**{self.resource_field_name: self.kwargs["resource_id"]},
)
.annotate(user_roles=Subquery(user_roles_query))
.annotate(user_roles=db.Subquery(user_roles_query))
.distinct()
)
return queryset
@@ -276,9 +256,9 @@ class ResourceAccessViewsetMixin:
instance.role == "owner"
and resource.accesses.filter(role="owner").count() == 1
):
return drf_response.Response(
return drf.response.Response(
{"detail": "Cannot delete the last owner access for the resource."},
status=status.HTTP_403_FORBIDDEN,
status=drf.status.HTTP_403_FORBIDDEN,
)
return super().destroy(request, *args, **kwargs)
@@ -299,12 +279,12 @@ class ResourceAccessViewsetMixin:
and resource.accesses.filter(role=models.RoleChoices.OWNER).count() == 1
):
message = "Cannot change the role to a non-owner role for the last owner access."
raise exceptions.PermissionDenied({"detail": message})
raise drf.exceptions.PermissionDenied({"detail": message})
serializer.save()
class DocumentMetadata(metadata.SimpleMetadata):
class DocumentMetadata(drf.metadata.SimpleMetadata):
"""Custom metadata class to add information"""
def determine_metadata(self, request, view):
@@ -322,35 +302,90 @@ class DocumentMetadata(metadata.SimpleMetadata):
class DocumentViewSet(
ResourceViewsetMixin,
mixins.CreateModelMixin,
mixins.DestroyModelMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
drf.mixins.CreateModelMixin,
drf.mixins.DestroyModelMixin,
drf.mixins.UpdateModelMixin,
drf.viewsets.GenericViewSet,
):
"""Document ViewSet"""
"""
Document ViewSet for managing documents.
Provides endpoints for creating, updating, and deleting documents,
along with filtering options.
Filtering:
- `is_creator_me=true`: Returns documents created by the current user.
- `is_creator_me=false`: Returns documents created by other users.
- `is_favorite=true`: Returns documents marked as favorite by the current user
- `is_favorite=false`: Returns documents not marked as favorite by the current user
- `title=hello`: Returns documents which title contains the "hello" string
Example Usage:
- GET /api/v1.0/documents/?is_creator_me=true&is_favorite=true
- GET /api/v1.0/documents/?is_creator_me=false&title=hello
"""
filter_backends = [drf_filters.DjangoFilterBackend, filters.OrderingFilter]
filterset_class = DocumentFilter
metadata_class = DocumentMetadata
ordering = ["-updated_at"]
ordering_fields = ["created_at", "is_favorite", "updated_at", "title"]
permission_classes = [
permissions.AccessPermission,
]
serializer_class = serializers.DocumentSerializer
access_model_class = models.DocumentAccess
resource_field_name = "document"
queryset = models.Document.objects.all()
ordering = ["-updated_at"]
metadata_class = DocumentMetadata
serializer_class = serializers.DocumentSerializer
def get_serializer_class(self):
"""
Use ListDocumentSerializer for list actions, otherwise use DocumentSerializer.
"""
if self.action == "list":
return serializers.ListDocumentSerializer
return self.serializer_class
def get_queryset(self):
"""Optimize queryset to include favorite status for the current user."""
queryset = super().get_queryset()
user = self.request.user
# Annotate the number of accesses associated with each document
queryset = queryset.annotate(nb_accesses=Count("accesses", distinct=True))
if not user.is_authenticated:
# If the user is not authenticated, annotate `is_favorite` as False
return queryset.annotate(is_favorite=Value(False))
# Annotate the queryset to indicate if the document is favorited by the current user
favorite_exists = models.DocumentFavorite.objects.filter(
document_id=OuterRef("pk"), user=user
)
queryset = queryset.annotate(is_favorite=Exists(favorite_exists))
# Annotate the queryset with the logged-in user roles
user_roles_query = (
models.DocumentAccess.objects.filter(
Q(user=user) | Q(team__in=user.teams),
document_id=OuterRef("pk"),
)
.values("document")
.annotate(roles_array=ArrayAgg("role"))
.values("roles_array")
)
return queryset.annotate(user_roles=Subquery(user_roles_query)).distinct()
def list(self, request, *args, **kwargs):
"""Restrict resources returned by the list endpoint"""
queryset = self.filter_queryset(self.get_queryset())
user = self.request.user
if user.is_authenticated:
queryset = queryset.filter(
Q(accesses__user=user)
| Q(accesses__team__in=user.teams)
db.Q(accesses__user=user)
| db.Q(accesses__team__in=user.teams)
| (
Q(link_traces__user=user)
& ~Q(link_reach=models.LinkReachChoices.RESTRICTED)
db.Q(link_traces__user=user)
& ~db.Q(link_reach=models.LinkReachChoices.RESTRICTED)
)
)
else:
@@ -362,7 +397,7 @@ class DocumentViewSet(
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(queryset, many=True)
return drf_response.Response(serializer.data)
return drf.response.Response(serializer.data)
def retrieve(self, request, *args, **kwargs):
"""
@@ -385,9 +420,42 @@ class DocumentViewSet(
# The trace already exists, so we just pass without doing anything
pass
return drf_response.Response(serializer.data)
return drf.response.Response(serializer.data)
@decorators.action(detail=True, methods=["get"], url_path="versions")
def perform_create(self, serializer):
"""Set the current user as creator and owner of the newly created object."""
obj = serializer.save(creator=self.request.user)
models.DocumentAccess.objects.create(
document=obj,
user=self.request.user,
role=models.RoleChoices.OWNER,
)
@drf.decorators.action(
authentication_classes=[authentication.ServerToServerAuthentication],
detail=False,
methods=["post"],
permission_classes=[],
url_path="create-for-owner",
)
def create_for_owner(self, request):
"""
Create a document on behalf of a specified owner (pre-existing user or invited).
"""
# Deserialize and validate the data
serializer = serializers.ServerCreateDocumentSerializer(data=request.data)
if not serializer.is_valid():
return drf_response.Response(
serializer.errors, status=status.HTTP_400_BAD_REQUEST
)
document = serializer.save()
return drf_response.Response(
{"id": str(document.id)}, status=status.HTTP_201_CREATED
)
@drf.decorators.action(detail=True, methods=["get"], url_path="versions")
def versions_list(self, request, *args, **kwargs):
"""
Return the document's versions but only those created after the user got access
@@ -395,7 +463,7 @@ class DocumentViewSet(
"""
user = request.user
if not user.is_authenticated:
raise exceptions.PermissionDenied("Authentication required.")
raise drf.exceptions.PermissionDenied("Authentication required.")
# Validate query parameters using dedicated serializer
serializer = serializers.VersionFilterSerializer(data=request.query_params)
@@ -406,13 +474,13 @@ class DocumentViewSet(
# Users should not see version history dating from before they gained access to the
# document. Filter to get the minimum access date for the logged-in user
access_queryset = document.accesses.filter(
Q(user=user) | Q(team__in=user.teams)
).aggregate(min_date=Min("created_at"))
db.Q(user=user) | db.Q(team__in=user.teams)
).aggregate(min_date=db.Min("created_at"))
# Handle the case where the user has no accesses
min_datetime = access_queryset["min_date"]
if not min_datetime:
return exceptions.PermissionDenied(
return drf.exceptions.PermissionDenied(
"Only users with specific access can see version history"
)
@@ -422,9 +490,9 @@ class DocumentViewSet(
page_size=serializer.validated_data.get("page_size"),
)
return drf_response.Response(versions_data)
return drf.response.Response(versions_data)
@decorators.action(
@drf.decorators.action(
detail=True,
methods=["get", "delete"],
url_path="versions/(?P<version_id>[0-9a-f-]{36})",
@@ -445,7 +513,7 @@ class DocumentViewSet(
min_datetime = min(
access.created_at
for access in document.accesses.filter(
Q(user=user) | Q(team__in=user.teams),
db.Q(user=user) | db.Q(team__in=user.teams),
)
)
if response["LastModified"] < min_datetime:
@@ -453,11 +521,11 @@ class DocumentViewSet(
if request.method == "DELETE":
response = document.delete_version(version_id)
return drf_response.Response(
return drf.response.Response(
status=response["ResponseMetadata"]["HTTPStatusCode"]
)
return drf_response.Response(
return drf.response.Response(
{
"content": response["Body"].read().decode("utf-8"),
"last_modified": response["LastModified"],
@@ -465,7 +533,7 @@ class DocumentViewSet(
}
)
@decorators.action(detail=True, methods=["put"], url_path="link-configuration")
@drf.decorators.action(detail=True, methods=["put"], url_path="link-configuration")
def link_configuration(self, request, *args, **kwargs):
"""Update link configuration with specific rights (cf get_abilities)."""
# Check permissions first
@@ -478,9 +546,50 @@ class DocumentViewSet(
serializer.is_valid(raise_exception=True)
serializer.save()
return drf_response.Response(serializer.data, status=status.HTTP_200_OK)
@decorators.action(detail=True, methods=["post"], url_path="attachment-upload")
# Notify collaboration server about the link updated
CollaborationService().reset_connections(str(document.id))
return drf.response.Response(serializer.data, status=drf.status.HTTP_200_OK)
@drf.decorators.action(detail=True, methods=["post", "delete"], url_path="favorite")
def favorite(self, request, *args, **kwargs):
"""
Mark or unmark the document as a favorite for the logged-in user based on the HTTP method.
"""
# Check permissions first
document = self.get_object()
user = request.user
if request.method == "POST":
# Try to mark as favorite
try:
models.DocumentFavorite.objects.create(document=document, user=user)
except ValidationError:
return drf.response.Response(
{"detail": "Document already marked as favorite"},
status=drf.status.HTTP_200_OK,
)
return drf.response.Response(
{"detail": "Document marked as favorite"},
status=drf.status.HTTP_201_CREATED,
)
# Handle DELETE method to unmark as favorite
deleted, _ = models.DocumentFavorite.objects.filter(
document=document, user=user
).delete()
if deleted:
return drf.response.Response(
{"detail": "Document unmarked as favorite"},
status=drf.status.HTTP_204_NO_CONTENT,
)
return drf.response.Response(
{"detail": "Document was already not marked as favorite"},
status=drf.status.HTTP_200_OK,
)
@drf.decorators.action(detail=True, methods=["post"], url_path="attachment-upload")
def attachment_upload(self, request, *args, **kwargs):
"""Upload a file related to a given document"""
# Check permissions first
@@ -496,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"
@@ -505,15 +617,15 @@ class DocumentViewSet(
file, default_storage.bucket_name, key, ExtraArgs=extra_args
)
return drf_response.Response(
{"file": f"{settings.MEDIA_URL:s}{key:s}"}, status=status.HTTP_201_CREATED
return drf.response.Response(
{"file": f"{settings.MEDIA_URL:s}{key:s}"},
status=drf.status.HTTP_201_CREATED,
)
@decorators.action(detail=False, methods=["get"], url_path="retrieve-auth")
def retrieve_auth(self, request, *args, **kwargs):
def _authorize_subrequest(self, request, pattern):
"""
This view is used by an Nginx subrequest to control access to a document's
attachment file.
Shared method to authorize access based on the original URL of an Nginx subrequest
and user permissions. Returns a dictionary of URL parameters if authorized.
The original url is passed by nginx in the "HTTP_X_ORIGINAL_URL" header.
See corresponding ingress configuration in Helm chart and read about the
@@ -525,33 +637,108 @@ class DocumentViewSet(
a 403 error). Note that we return 403 errors without any further details for security
reasons.
Parameters:
- pattern: The regex pattern to extract identifiers from the URL.
Returns:
- A dictionary of URL parameters if the request is authorized.
Raises:
- PermissionDenied if authorization fails.
"""
# Extract the original URL from the request header
original_url = request.META.get("HTTP_X_ORIGINAL_URL")
if not original_url:
logger.debug("Missing HTTP_X_ORIGINAL_URL header in subrequest")
raise drf.exceptions.PermissionDenied()
parsed_url = urlparse(original_url)
match = pattern.search(parsed_url.path)
# If the path does not match the pattern, try to extract the parameters from the query
if not match:
match = pattern.search(parsed_url.query)
if not match:
logger.debug(
"Subrequest URL '%s' did not match pattern '%s'",
parsed_url.path,
pattern,
)
raise drf.exceptions.PermissionDenied()
try:
url_params = match.groupdict()
except (ValueError, AttributeError) as exc:
logger.debug("Failed to extract parameters from subrequest URL: %s", exc)
raise drf.exceptions.PermissionDenied() from exc
pk = url_params.get("pk")
if not pk:
logger.debug("Document ID (pk) not found in URL parameters: %s", url_params)
raise drf.exceptions.PermissionDenied()
# Fetch the document and check if the user has access
try:
document = models.Document.objects.get(pk=pk)
except models.Document.DoesNotExist as exc:
logger.debug("Document with ID '%s' does not exist", pk)
raise drf.exceptions.PermissionDenied() from exc
user_abilities = document.get_abilities(request.user)
if not user_abilities.get(self.action, False):
logger.debug(
"User '%s' lacks permission for document '%s'", request.user, pk
)
raise drf.exceptions.PermissionDenied()
logger.debug(
"Subrequest authorization successful. Extracted parameters: %s", url_params
)
return url_params, user_abilities, request.user.id
@drf.decorators.action(detail=False, methods=["get"], url_path="media-auth")
def media_auth(self, request, *args, **kwargs):
"""
This view is used by an Nginx subrequest to control access to a document's
attachment file.
When we let the request go through, we compute authorization headers that will be added to
the request going through thanks to the nginx.ingress.kubernetes.io/auth-response-headers
annotation. The request will then be proxied to the object storage backend who will
respond with the file after checking the signature included in headers.
"""
original_url = urlparse(request.META.get("HTTP_X_ORIGINAL_URL"))
match = MEDIA_URL_PATTERN.search(original_url.path)
url_params, _, _ = self._authorize_subrequest(
request, MEDIA_STORAGE_URL_PATTERN
)
pk, key = url_params.values()
try:
pk, attachment_key = match.groups()
except AttributeError as excpt:
raise exceptions.PermissionDenied() from excpt
# Generate S3 authorization headers using the extracted URL parameters
request = utils.generate_s3_authorization_headers(f"{pk:s}/{key:s}")
# Check permission
try:
document = models.Document.objects.get(pk=pk)
except models.Document.DoesNotExist as excpt:
raise exceptions.PermissionDenied() from excpt
return drf.response.Response("authorized", headers=request.headers, status=200)
if not document.get_abilities(request.user).get("retrieve", False):
raise exceptions.PermissionDenied()
@drf.decorators.action(detail=False, methods=["get"], url_path="collaboration-auth")
def collaboration_auth(self, request, *args, **kwargs):
"""
This view is used by an Nginx subrequest to control access to a document's
collaboration server.
"""
_, user_abilities, user_id = self._authorize_subrequest(
request, COLLABORATION_WS_URL_PATTERN
)
can_edit = user_abilities["partial_update"]
# Generate authorization headers and return an authorization to proceed with the request
request = utils.generate_s3_authorization_headers(f"{pk:s}/{attachment_key:s}")
return drf_response.Response("authorized", headers=request.headers, status=200)
# Add the collaboration server secret token to the headers
headers = {
"Authorization": settings.COLLABORATION_SERVER_SECRET,
"X-Can-Edit": str(can_edit),
"X-User-Id": str(user_id),
}
@decorators.action(
return drf.response.Response("authorized", headers=headers, status=200)
@drf.decorators.action(
detail=True,
methods=["post"],
name="Apply a transformation action on a piece of text with AI",
@@ -577,9 +764,9 @@ class DocumentViewSet(
response = AIService().transform(text, action)
return drf_response.Response(response, status=status.HTTP_200_OK)
return drf.response.Response(response, status=drf.status.HTTP_200_OK)
@decorators.action(
@drf.decorators.action(
detail=True,
methods=["post"],
name="Translate a piece of text with AI",
@@ -606,17 +793,17 @@ class DocumentViewSet(
response = AIService().translate(text, language)
return drf_response.Response(response, status=status.HTTP_200_OK)
return drf.response.Response(response, status=drf.status.HTTP_200_OK)
class DocumentAccessViewSet(
ResourceAccessViewsetMixin,
mixins.CreateModelMixin,
mixins.DestroyModelMixin,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
drf.mixins.CreateModelMixin,
drf.mixins.DestroyModelMixin,
drf.mixins.ListModelMixin,
drf.mixins.RetrieveModelMixin,
drf.mixins.UpdateModelMixin,
drf.viewsets.GenericViewSet,
):
"""
API ViewSet for all interactions with document accesses.
@@ -654,42 +841,83 @@ class DocumentAccessViewSet(
access = serializer.save()
language = self.request.headers.get("Content-Language", "en-us")
access.document.email_invitation(
language,
access.document.send_invitation_email(
access.user.email,
access.role,
self.request.user,
language,
)
def perform_update(self, serializer):
"""Update an access to the document and notify the collaboration server."""
access = serializer.save()
access_user_id = None
if access.user:
access_user_id = str(access.user.id)
# Notify collaboration server about the access change
CollaborationService().reset_connections(
str(access.document.id), access_user_id
)
def perform_destroy(self, instance):
"""Delete an access to the document and notify the collaboration server."""
instance.delete()
# Notify collaboration server about the access removed
CollaborationService().reset_connections(
str(instance.document.id), str(instance.user.id)
)
class TemplateViewSet(
ResourceViewsetMixin,
mixins.CreateModelMixin,
mixins.DestroyModelMixin,
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
drf.mixins.CreateModelMixin,
drf.mixins.DestroyModelMixin,
drf.mixins.RetrieveModelMixin,
drf.mixins.UpdateModelMixin,
drf.viewsets.GenericViewSet,
):
"""Template ViewSet"""
filter_backends = [drf.filters.OrderingFilter]
permission_classes = [
permissions.IsAuthenticatedOrSafe,
permissions.AccessPermission,
]
ordering = ["-created_at"]
ordering_fields = ["created_at", "updated_at", "title"]
serializer_class = serializers.TemplateSerializer
access_model_class = models.TemplateAccess
resource_field_name = "template"
queryset = models.Template.objects.all()
def get_queryset(self):
"""Custom queryset to get user related templates."""
queryset = super().get_queryset()
user = self.request.user
if not user.is_authenticated:
return queryset
user_roles_query = (
models.TemplateAccess.objects.filter(
Q(user=user) | Q(team__in=user.teams),
template_id=OuterRef("pk"),
)
.values("template")
.annotate(roles_array=ArrayAgg("role"))
.values("roles_array")
)
return queryset.annotate(user_roles=Subquery(user_roles_query)).distinct()
def list(self, request, *args, **kwargs):
"""Restrict templates returned by the list endpoint"""
queryset = self.filter_queryset(self.get_queryset())
user = self.request.user
if user.is_authenticated:
queryset = queryset.filter(
Q(accesses__user=user)
| Q(accesses__team__in=user.teams)
| Q(is_public=True)
db.Q(accesses__user=user)
| db.Q(accesses__team__in=user.teams)
| db.Q(is_public=True)
)
else:
queryset = queryset.filter(is_public=True)
@@ -700,9 +928,18 @@ class TemplateViewSet(
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(queryset, many=True)
return drf_response.Response(serializer.data)
return drf.response.Response(serializer.data)
@decorators.action(
def perform_create(self, serializer):
"""Set the current user as owner of the newly created object."""
obj = serializer.save()
models.TemplateAccess.objects.create(
template=obj,
user=self.request.user,
role=models.RoleChoices.OWNER,
)
@drf.decorators.action(
detail=True,
methods=["post"],
url_path="generate-document",
@@ -725,8 +962,8 @@ class TemplateViewSet(
serializer = serializers.DocumentGenerationSerializer(data=request.data)
if not serializer.is_valid():
return drf_response.Response(
serializer.errors, status=status.HTTP_400_BAD_REQUEST
return drf.response.Response(
serializer.errors, status=drf.status.HTTP_400_BAD_REQUEST
)
body = serializer.validated_data["body"]
@@ -739,12 +976,12 @@ class TemplateViewSet(
class TemplateAccessViewSet(
ResourceAccessViewsetMixin,
mixins.CreateModelMixin,
mixins.DestroyModelMixin,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
drf.mixins.CreateModelMixin,
drf.mixins.DestroyModelMixin,
drf.mixins.ListModelMixin,
drf.mixins.RetrieveModelMixin,
drf.mixins.UpdateModelMixin,
drf.viewsets.GenericViewSet,
):
"""
API ViewSet for all interactions with template accesses.
@@ -779,12 +1016,12 @@ class TemplateAccessViewSet(
class InvitationViewset(
mixins.CreateModelMixin,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.DestroyModelMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
drf.mixins.CreateModelMixin,
drf.mixins.ListModelMixin,
drf.mixins.RetrieveModelMixin,
drf.mixins.DestroyModelMixin,
drf.mixins.UpdateModelMixin,
drf.viewsets.GenericViewSet,
):
"""API ViewSet for user invitations to document.
@@ -836,7 +1073,7 @@ class InvitationViewset(
# Determine which role the logged-in user has in the document
user_roles_query = (
models.DocumentAccess.objects.filter(
Q(user=user) | Q(team__in=teams),
db.Q(user=user) | db.Q(team__in=teams),
document=self.kwargs["resource_id"],
)
.values("document")
@@ -847,18 +1084,18 @@ class InvitationViewset(
queryset = (
# The logged-in user should be administrator or owner to see its accesses
queryset.filter(
Q(
db.Q(
document__accesses__user=user,
document__accesses__role__in=models.PRIVILEGED_ROLES,
)
| Q(
| db.Q(
document__accesses__team__in=teams,
document__accesses__role__in=models.PRIVILEGED_ROLES,
),
)
# Abilities are computed based on logged-in user's role and
# the user role on each document access
.annotate(user_roles=Subquery(user_roles_query))
.annotate(user_roles=db.Subquery(user_roles_query))
.distinct()
)
return queryset
@@ -869,6 +1106,35 @@ class InvitationViewset(
language = self.request.headers.get("Content-Language", "en-us")
invitation.document.email_invitation(
language, invitation.email, invitation.role, self.request.user
invitation.document.send_invitation_email(
invitation.email, invitation.role, self.request.user, language
)
class ConfigView(drf.views.APIView):
"""API ViewSet for sharing some public settings."""
permission_classes = [AllowAny]
def get(self, request):
"""
GET /api/v1.0/config/
Return a dictionary of public settings.
"""
array_settings = [
"COLLABORATION_WS_URL",
"CRISP_WEBSITE_ID",
"ENVIRONMENT",
"FRONTEND_THEME",
"MEDIA_BASE_URL",
"POSTHOG_KEY",
"LANGUAGES",
"LANGUAGE_CODE",
"SENTRY_DSN",
]
dict_settings = {}
for setting in array_settings:
if hasattr(settings, setting):
dict_settings[setting] = getattr(settings, setting)
return drf.response.Response(dict_settings)

View File

@@ -0,0 +1,52 @@
"""Custom authentication classes for the Impress core app"""
from django.conf import settings
from rest_framework.authentication import BaseAuthentication
from rest_framework.exceptions import AuthenticationFailed
class ServerToServerAuthentication(BaseAuthentication):
"""
Custom authentication class for server-to-server requests.
Validates the presence and correctness of the Authorization header.
"""
AUTH_HEADER = "Authorization"
TOKEN_TYPE = "Bearer" # noqa S105
def authenticate(self, request):
"""
Authenticate the server-to-server request by validating the Authorization header.
This method checks if the Authorization header is present in the request, ensures it
contains a valid token with the correct format, and verifies the token against the
list of allowed server-to-server tokens. If the header is missing, improperly formatted,
or contains an invalid token, an AuthenticationFailed exception is raised.
Returns:
None: If authentication is successful
(no user is authenticated for server-to-server requests).
Raises:
AuthenticationFailed: If the Authorization header is missing, malformed,
or contains an invalid token.
"""
auth_header = request.headers.get(self.AUTH_HEADER)
if not auth_header:
raise AuthenticationFailed("Authorization header is missing.")
# Validate token format and existence
auth_parts = auth_header.split(" ")
if len(auth_parts) != 2 or auth_parts[0] != self.TOKEN_TYPE:
raise AuthenticationFailed("Invalid authorization header.")
token = auth_parts[1]
if token not in settings.SERVER_TO_SERVER_API_TOKENS:
raise AuthenticationFailed("Invalid server-to-server token.")
# Authentication is successful, but no user is authenticated
def authenticate_header(self, request):
"""Return the WWW-Authenticate header value."""
return f"{self.TOKEN_TYPE} realm='Create document server to server'"

View File

@@ -1,5 +1,7 @@
"""Authentication Backends for the Impress core app."""
import logging
from django.conf import settings
from django.core.exceptions import SuspiciousOperation
from django.utils.translation import gettext_lazy as _
@@ -9,7 +11,9 @@ from mozilla_django_oidc.auth import (
OIDCAuthenticationBackend as MozillaOIDCAuthenticationBackend,
)
from core.models import User
from core.models import DuplicateEmailError, User
logger = logging.getLogger(__name__)
class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
@@ -59,10 +63,29 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
return userinfo
def verify_claims(self, claims):
"""
Verify the presence of essential claims and the "sub" (which is mandatory as defined
by the OIDC specification) to decide if authentication should be allowed.
"""
essential_claims = settings.USER_OIDC_ESSENTIAL_CLAIMS
missing_claims = [claim for claim in essential_claims if claim not in claims]
if missing_claims:
logger.error("Missing essential claims: %s", missing_claims)
return False
return True
def get_or_create_user(self, access_token, id_token, payload):
"""Return a User based on userinfo. Create a new user if no match is found."""
user_info = self.get_userinfo(access_token, id_token, payload)
if not self.verify_claims(user_info):
raise SuspiciousOperation("Claims verification failed.")
sub = user_info["sub"]
email = user_info.get("email")
# Get user's full name from OIDC fields defined in settings
@@ -75,13 +98,10 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
"short_name": short_name,
}
sub = user_info.get("sub")
if not sub:
raise SuspiciousOperation(
_("User info contained no recognizable user identification")
)
user = self.get_existing_user(sub, email)
try:
user = User.objects.get_user_by_sub_or_email(sub, email)
except DuplicateEmailError as err:
raise SuspiciousOperation(err.message) from err
if user:
if not user.is_active:
@@ -100,18 +120,6 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
)
return full_name or None
def get_existing_user(self, sub, email):
"""Fetch existing user by sub or email."""
try:
return User.objects.get(sub=sub)
except User.DoesNotExist:
if email and settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION:
try:
return User.objects.get(email=email)
except User.DoesNotExist:
pass
return None
def update_user_if_needed(self, user, claims):
"""Update user claims if they have changed."""
has_changed = any(
@@ -119,4 +127,4 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
)
if has_changed:
updated_claims = {key: value for key, value in claims.items() if value}
self.UserModel.objects.filter(sub=user.sub).update(**updated_claims)
self.UserModel.objects.filter(id=user.id).update(**updated_claims)

View File

@@ -56,6 +56,7 @@ class DocumentFactory(factory.django.DjangoModelFactory):
title = factory.Sequence(lambda n: f"document{n}")
content = factory.Sequence(lambda n: f"content{n}")
creator = factory.SubFactory(UserFactory)
link_reach = factory.fuzzy.FuzzyChoice(
[a[0] for a in models.LinkReachChoices.choices]
)
@@ -80,6 +81,13 @@ class DocumentFactory(factory.django.DjangoModelFactory):
for item in extracted:
models.LinkTrace.objects.create(document=self, user=item)
@factory.post_generation
def favorited_by(self, create, extracted, **kwargs):
"""Mark document as favorited by a list of users."""
if create and extracted:
for item in extracted:
models.DocumentFavorite.objects.create(document=self, user=item)
class UserDocumentAccessFactory(factory.django.DjangoModelFactory):
"""Create fake document user accesses for testing."""

View File

View File

@@ -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}."
)

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.2 on 2024-10-25 11:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0007_fix_users_duplicate'),
]
operations = [
migrations.AlterField(
model_name='document',
name='link_reach',
field=models.CharField(choices=[('restricted', 'Restricted'), ('authenticated', 'Authenticated'), ('public', 'Public')], default='restricted', max_length=20),
),
]

View File

@@ -0,0 +1,37 @@
# Generated by Django 5.1.2 on 2024-11-08 07:59
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0008_alter_document_link_reach'),
]
operations = [
migrations.AlterField(
model_name='user',
name='language',
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
),
migrations.CreateModel(
name='DocumentFavorite',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
('document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='favorited_by_users', to='core.document')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='favorite_documents', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Document favorite',
'verbose_name_plural': 'Document favorites',
'db_table': 'impress_document_favorite',
'constraints': [models.UniqueConstraint(fields=('user', 'document'), name='unique_document_favorite_user', violation_error_message='This document is already targeted by a favorite relation instance for the same user.')],
},
),
]

View File

@@ -0,0 +1,31 @@
# Generated by Django 5.1.2 on 2024-11-09 11:36
import django.core.validators
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0009_add_document_favorite'),
]
operations = [
migrations.AddField(
model_name='document',
name='creator',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='documents_created', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='user',
name='language',
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
),
migrations.AlterField(
model_name='user',
name='sub',
field=models.CharField(blank=True, help_text='Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only.', max_length=255, null=True, unique=True, validators=[django.core.validators.RegexValidator(message='Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters.', regex='^[\\w.@+-:]+\\Z')], verbose_name='sub'),
),
]

View File

@@ -0,0 +1,52 @@
# Generated by Django 5.1.2 on 2024-11-09 11:48
import django.db.models.deletion
from django.conf import settings
from django.db import migrations
from django.db.models import F, ForeignKey, Subquery, OuterRef, Q
def set_creator_from_document_access(apps, schema_editor):
"""
Populate the `creator` field for existing Document records.
This function assigns the `creator` field using the existing
DocumentAccess entries. We can be sure that all documents have at
least one user with "owner" role. If the document has several roles,
it should take the entry with the oldest date of creation.
The update is performed using efficient bulk queries with Django's
Subquery and OuterRef to minimize database hits and ensure performance.
Note: After running this migration, we quickly modify the schema to make
the `creator` field required.
"""
Document = apps.get_model("core", "Document")
DocumentAccess = apps.get_model("core", "DocumentAccess")
# Update `creator` using the "owner" role
owner_subquery = DocumentAccess.objects.filter(
document=OuterRef('pk'),
user__isnull=False,
role='owner',
).order_by('created_at').values('user_id')[:1]
Document.objects.filter(
creator__isnull=True
).update(creator=Subquery(owner_subquery))
class Migration(migrations.Migration):
dependencies = [
('core', '0010_add_field_creator_to_document'),
]
operations = [
migrations.RunPython(set_creator_from_document_access, reverse_code=migrations.RunPython.noop),
migrations.AlterField(
model_name='document',
name='creator',
field=ForeignKey(on_delete=django.db.models.deletion.RESTRICT, related_name='documents_created', to=settings.AUTH_USER_MODEL),
),
]

View File

@@ -0,0 +1,30 @@
# Generated by Django 5.1.2 on 2024-11-30 22:23
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0011_populate_creator_field_and_make_it_required'),
]
operations = [
migrations.AlterField(
model_name='document',
name='creator',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='documents_created', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='invitation',
name='issuer',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='invitations', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='user',
name='language',
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
),
]

View File

@@ -1,6 +1,7 @@
"""
Declare and configure the models for the impress core application
"""
# pylint: disable=too-many-lines
import hashlib
import smtplib
@@ -26,8 +27,8 @@ from django.template.context import Context
from django.template.loader import render_to_string
from django.utils import html, timezone
from django.utils.functional import cached_property, lazy
from django.utils.translation import get_language, override
from django.utils.translation import gettext_lazy as _
from django.utils.translation import override
import frontmatter
import markdown
@@ -89,6 +90,16 @@ class LinkReachChoices(models.TextChoices):
PUBLIC = "public", _("Public") # Even anonymous users can access the document
class DuplicateEmailError(Exception):
"""Raised when an email is already associated with a pre-existing user."""
def __init__(self, message=None, email=None):
"""Set message and email to describe the exception."""
self.message = message
self.email = email
super().__init__(self.message)
class BaseModel(models.Model):
"""
Serves as an abstract base model for other models, ensuring that records are validated
@@ -126,21 +137,50 @@ class BaseModel(models.Model):
super().save(*args, **kwargs)
class UserManager(auth_models.UserManager):
"""Custom manager for User model with additional methods."""
def get_user_by_sub_or_email(self, sub, email):
"""Fetch existing user by sub or email."""
try:
return self.get(sub=sub)
except self.model.DoesNotExist as err:
if not email:
return None
if settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION:
try:
return self.get(email=email)
except self.model.DoesNotExist:
pass
elif (
self.filter(email=email).exists()
and not settings.OIDC_ALLOW_DUPLICATE_EMAILS
):
raise DuplicateEmailError(
_(
"We couldn't find a user with this sub but the email is already "
"associated with a registered user."
)
) from err
return None
class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
"""User model to work with OIDC only authentication."""
sub_validator = validators.RegexValidator(
regex=r"^[\w.@+-]+\Z",
regex=r"^[\w.@+-:]+\Z",
message=_(
"Enter a valid sub. This value may contain only letters, "
"numbers, and @/./+/-/_ characters."
"numbers, and @/./+/-/_/: characters."
),
)
sub = models.CharField(
_("sub"),
help_text=_(
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
),
max_length=255,
unique=True,
@@ -192,7 +232,7 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
),
)
objects = auth_models.UserManager()
objects = UserManager()
USERNAME_FIELD = "admin_email"
REQUIRED_FIELDS = []
@@ -239,6 +279,13 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
for invitation in valid_invitations
]
)
# Set creator of documents if not yet set (e.g. documents created via server-to-server API)
document_ids = [invitation.document_id for invitation in valid_invitations]
Document.objects.filter(id__in=document_ids, creator__isnull=True).update(
creator=self
)
valid_invitations.delete()
def email_user(self, subject, message, from_email=None, **kwargs):
@@ -336,11 +383,18 @@ class Document(BaseModel):
link_reach = models.CharField(
max_length=20,
choices=LinkReachChoices.choices,
default=LinkReachChoices.AUTHENTICATED,
default=LinkReachChoices.RESTRICTED,
)
link_role = models.CharField(
max_length=20, choices=LinkRoleChoices.choices, default=LinkRoleChoices.READER
)
creator = models.ForeignKey(
User,
on_delete=models.RESTRICT,
related_name="documents_created",
blank=True,
null=True,
)
_content = None
@@ -496,7 +550,8 @@ class Document(BaseModel):
# Compute version roles before adding link roles because we don't
# want anonymous users to access versions (we wouldn't know from
# which date to allow them anyway)
can_get_versions = bool(roles)
# Anonymous users should also not see document accesses
has_role = bool(roles)
# Add role provided by the document link
if self.link_reach == LinkReachChoices.PUBLIC or (
@@ -507,63 +562,86 @@ class Document(BaseModel):
is_owner_or_admin = bool(
roles.intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
)
is_editor = bool(RoleChoices.EDITOR in roles)
can_get = bool(roles)
can_update = is_owner_or_admin or RoleChoices.EDITOR in roles
return {
"ai_transform": is_owner_or_admin or is_editor,
"ai_translate": is_owner_or_admin or is_editor,
"attachment_upload": is_owner_or_admin or is_editor,
"accesses_manage": is_owner_or_admin,
"accesses_view": has_role,
"ai_transform": can_update,
"ai_translate": can_update,
"attachment_upload": can_update,
"collaboration_auth": can_get,
"destroy": RoleChoices.OWNER in roles,
"favorite": can_get and user.is_authenticated,
"link_configuration": is_owner_or_admin,
"manage_accesses": is_owner_or_admin,
"invite_owner": RoleChoices.OWNER in roles,
"partial_update": is_owner_or_admin or is_editor,
"partial_update": can_update,
"retrieve": can_get,
"update": is_owner_or_admin or is_editor,
"media_auth": can_get,
"update": can_update,
"versions_destroy": is_owner_or_admin,
"versions_list": can_get_versions,
"versions_retrieve": can_get_versions,
"versions_list": has_role,
"versions_retrieve": has_role,
}
def email_invitation(self, language, email, role, sender):
"""Send email invitation."""
sender_name = sender.full_name or sender.email
def send_email(self, subject, emails, context=None, language=None):
"""Generate and send email from a template."""
context = context or {}
domain = Site.objects.get_current().domain
language = language or get_language()
context.update(
{
"brandname": settings.EMAIL_BRAND_NAME,
"document": self,
"domain": domain,
"link": f"{domain}/docs/{self.id}/",
"logo_img": settings.EMAIL_LOGO_IMG,
}
)
try:
with override(language):
title = _(
"%(sender_name)s shared a document with you: %(document)s"
) % {
"sender_name": sender_name,
"document": self.title,
}
template_vars = {
"title": title,
"domain": domain,
"document": self,
"link": f"{domain}/docs/{self.id}/",
"sender_name": sender_name,
"sender_name_email": f"{sender.full_name} ({sender.email})"
if sender.full_name
else sender.email,
"role": RoleChoices(role).label.lower(),
}
msg_html = render_to_string("mail/html/invitation.html", template_vars)
msg_plain = render_to_string("mail/text/invitation.txt", template_vars)
with override(language):
msg_html = render_to_string("mail/html/invitation.html", context)
msg_plain = render_to_string("mail/text/invitation.txt", context)
subject = str(subject) # Force translation
try:
send_mail(
title,
subject.capitalize(),
msg_plain,
settings.EMAIL_FROM,
[email],
emails,
html_message=msg_html,
fail_silently=False,
)
except smtplib.SMTPException as exception:
logger.error("invitation to %s was not sent: %s", emails, exception)
except smtplib.SMTPException as exception:
logger.error("invitation to %s was not sent: %s", email, exception)
def send_invitation_email(self, email, role, sender, language=None):
"""Method allowing a user to send an email invitation to another user for a document."""
language = language or get_language()
role = RoleChoices(role).label
sender_name = sender.full_name or sender.email
sender_name_email = (
f"{sender.full_name:s} ({sender.email})"
if sender.full_name
else sender.email
)
with override(language):
context = {
"title": _("{name} shared a document with you!").format(
name=sender_name
),
"message": _(
'{name} invited you with the role "{role}" on the following document:'
).format(name=sender_name_email, role=role.lower()),
}
subject = _("{name} shared a document with you: {title}").format(
name=sender_name, title=self.title
)
self.send_email(subject, [email], context, language)
class LinkTrace(BaseModel):
@@ -598,6 +676,37 @@ class LinkTrace(BaseModel):
return f"{self.user!s} trace on document {self.document!s}"
class DocumentFavorite(BaseModel):
"""Relation model to store a user's favorite documents."""
document = models.ForeignKey(
Document,
on_delete=models.CASCADE,
related_name="favorited_by_users",
)
user = models.ForeignKey(
User, on_delete=models.CASCADE, related_name="favorite_documents"
)
class Meta:
db_table = "impress_document_favorite"
verbose_name = _("Document favorite")
verbose_name_plural = _("Document favorites")
constraints = [
models.UniqueConstraint(
fields=["user", "document"],
name="unique_document_favorite_user",
violation_error_message=_(
"This document is already targeted by a favorite relation instance "
"for the same user."
),
),
]
def __str__(self):
return f"{self.user!s} favorite on document {self.document!s}"
class DocumentAccess(BaseAccess):
"""Relation model to give access to a document for a user or a team with a role."""
@@ -673,15 +782,15 @@ class Template(BaseModel):
is_owner_or_admin = bool(
set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
)
is_editor = bool(RoleChoices.EDITOR in roles)
can_get = self.is_public or bool(roles)
can_update = is_owner_or_admin or RoleChoices.EDITOR in roles
return {
"destroy": RoleChoices.OWNER in roles,
"generate_document": can_get,
"manage_accesses": is_owner_or_admin,
"update": is_owner_or_admin or is_editor,
"partial_update": is_owner_or_admin or is_editor,
"accesses_manage": is_owner_or_admin,
"update": can_update,
"partial_update": can_update,
"retrieve": can_get,
}
@@ -848,6 +957,8 @@ class Invitation(BaseModel):
User,
on_delete=models.CASCADE,
related_name="invitations",
blank=True,
null=True,
)
class Meta:
@@ -868,7 +979,10 @@ class Invitation(BaseModel):
super().clean()
# Check if an identity already exists for the provided email
if User.objects.filter(email=self.email).exists():
if (
User.objects.filter(email=self.email).exists()
and not settings.OIDC_ALLOW_DUPLICATE_EMAILS
):
raise exceptions.ValidationError(
{"email": _("This email is already associated to a registered user.")}
)

View File

@@ -67,10 +67,19 @@ class AIService:
)
content = response.choices[0].message.content
sanitized_content = re.sub(r"(?<!\\)\n", "\\\\n", content)
sanitized_content = re.sub(r"(?<!\\)\t", "\\\\t", sanitized_content)
json_response = json.loads(sanitized_content)
try:
sanitized_content = re.sub(r'\s*"answer"\s*:\s*', '"answer": ', content)
sanitized_content = re.sub(r"\s*\}", "}", sanitized_content)
sanitized_content = re.sub(r"(?<!\\)\n", "\\\\n", sanitized_content)
sanitized_content = re.sub(r"(?<!\\)\t", "\\\\t", sanitized_content)
json_response = json.loads(sanitized_content)
except (json.JSONDecodeError, IndexError):
try:
json_response = json.loads(content)
except json.JSONDecodeError as err:
raise RuntimeError("AI response is not valid JSON", content) from err
if "answer" not in json_response:
raise RuntimeError("AI response does not contain an answer")

View File

@@ -0,0 +1,43 @@
"""Collaboration services."""
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
import requests
class CollaborationService:
"""Service class for Collaboration related operations."""
def __init__(self):
"""Ensure that the collaboration configuration is set properly."""
if settings.COLLABORATION_API_URL is None:
raise ImproperlyConfigured("Collaboration configuration not set")
def reset_connections(self, room, user_id=None):
"""
Reset connections of a room in the collaboration server.
Reseting a connection means that the user will be disconnected and will
have to reconnect to the collaboration server, with updated rights.
"""
endpoint = "reset-connections"
# room is necessary as a parameter, it is easier to stick to the
# same pod thanks to a parameter
endpoint_url = f"{settings.COLLABORATION_API_URL}{endpoint}/?room={room}"
# Note: Collaboration microservice accepts only raw token, which is not recommended
headers = {"Authorization": settings.COLLABORATION_SERVER_SECRET}
if user_id:
headers["X-User-Id"] = user_id
try:
response = requests.post(endpoint_url, headers=headers, timeout=10)
except requests.RequestException as e:
raise requests.HTTPError("Failed to notify WebSocket server.") from e
if response.status_code != 200:
raise requests.HTTPError(
f"Failed to notify WebSocket server. Status code: {response.status_code}, "
f"Response: {response.text}"
)

View File

@@ -0,0 +1,78 @@
"""Converter services."""
from django.conf import settings
import requests
class ConversionError(Exception):
"""Base exception for conversion-related errors."""
class ValidationError(ConversionError):
"""Raised when the input validation fails."""
class ServiceUnavailableError(ConversionError):
"""Raised when the conversion service is unavailable."""
class InvalidResponseError(ConversionError):
"""Raised when the conversion service returns an invalid response."""
class MissingContentError(ConversionError):
"""Raised when the response is missing required content."""
class YdocConverter:
"""Service class for conversion-related operations."""
@property
def auth_header(self):
"""Build microservice authentication header."""
# Note: Yprovider microservice accepts only raw token, which is not recommended
return settings.Y_PROVIDER_API_KEY
def convert_markdown(self, text):
"""Convert a Markdown text into our internal format using an external microservice."""
if not text:
raise ValidationError("Input text cannot be empty")
try:
response = requests.post(
f"{settings.Y_PROVIDER_API_BASE_URL}{settings.CONVERSION_API_ENDPOINT}/",
json={
"content": text,
},
headers={
"Authorization": self.auth_header,
"Content-Type": "application/json",
},
timeout=settings.CONVERSION_API_TIMEOUT,
verify=settings.CONVERSION_API_SECURE,
)
response.raise_for_status()
conversion_response = response.json()
except requests.RequestException as err:
raise ServiceUnavailableError(
"Failed to connect to conversion service",
) from err
except ValueError as err:
raise InvalidResponseError(
"Could not parse conversion service response"
) from err
try:
document_content = conversion_response[
settings.CONVERSION_API_CONTENT_FIELD
]
except KeyError as err:
raise MissingContentError(
f"Response missing required field: {settings.CONVERSION_API_CONTENT_FIELD}"
) from err
return document_content

View File

@@ -1,6 +1,9 @@
"""Unit tests for the Authentication Backends."""
import random
import re
from logging import Logger
from unittest import mock
from django.core.exceptions import SuspiciousOperation
from django.test.utils import override_settings
@@ -62,7 +65,33 @@ def test_authentication_getter_existing_user_via_email(
assert user == db_user
def test_authentication_getter_existing_user_no_fallback_to_email(
def test_authentication_getter_email_none(monkeypatch):
"""
If no user is found with the sub and no email is provided, a new user should be created.
"""
klass = OIDCAuthenticationBackend()
db_user = UserFactory(email=None)
def get_userinfo_mocked(*args):
user_info = {"sub": "123"}
if random.choice([True, False]):
user_info["email"] = None
return user_info
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
# Since the sub and email didn't match, it should create a new user
assert models.User.objects.count() == 2
assert user != db_user
assert user.sub == "123"
def test_authentication_getter_existing_user_no_fallback_to_email_allow_duplicate(
settings, monkeypatch
):
"""
@@ -75,6 +104,7 @@ def test_authentication_getter_existing_user_no_fallback_to_email(
# Set the setting to False
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = False
settings.OIDC_ALLOW_DUPLICATE_EMAILS = True
def get_userinfo_mocked(*args):
return {"sub": "123", "email": db_user.email}
@@ -91,6 +121,39 @@ def test_authentication_getter_existing_user_no_fallback_to_email(
assert user.sub == "123"
def test_authentication_getter_existing_user_no_fallback_to_email_no_duplicate(
settings, monkeypatch
):
"""
When the "OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION" setting is set to False,
the system should not match users by email, even if the email matches.
"""
klass = OIDCAuthenticationBackend()
db_user = UserFactory()
# Set the setting to False
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = False
settings.OIDC_ALLOW_DUPLICATE_EMAILS = False
def get_userinfo_mocked(*args):
return {"sub": "123", "email": db_user.email}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with pytest.raises(
SuspiciousOperation,
match=(
"We couldn't find a user with this sub but the email is already associated "
"with a registered user."
),
):
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
# Since the sub doesn't match, it should not create a new user
assert models.User.objects.count() == 1
def test_authentication_getter_existing_user_with_email(
django_assert_num_queries, monkeypatch
):
@@ -128,11 +191,12 @@ def test_authentication_getter_existing_user_with_email(
("Jack", "Duy", "jack.duy@example.com"),
],
)
def test_authentication_getter_existing_user_change_fields(
def test_authentication_getter_existing_user_change_fields_sub(
first_name, last_name, email, django_assert_num_queries, monkeypatch
):
"""
It should update the email or name fields on the user when they change.
It should update the email or name fields on the user when they change
and the user was identified by its "sub".
"""
klass = OIDCAuthenticationBackend()
user = UserFactory(
@@ -162,6 +226,48 @@ def test_authentication_getter_existing_user_change_fields(
assert user.short_name == first_name
@pytest.mark.parametrize(
"first_name, last_name, email",
[
("Jack", "Doe", "john.doe@example.com"),
("John", "Duy", "john.doe@example.com"),
],
)
def test_authentication_getter_existing_user_change_fields_email(
first_name, last_name, email, django_assert_num_queries, monkeypatch
):
"""
It should update the name fields on the user when they change
and the user was identified by its "email" as fallback.
"""
klass = OIDCAuthenticationBackend()
user = UserFactory(
full_name="John Doe", short_name="John", email="john.doe@example.com"
)
def get_userinfo_mocked(*args):
return {
"sub": "123",
"email": user.email,
"first_name": first_name,
"last_name": last_name,
}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
# One and only one additional update query when a field has changed
with django_assert_num_queries(3):
authenticated_user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user == authenticated_user
user.refresh_from_db()
assert user.email == email
assert user.full_name == f"{first_name:s} {last_name:s}"
assert user.short_name == first_name
def test_authentication_getter_new_user_no_email(monkeypatch):
"""
If no user matches the user's info sub, a user should be created.
@@ -213,29 +319,6 @@ def test_authentication_getter_new_user_with_email(monkeypatch):
assert models.User.objects.count() == 1
def test_authentication_getter_invalid_token(django_assert_num_queries, monkeypatch):
"""The user's info doesn't contain a sub."""
klass = OIDCAuthenticationBackend()
def get_userinfo_mocked(*args):
return {
"test": "123",
}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with (
django_assert_num_queries(0),
pytest.raises(
SuspiciousOperation,
match="User info contained no recognizable user identification",
),
):
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
assert models.User.objects.exists() is False
@override_settings(OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo")
@responses.activate
def test_authentication_get_userinfo_json_response():
@@ -341,7 +424,7 @@ def test_authentication_getter_existing_disabled_user_via_email(
django_assert_num_queries, monkeypatch
):
"""
If an existing user does not matches the sub but matches the email and is disabled,
If an existing user does not match the sub but matches the email and is disabled,
an error should be raised and a user should not be created.
"""
@@ -365,3 +448,102 @@ def test_authentication_getter_existing_disabled_user_via_email(
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
assert models.User.objects.count() == 1
# Essential claims
def test_authentication_verify_claims_default(django_assert_num_queries, monkeypatch):
"""The sub claim should be mandatory by default."""
klass = OIDCAuthenticationBackend()
def get_userinfo_mocked(*args):
return {
"test": "123",
}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with (
django_assert_num_queries(0),
pytest.raises(
KeyError,
match="sub",
),
):
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
assert models.User.objects.exists() is False
@pytest.mark.parametrize(
"essential_claims, missing_claims",
[
(["email", "sub"], ["email"]),
(["Email", "sub"], ["Email"]), # Case sensitivity
],
)
@override_settings(OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo")
@mock.patch.object(Logger, "error")
def test_authentication_verify_claims_essential_missing(
mock_logger,
essential_claims,
missing_claims,
django_assert_num_queries,
monkeypatch,
):
"""Ensure SuspiciousOperation is raised if essential claims are missing."""
klass = OIDCAuthenticationBackend()
def get_userinfo_mocked(*args):
return {
"sub": "123",
"last_name": "Doe",
}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with (
django_assert_num_queries(0),
pytest.raises(
SuspiciousOperation,
match="Claims verification failed",
),
override_settings(USER_OIDC_ESSENTIAL_CLAIMS=essential_claims),
):
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
assert models.User.objects.exists() is False
mock_logger.assert_called_once_with("Missing essential claims: %s", missing_claims)
@override_settings(
OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo",
USER_OIDC_ESSENTIAL_CLAIMS=["email", "last_name"],
)
def test_authentication_verify_claims_success(django_assert_num_queries, monkeypatch):
"""Ensure user is authenticated when all essential claims are present."""
klass = OIDCAuthenticationBackend()
def get_userinfo_mocked(*args):
return {
"email": "john.doe@example.com",
"last_name": "Doe",
"sub": "123",
}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with django_assert_num_queries(6):
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert models.User.objects.filter(id=user.id).exists()
assert user.sub == "123"
assert user.full_name == "Doe"
assert user.short_name is None
assert user.email == "john.doe@example.com"

View File

@@ -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"

View File

@@ -11,6 +11,9 @@ from rest_framework.test import APIClient
from core import factories, models
from core.api import serializers
from core.tests.conftest import TEAM, USER, VIA
from core.tests.test_services_collaboration_services import ( # pylint: disable=unused-import
mock_reset_connections,
)
pytestmark = pytest.mark.django_db
@@ -316,7 +319,11 @@ def test_api_document_accesses_update_authenticated_reader_or_editor(
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_update_administrator_except_owner(via, mock_user_teams):
def test_api_document_accesses_update_administrator_except_owner(
via,
mock_user_teams,
mock_reset_connections, # pylint: disable=redefined-outer-name
):
"""
A user who is a direct administrator in a document should be allowed to update a user
access for this document, as long as they don't try to set the role to owner.
@@ -351,18 +358,21 @@ def test_api_document_accesses_update_administrator_except_owner(via, mock_user_
for field, value in new_values.items():
new_data = {**old_values, field: value}
response = client.put(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
data=new_data,
format="json",
)
if (
new_data["role"] == old_values["role"]
): # we are not really updating the role
if new_data["role"] == old_values["role"]:
response = client.put(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
data=new_data,
format="json",
)
assert response.status_code == 403
else:
assert response.status_code == 200
with mock_reset_connections(document.id, str(access.user_id)):
response = client.put(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
data=new_data,
format="json",
)
assert response.status_code == 200
access.refresh_from_db()
updated_values = serializers.DocumentAccessSerializer(instance=access).data
@@ -420,7 +430,11 @@ def test_api_document_accesses_update_administrator_from_owner(via, mock_user_te
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_update_administrator_to_owner(via, mock_user_teams):
def test_api_document_accesses_update_administrator_to_owner(
via,
mock_user_teams,
mock_reset_connections, # pylint: disable=redefined-outer-name
):
"""
A user who is an administrator in a document, should not be allowed to update
the user access of another user to grant document ownership.
@@ -457,16 +471,23 @@ def test_api_document_accesses_update_administrator_to_owner(via, mock_user_team
for field, value in new_values.items():
new_data = {**old_values, field: value}
response = client.put(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
data=new_data,
format="json",
)
# We are not allowed or not really updating the role
if field == "role" or new_data["role"] == old_values["role"]:
response = client.put(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
data=new_data,
format="json",
)
assert response.status_code == 403
else:
assert response.status_code == 200
with mock_reset_connections(document.id, str(access.user_id)):
response = client.put(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
data=new_data,
format="json",
)
assert response.status_code == 200
access.refresh_from_db()
updated_values = serializers.DocumentAccessSerializer(instance=access).data
@@ -474,7 +495,11 @@ def test_api_document_accesses_update_administrator_to_owner(via, mock_user_team
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_update_owner(via, mock_user_teams):
def test_api_document_accesses_update_owner(
via,
mock_user_teams,
mock_reset_connections, # pylint: disable=redefined-outer-name
):
"""
A user who is an owner in a document should be allowed to update
a user access for this document whatever the role.
@@ -507,18 +532,24 @@ def test_api_document_accesses_update_owner(via, mock_user_teams):
for field, value in new_values.items():
new_data = {**old_values, field: value}
response = client.put(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
data=new_data,
format="json",
)
if (
new_data["role"] == old_values["role"]
): # we are not really updating the role
response = client.put(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
data=new_data,
format="json",
)
assert response.status_code == 403
else:
assert response.status_code == 200
with mock_reset_connections(document.id, str(access.user_id)):
response = client.put(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
data=new_data,
format="json",
)
assert response.status_code == 200
access.refresh_from_db()
updated_values = serializers.DocumentAccessSerializer(instance=access).data
@@ -530,7 +561,11 @@ def test_api_document_accesses_update_owner(via, mock_user_teams):
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_update_owner_self(via, mock_user_teams):
def test_api_document_accesses_update_owner_self(
via,
mock_user_teams,
mock_reset_connections, # pylint: disable=redefined-outer-name
):
"""
A user who is owner of a document should be allowed to update
their own user access provided there are other owners in the document.
@@ -568,21 +603,23 @@ def test_api_document_accesses_update_owner_self(via, mock_user_teams):
# Add another owner and it should now work
factories.UserDocumentAccessFactory(document=document, role="owner")
response = client.put(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
data={
**old_values,
"role": new_role,
"user_id": old_values.get("user", {}).get("id")
if old_values.get("user") is not None
else None,
},
format="json",
)
user_id = str(access.user_id) if via == USER else None
with mock_reset_connections(document.id, user_id):
response = client.put(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
data={
**old_values,
"role": new_role,
"user_id": old_values.get("user", {}).get("id")
if old_values.get("user") is not None
else None,
},
format="json",
)
assert response.status_code == 200
access.refresh_from_db()
assert access.role == new_role
assert response.status_code == 200
access.refresh_from_db()
assert access.role == new_role
# Delete
@@ -656,7 +693,9 @@ def test_api_document_accesses_delete_reader_or_editor(via, role, mock_user_team
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_delete_administrators_except_owners(
via, mock_user_teams
via,
mock_user_teams,
mock_reset_connections, # pylint: disable=redefined-outer-name
):
"""
Users who are administrators in a document should be allowed to delete an access
@@ -685,12 +724,13 @@ def test_api_document_accesses_delete_administrators_except_owners(
assert models.DocumentAccess.objects.count() == 2
assert models.DocumentAccess.objects.filter(user=access.user).exists()
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
)
with mock_reset_connections(document.id, str(access.user_id)):
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 204
assert models.DocumentAccess.objects.count() == 1
assert response.status_code == 204
assert models.DocumentAccess.objects.count() == 1
@pytest.mark.parametrize("via", VIA)
@@ -729,7 +769,11 @@ def test_api_document_accesses_delete_administrator_on_owners(via, mock_user_tea
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_delete_owners(via, mock_user_teams):
def test_api_document_accesses_delete_owners(
via,
mock_user_teams,
mock_reset_connections, # pylint: disable=redefined-outer-name
):
"""
Users should be able to delete the document access of another user
for a document of which they are owner.
@@ -753,12 +797,13 @@ def test_api_document_accesses_delete_owners(via, mock_user_teams):
assert models.DocumentAccess.objects.count() == 2
assert models.DocumentAccess.objects.filter(user=access.user).exists()
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
)
with mock_reset_connections(document.id, str(access.user_id)):
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 204
assert models.DocumentAccess.objects.count() == 1
assert response.status_code == 204
assert models.DocumentAccess.objects.count() == 1
@pytest.mark.parametrize("via", VIA)

View File

@@ -171,10 +171,11 @@ def test_api_document_accesses_create_authenticated_administrator(via, mock_user
email = mail.outbox[0]
assert email.to == [other_user["email"]]
email_content = " ".join(email.body.split())
assert f"{user.full_name} shared a document with you!" in email_content
assert (
f"{user.full_name} shared a document with you: {document.title}"
in email_content
)
f"{user.full_name} ({user.email}) invited you with the role &quot;{role}&quot; "
f"on the following document: {document.title}"
) in email_content
assert "docs/" + str(document.id) + "/" in email_content
@@ -228,8 +229,9 @@ def test_api_document_accesses_create_authenticated_owner(via, mock_user_teams):
email = mail.outbox[0]
assert email.to == [other_user["email"]]
email_content = " ".join(email.body.split())
assert f"{user.full_name} shared a document with you!" in email_content
assert (
f"{user.full_name} shared a document with you: {document.title}"
in email_content
)
f"{user.full_name} ({user.email}) invited you with the role &quot;{role}&quot; "
f"on the following document: {document.title}"
) in email_content
assert "docs/" + str(document.id) + "/" in email_content

View File

@@ -7,6 +7,7 @@ from datetime import timedelta
from unittest import mock
from django.core import mail
from django.test import override_settings
from django.utils import timezone
import pytest
@@ -339,6 +340,7 @@ def test_api_document_invitations_create_authenticated_outsider():
assert response.status_code == 403
@override_settings(EMAIL_BRAND_NAME="My brand name", EMAIL_LOGO_IMG="my-img.jpg")
@pytest.mark.parametrize(
"inviting,invited,response_code",
(
@@ -402,10 +404,13 @@ def test_api_document_invitations_create_privileged_members(
email = mail.outbox[0]
assert email.to == ["guest@example.com"]
email_content = " ".join(email.body.split())
assert f"{user.full_name} shared a document with you!" in email_content
assert (
f"{user.full_name} shared a document with you: {document.title}"
in email_content
)
f"{user.full_name} ({user.email}) invited you with the role &quot;{invited}&quot; "
f"on the following document: {document.title}"
) in email_content
assert "My brand name" in email_content
assert "my-img.jpg" in email_content
else:
assert models.Invitation.objects.exists() is False
@@ -452,10 +457,7 @@ def test_api_document_invitations_create_email_from_content_language():
assert email.to == ["guest@example.com"]
email_content = " ".join(email.body.split())
assert (
f"{user.full_name} a partagé un document avec vous: {document.title}"
in email_content
)
assert f"{user.full_name} a partagé un document avec vous!" in email_content
def test_api_document_invitations_create_email_from_content_language_not_supported():
@@ -494,10 +496,7 @@ def test_api_document_invitations_create_email_from_content_language_not_support
assert email.to == ["guest@example.com"]
email_content = " ".join(email.body.split())
assert (
f"{user.full_name} shared a document with you: {document.title}"
in email_content
)
assert f"{user.full_name} shared a document with you!" in email_content
def test_api_document_invitations_create_email_full_name_empty():
@@ -535,10 +534,10 @@ def test_api_document_invitations_create_email_full_name_empty():
assert email.to == ["guest@example.com"]
email_content = " ".join(email.body.split())
assert f"{user.email} shared a document with you: {document.title}" in email_content
assert f"{user.email} shared a document with you!" in email_content
assert (
f'{user.email} invited you with the role "reader" on the '
f"following document : {document.title}" in email_content
f"{user.email.capitalize()} invited you with the role &quot;reader&quot; on the "
f"following document: {document.title}" in email_content
)

View File

@@ -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"

View File

@@ -47,6 +47,7 @@ def test_api_documents_create_authenticated_success():
assert response.status_code == 201
document = Document.objects.get()
assert document.title == "my document"
assert document.link_reach == "restricted"
assert document.accesses.filter(role="owner", user=user).exists()

View File

@@ -0,0 +1,575 @@
"""
Tests for Documents API endpoint in impress's core app: create
"""
# pylint: disable=W0621
from unittest.mock import patch
from django.core import mail
from django.test import override_settings
import pytest
from rest_framework.test import APIClient
from core import factories
from core.api.serializers import ServerCreateDocumentSerializer
from core.models import Document, Invitation, User
from core.services.converter_services import ConversionError, YdocConverter
pytestmark = pytest.mark.django_db
@pytest.fixture
def mock_convert_md():
"""Mock YdocConverter.convert_markdown to return a converted content."""
with patch.object(
YdocConverter,
"convert_markdown",
return_value="Converted document content",
) as mock:
yield mock
def test_api_documents_create_for_owner_missing_token():
"""Requests with no token should not be allowed to create documents for owner."""
data = {
"title": "My Document",
"content": "Document content",
"sub": "123",
"email": "john.doe@example.com",
}
response = APIClient().post(
"/api/v1.0/documents/create-for-owner/", data, format="json"
)
assert response.status_code == 401
assert not Document.objects.exists()
@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"])
def test_api_documents_create_for_owner_invalid_token():
"""Requests with an invalid token should not be allowed to create documents for owner."""
data = {
"title": "My Document",
"content": "Document content",
"sub": "123",
"email": "john.doe@example.com",
"language": "fr",
}
response = APIClient().post(
"/api/v1.0/documents/create-for-owner/",
data,
format="json",
HTTP_AUTHORIZATION="Bearer InvalidToken",
)
assert response.status_code == 401
assert not Document.objects.exists()
def test_api_documents_create_for_owner_authenticated_forbidden():
"""
Authenticated users should not be allowed to call create documents on behalf of other users.
This API endpoint is reserved for server-to-server calls.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
data = {
"title": "My Document",
"content": "Document content",
"sub": "123",
"email": "john.doe@example.com",
}
response = client.post(
"/api/v1.0/documents/create-for-owner/",
data,
format="json",
)
assert response.status_code == 401
assert not Document.objects.exists()
@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"])
def test_api_documents_create_for_owner_missing_sub():
"""Requests with no sub should not be allowed to create documents for owner."""
data = {
"title": "My Document",
"content": "Document content",
"email": "john.doe@example.com",
}
response = APIClient().post(
"/api/v1.0/documents/create-for-owner/",
data,
format="json",
HTTP_AUTHORIZATION="Bearer DummyToken",
)
assert response.status_code == 400
assert not Document.objects.exists()
assert response.json() == {"sub": ["This field is required."]}
@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"])
def test_api_documents_create_for_owner_missing_email():
"""Requests with no email should not be allowed to create documents for owner."""
data = {
"title": "My Document",
"content": "Document content",
"sub": "123",
}
response = APIClient().post(
"/api/v1.0/documents/create-for-owner/",
data,
format="json",
HTTP_AUTHORIZATION="Bearer DummyToken",
)
assert response.status_code == 400
assert not Document.objects.exists()
assert response.json() == {"email": ["This field is required."]}
@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"])
def test_api_documents_create_for_owner_invalid_sub():
"""Requests with an invalid sub should not be allowed to create documents for owner."""
data = {
"title": "My Document",
"content": "Document content",
"sub": "123!!",
"email": "john.doe@example.com",
}
response = APIClient().post(
"/api/v1.0/documents/create-for-owner/",
data,
format="json",
HTTP_AUTHORIZATION="Bearer DummyToken",
)
assert response.status_code == 400
assert not Document.objects.exists()
assert response.json() == {
"sub": [
"Enter a valid sub. This value may contain only letters, "
"numbers, and @/./+/-/_/: characters."
]
}
@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"])
def test_api_documents_create_for_owner_existing(mock_convert_md):
"""
It should be possible to create a document on behalf of a pre-existing user
by passing their sub and email.
"""
user = factories.UserFactory(language="en-us")
data = {
"title": "My Document",
"content": "Document content",
"sub": str(user.sub),
"email": "irrelevant@example.com", # Should be ignored since the user already exists
}
response = APIClient().post(
"/api/v1.0/documents/create-for-owner/",
data,
format="json",
HTTP_AUTHORIZATION="Bearer DummyToken",
)
assert response.status_code == 201
mock_convert_md.assert_called_once_with("Document content")
document = Document.objects.get()
assert response.json() == {"id": str(document.id)}
assert document.title == "My Document"
assert document.content == "Converted document content"
assert document.creator == user
assert document.accesses.filter(user=user, role="owner").exists()
assert Invitation.objects.exists() is False
assert len(mail.outbox) == 1
email = mail.outbox[0]
assert email.to == [user.email]
assert email.subject == "A new document was created on your behalf!"
email_content = " ".join(email.body.split())
assert "A new document was created on your behalf!" in email_content
assert (
"You have been granted ownership of a new document: My Document"
) in email_content
@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"])
def test_api_documents_create_for_owner_new_user(mock_convert_md):
"""
It should be possible to create a document on behalf of new users by
passing their unknown sub and email address.
"""
data = {
"title": "My Document",
"content": "Document content",
"sub": "123",
"email": "john.doe@example.com", # Should be used to create a new user
}
response = APIClient().post(
"/api/v1.0/documents/create-for-owner/",
data,
format="json",
HTTP_AUTHORIZATION="Bearer DummyToken",
)
assert response.status_code == 201
mock_convert_md.assert_called_once_with("Document content")
document = Document.objects.get()
assert response.json() == {"id": str(document.id)}
assert document.title == "My Document"
assert document.content == "Converted document content"
assert document.creator is None
assert document.accesses.exists() is False
invitation = Invitation.objects.get()
assert invitation.email == "john.doe@example.com"
assert invitation.role == "owner"
assert len(mail.outbox) == 1
email = mail.outbox[0]
assert email.to == ["john.doe@example.com"]
assert email.subject == "A new document was created on your behalf!"
email_content = " ".join(email.body.split())
assert "A new document was created on your behalf!" in email_content
assert (
"You have been granted ownership of a new document: My Document"
) in email_content
# The creator field on the document should be set when the user is created
user = User.objects.create(email="john.doe@example.com", password="!")
document.refresh_from_db()
assert document.creator == user
@override_settings(
SERVER_TO_SERVER_API_TOKENS=["DummyToken"],
OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION=True,
)
def test_api_documents_create_for_owner_existing_user_email_no_sub_with_fallback(
mock_convert_md,
):
"""
It should be possible to create a document on behalf of a pre-existing user for
who the sub was not found if the settings allow it. This edge case should not
happen in a healthy OIDC federation but can be usefull if an OIDC provider modifies
users sub on each login for example...
"""
user = factories.UserFactory(language="en-us")
data = {
"title": "My Document",
"content": "Document content",
"sub": "123",
"email": user.email,
}
response = APIClient().post(
"/api/v1.0/documents/create-for-owner/",
data,
format="json",
HTTP_AUTHORIZATION="Bearer DummyToken",
)
assert response.status_code == 201
mock_convert_md.assert_called_once_with("Document content")
document = Document.objects.get()
assert response.json() == {"id": str(document.id)}
assert document.title == "My Document"
assert document.content == "Converted document content"
assert document.creator == user
assert document.accesses.filter(user=user, role="owner").exists()
assert Invitation.objects.exists() is False
assert len(mail.outbox) == 1
email = mail.outbox[0]
assert email.to == [user.email]
assert email.subject == "A new document was created on your behalf!"
email_content = " ".join(email.body.split())
assert "A new document was created on your behalf!" in email_content
assert (
"You have been granted ownership of a new document: My Document"
) in email_content
@override_settings(
SERVER_TO_SERVER_API_TOKENS=["DummyToken"],
OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION=False,
OIDC_ALLOW_DUPLICATE_EMAILS=False,
)
def test_api_documents_create_for_owner_existing_user_email_no_sub_no_fallback(
mock_convert_md,
):
"""
When a user does not match an existing sub and fallback to matching on email is
not allowed in settings, it should raise an error if the email is already used by
a registered user and duplicate emails are not allowed.
"""
user = factories.UserFactory()
data = {
"title": "My Document",
"content": "Document content",
"sub": "123",
"email": user.email,
}
response = APIClient().post(
"/api/v1.0/documents/create-for-owner/",
data,
format="json",
HTTP_AUTHORIZATION="Bearer DummyToken",
)
assert response.status_code == 400
assert response.json() == {
"email": [
(
"We couldn't find a user with this sub but the email is already "
"associated with a registered user."
)
]
}
assert mock_convert_md.called is False
assert Document.objects.exists() is False
assert Invitation.objects.exists() is False
assert len(mail.outbox) == 0
@override_settings(
SERVER_TO_SERVER_API_TOKENS=["DummyToken"],
OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION=False,
OIDC_ALLOW_DUPLICATE_EMAILS=True,
)
def test_api_documents_create_for_owner_new_user_no_sub_no_fallback_allow_duplicate(
mock_convert_md,
):
"""
When a user does not match an existing sub and fallback to matching on email is
not allowed in settings, it should be possible to create a new user with the same
email as an existing user if the settings allow it (identification is still done
via the sub in this case).
"""
user = factories.UserFactory()
data = {
"title": "My Document",
"content": "Document content",
"sub": "123",
"email": user.email,
}
response = APIClient().post(
"/api/v1.0/documents/create-for-owner/",
data,
format="json",
HTTP_AUTHORIZATION="Bearer DummyToken",
)
assert response.status_code == 201
mock_convert_md.assert_called_once_with("Document content")
document = Document.objects.get()
assert response.json() == {"id": str(document.id)}
assert document.title == "My Document"
assert document.content == "Converted document content"
assert document.creator is None
assert document.accesses.exists() is False
invitation = Invitation.objects.get()
assert invitation.email == user.email
assert invitation.role == "owner"
assert len(mail.outbox) == 1
email = mail.outbox[0]
assert email.to == [user.email]
assert email.subject == "A new document was created on your behalf!"
email_content = " ".join(email.body.split())
assert "A new document was created on your behalf!" in email_content
assert (
"You have been granted ownership of a new document: My Document"
) in email_content
# The creator field on the document should be set when the user is created
user = User.objects.create(email=user.email, password="!")
document.refresh_from_db()
assert document.creator == user
@patch.object(ServerCreateDocumentSerializer, "_send_email_notification")
@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"], LANGUAGE_CODE="de-de")
def test_api_documents_create_for_owner_with_default_language(
mock_send, mock_convert_md
):
"""The default language from settings should apply by default."""
data = {
"title": "My Document",
"content": "Document content",
"sub": "123",
"email": "john.doe@example.com",
}
response = APIClient().post(
"/api/v1.0/documents/create-for-owner/",
data,
format="json",
HTTP_AUTHORIZATION="Bearer DummyToken",
)
assert response.status_code == 201
mock_convert_md.assert_called_once_with("Document content")
assert mock_send.call_args[0][3] == "de-de"
@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"])
def test_api_documents_create_for_owner_with_custom_language(mock_convert_md):
"""
Test creating a document with a specific language.
Useful if the remote server knows the user's language.
"""
data = {
"title": "My Document",
"content": "Document content",
"sub": "123",
"email": "john.doe@example.com",
"language": "fr-fr",
}
response = APIClient().post(
"/api/v1.0/documents/create-for-owner/",
data,
format="json",
HTTP_AUTHORIZATION="Bearer DummyToken",
)
assert response.status_code == 201
mock_convert_md.assert_called_once_with("Document content")
assert len(mail.outbox) == 1
email = mail.outbox[0]
assert email.to == ["john.doe@example.com"]
assert email.subject == "Un nouveau document a été créé pour vous !"
email_content = " ".join(email.body.split())
assert "Un nouveau document a été créé pour vous !" in email_content
assert (
"Vous avez été déclaré propriétaire d&#x27;un nouveau document : My Document"
) in email_content
@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"])
def test_api_documents_create_for_owner_with_custom_subject_and_message(
mock_convert_md,
):
"""It should be possible to customize the subject and message of the invitation email."""
data = {
"title": "My Document",
"content": "Document content",
"sub": "123",
"email": "john.doe@example.com",
"message": "mon message spécial",
"subject": "mon sujet spécial !",
}
response = APIClient().post(
"/api/v1.0/documents/create-for-owner/",
data,
format="json",
HTTP_AUTHORIZATION="Bearer DummyToken",
)
assert response.status_code == 201
mock_convert_md.assert_called_once_with("Document content")
assert len(mail.outbox) == 1
email = mail.outbox[0]
assert email.to == ["john.doe@example.com"]
assert email.subject == "Mon sujet spécial !"
email_content = " ".join(email.body.split())
assert "Mon sujet spécial !" in email_content
assert "Mon message spécial" in email_content
@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"])
def test_api_documents_create_for_owner_with_converter_exception(
mock_convert_md,
):
"""In case of converter error, a 400 error should be raised."""
mock_convert_md.side_effect = ConversionError("Conversion failed")
data = {
"title": "My Document",
"content": "Document content",
"sub": "123",
"email": "john.doe@example.com",
"message": "mon message spécial",
"subject": "mon sujet spécial !",
}
response = APIClient().post(
"/api/v1.0/documents/create-for-owner/",
data,
format="json",
HTTP_AUTHORIZATION="Bearer DummyToken",
)
mock_convert_md.assert_called_once_with("Document content")
assert response.status_code == 400
assert response.json() == {"content": ["Could not convert content"]}
@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"])
def test_api_documents_create_for_owner_with_empty_content():
"""The content should not be empty or a 400 error should be raised."""
data = {
"title": "My Document",
"content": " ",
"sub": "123",
"email": "john.doe@example.com",
}
response = APIClient().post(
"/api/v1.0/documents/create-for-owner/",
data,
format="json",
HTTP_AUTHORIZATION="Bearer DummyToken",
)
assert response.status_code == 400
assert response.json() == {
"content": [
"This field may not be blank.",
],
}

View File

@@ -0,0 +1,308 @@
"""Test favorite document API endpoint for users in impress's core app."""
import pytest
from rest_framework.test import APIClient
from core import factories, models
pytestmark = pytest.mark.django_db
@pytest.mark.parametrize(
"reach",
[
"restricted",
"authenticated",
"public",
],
)
@pytest.mark.parametrize("method", ["post", "delete"])
def test_api_document_favorite_anonymous_user(method, reach):
"""Anonymous users should not be able to mark/unmark documents as favorites."""
document = factories.DocumentFactory(link_reach=reach)
response = getattr(APIClient(), method)(
f"/api/v1.0/documents/{document.id!s}/favorite/"
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
# Verify in database
assert models.DocumentFavorite.objects.exists() is False
@pytest.mark.parametrize(
"reach, has_role",
[
["restricted", True],
["authenticated", False],
["authenticated", True],
["public", False],
["public", True],
],
)
def test_api_document_favorite_authenticated_post_allowed(reach, has_role):
"""Authenticated users should be able to mark a document as favorite using POST."""
user = factories.UserFactory()
document = factories.DocumentFactory(link_reach=reach)
client = APIClient()
client.force_login(user)
if has_role:
models.DocumentAccess.objects.create(document=document, user=user)
# Mark as favorite
response = client.post(f"/api/v1.0/documents/{document.id!s}/favorite/")
assert response.status_code == 201
assert response.json() == {"detail": "Document marked as favorite"}
# Verify in database
assert models.DocumentFavorite.objects.filter(document=document, user=user).exists()
# Verify document format
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
assert response.json()["is_favorite"] is True
def test_api_document_favorite_authenticated_post_forbidden():
"""Authenticated users should be able to mark a document as favorite using POST."""
user = factories.UserFactory()
document = factories.DocumentFactory(link_reach="restricted")
client = APIClient()
client.force_login(user)
# Try marking as favorite
response = client.post(f"/api/v1.0/documents/{document.id!s}/favorite/")
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
# Verify in database
assert (
models.DocumentFavorite.objects.filter(document=document, user=user).exists()
is False
)
@pytest.mark.parametrize(
"reach, has_role",
[
["restricted", True],
["authenticated", False],
["authenticated", True],
["public", False],
["public", True],
],
)
def test_api_document_favorite_authenticated_post_already_favorited_allowed(
reach, has_role
):
"""POST should not create duplicate favorites if already marked."""
user = factories.UserFactory()
document = factories.DocumentFactory(link_reach=reach, favorited_by=[user])
client = APIClient()
client.force_login(user)
if has_role:
models.DocumentAccess.objects.create(document=document, user=user)
# Try to mark as favorite again
response = client.post(f"/api/v1.0/documents/{document.id!s}/favorite/")
assert response.status_code == 200
assert response.json() == {"detail": "Document already marked as favorite"}
# Verify in database
assert models.DocumentFavorite.objects.filter(document=document, user=user).exists()
# Verify document format
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
assert response.json()["is_favorite"] is True
def test_api_document_favorite_authenticated_post_already_favorited_forbidden():
"""POST should not create duplicate favorites if already marked."""
user = factories.UserFactory()
document = factories.DocumentFactory(link_reach="restricted", favorited_by=[user])
client = APIClient()
client.force_login(user)
# Try to mark as favorite again
response = client.post(f"/api/v1.0/documents/{document.id!s}/favorite/")
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
# Verify in database
assert models.DocumentFavorite.objects.filter(document=document, user=user).exists()
@pytest.mark.parametrize(
"reach, has_role",
[
["restricted", True],
["authenticated", False],
["authenticated", True],
["public", False],
["public", True],
],
)
def test_api_document_favorite_authenticated_delete_allowed(reach, has_role):
"""Authenticated users should be able to unmark a document as favorite using DELETE."""
user = factories.UserFactory()
document = factories.DocumentFactory(link_reach=reach, favorited_by=[user])
client = APIClient()
client.force_login(user)
if has_role:
models.DocumentAccess.objects.create(document=document, user=user)
# Unmark as favorite
response = client.delete(f"/api/v1.0/documents/{document.id!s}/favorite/")
assert response.status_code == 204
# Verify in database
assert (
models.DocumentFavorite.objects.filter(document=document, user=user).exists()
is False
)
# Verify document format
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
assert response.json()["is_favorite"] is False
def test_api_document_favorite_authenticated_delete_forbidden():
"""Authenticated users should be able to unmark a document as favorite using DELETE."""
user = factories.UserFactory()
document = factories.DocumentFactory(link_reach="restricted", favorited_by=[user])
client = APIClient()
client.force_login(user)
# Unmark as favorite
response = client.delete(f"/api/v1.0/documents/{document.id!s}/favorite/")
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
# Verify in database
assert (
models.DocumentFavorite.objects.filter(document=document, user=user).exists()
is True
)
@pytest.mark.parametrize(
"reach, has_role",
[
["restricted", True],
["authenticated", False],
["authenticated", True],
["public", False],
["public", True],
],
)
def test_api_document_favorite_authenticated_delete_not_favorited_allowed(
reach, has_role
):
"""DELETE should be idempotent if the document is not marked as favorite."""
user = factories.UserFactory()
document = factories.DocumentFactory(link_reach=reach)
client = APIClient()
client.force_login(user)
if has_role:
models.DocumentAccess.objects.create(document=document, user=user)
# Try to unmark as favorite when no favorite entry exists
response = client.delete(f"/api/v1.0/documents/{document.id!s}/favorite/")
assert response.status_code == 200
assert response.json() == {"detail": "Document was already not marked as favorite"}
# Verify in database
assert (
models.DocumentFavorite.objects.filter(document=document, user=user).exists()
is False
)
# Verify document format
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
assert response.json()["is_favorite"] is False
def test_api_document_favorite_authenticated_delete_not_favorited_forbidden():
"""DELETE should be idempotent if the document is not marked as favorite."""
user = factories.UserFactory()
document = factories.DocumentFactory(link_reach="restricted")
client = APIClient()
client.force_login(user)
# Try to unmark as favorite when no favorite entry exists
response = client.delete(f"/api/v1.0/documents/{document.id!s}/favorite/")
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
# Verify in database
assert (
models.DocumentFavorite.objects.filter(document=document, user=user).exists()
is False
)
@pytest.mark.parametrize(
"reach, has_role",
[
["restricted", True],
["authenticated", False],
["authenticated", True],
["public", False],
["public", True],
],
)
def test_api_document_favorite_authenticated_post_unmark_then_mark_again_allowed(
reach, has_role
):
"""A user should be able to mark, unmark, and mark a document again as favorite."""
user = factories.UserFactory()
document = factories.DocumentFactory(link_reach=reach)
client = APIClient()
client.force_login(user)
if has_role:
models.DocumentAccess.objects.create(document=document, user=user)
url = f"/api/v1.0/documents/{document.id!s}/favorite/"
# Mark as favorite
response = client.post(url)
assert response.status_code == 201
# Unmark as favorite
response = client.delete(url)
assert response.status_code == 204
# Mark as favorite again
response = client.post(url)
assert response.status_code == 201
assert response.json() == {"detail": "Document marked as favorite"}
# Verify in database
assert models.DocumentFavorite.objects.filter(document=document, user=user).exists()
# Verify document format
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
assert response.json()["is_favorite"] is True

View File

@@ -6,6 +6,9 @@ from rest_framework.test import APIClient
from core import factories, models
from core.api import serializers
from core.tests.conftest import TEAM, USER, VIA
from core.tests.test_services_collaboration_services import ( # pylint: disable=unused-import
mock_reset_connections,
)
pytestmark = pytest.mark.django_db
@@ -116,7 +119,10 @@ def test_api_documents_link_configuration_update_authenticated_related_forbidden
@pytest.mark.parametrize("role", ["administrator", "owner"])
@pytest.mark.parametrize("via", VIA)
def test_api_documents_link_configuration_update_authenticated_related_success(
via, role, mock_user_teams
via,
role,
mock_user_teams,
mock_reset_connections, # pylint: disable=redefined-outer-name
):
"""
A user who is administrator or owner of a document should be allowed to update
@@ -139,14 +145,16 @@ def test_api_documents_link_configuration_update_authenticated_related_success(
new_document_values = serializers.LinkDocumentSerializer(
instance=factories.DocumentFactory()
).data
response = client.put(
f"/api/v1.0/documents/{document.id!s}/link-configuration/",
new_document_values,
format="json",
)
assert response.status_code == 200
document = models.Document.objects.get(pk=document.pk)
document_values = serializers.LinkDocumentSerializer(instance=document).data
for key, value in document_values.items():
assert value == new_document_values[key]
with mock_reset_connections(document.id):
response = client.put(
f"/api/v1.0/documents/{document.id!s}/link-configuration/",
new_document_values,
format="json",
)
assert response.status_code == 200
document = models.Document.objects.get(pk=document.pk)
document_values = serializers.LinkDocumentSerializer(instance=document).data
for key, value in document_values.items():
assert value == new_document_values[key]

View File

@@ -3,7 +3,9 @@ Tests for Documents API endpoint in impress's core app: list
"""
import operator
import random
from unittest import mock
from urllib.parse import urlencode
import pytest
from faker import Faker
@@ -32,7 +34,47 @@ def test_api_documents_list_anonymous(reach, role):
assert len(results) == 0
def test_api_documents_list_authenticated_direct():
def test_api_documents_list_format():
"""Validate the format of documents as returned by the list view."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
other_users = factories.UserFactory.create_batch(3)
document = factories.DocumentFactory(
users=[user, *factories.UserFactory.create_batch(2)],
favorited_by=[user, *other_users],
link_traces=other_users,
)
response = client.get("/api/v1.0/documents/")
assert response.status_code == 200
content = response.json()
results = content.pop("results")
assert content == {
"count": 1,
"next": None,
"previous": None,
}
assert len(results) == 1
assert results[0] == {
"id": str(document.id),
"abilities": document.get_abilities(user),
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"is_favorite": True,
"link_reach": document.link_reach,
"link_role": document.link_role,
"nb_accesses": 3,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
}
def test_api_documents_list_authenticated_direct(django_assert_num_queries):
"""
Authenticated users should be able to list documents they are a direct
owner/administrator/member of or documents that have a link reach other
@@ -55,9 +97,8 @@ def test_api_documents_list_authenticated_direct():
expected_ids = {str(document.id) for document in documents}
response = client.get(
"/api/v1.0/documents/",
)
with django_assert_num_queries(3):
response = client.get("/api/v1.0/documents/")
assert response.status_code == 200
results = response.json()["results"]
@@ -66,7 +107,9 @@ def test_api_documents_list_authenticated_direct():
assert expected_ids == results_id
def test_api_documents_list_authenticated_via_team(mock_user_teams):
def test_api_documents_list_authenticated_via_team(
django_assert_num_queries, mock_user_teams
):
"""
Authenticated users should be able to list documents they are a
owner/administrator/member of via a team.
@@ -89,7 +132,8 @@ def test_api_documents_list_authenticated_via_team(mock_user_teams):
expected_ids = {str(document.id) for document in documents_team1 + documents_team2}
response = client.get("/api/v1.0/documents/")
with django_assert_num_queries(3):
response = client.get("/api/v1.0/documents/")
assert response.status_code == 200
results = response.json()["results"]
@@ -98,7 +142,9 @@ def test_api_documents_list_authenticated_via_team(mock_user_teams):
assert expected_ids == results_id
def test_api_documents_list_authenticated_link_reach_restricted():
def test_api_documents_list_authenticated_link_reach_restricted(
django_assert_num_queries,
):
"""
An authenticated user who has link traces to a document that is restricted should not
see it on the list view
@@ -115,9 +161,10 @@ def test_api_documents_list_authenticated_link_reach_restricted():
other_document = factories.DocumentFactory(link_reach="public")
models.LinkTrace.objects.create(document=other_document, user=user)
response = client.get(
"/api/v1.0/documents/",
)
with django_assert_num_queries(3):
response = client.get(
"/api/v1.0/documents/",
)
assert response.status_code == 200
results = response.json()["results"]
@@ -127,7 +174,9 @@ def test_api_documents_list_authenticated_link_reach_restricted():
assert results[0]["id"] == str(other_document.id)
def test_api_documents_list_authenticated_link_reach_public_or_authenticated():
def test_api_documents_list_authenticated_link_reach_public_or_authenticated(
django_assert_num_queries,
):
"""
An authenticated user who has link traces to a document with public or authenticated
link reach should see it on the list view.
@@ -144,9 +193,10 @@ def test_api_documents_list_authenticated_link_reach_public_or_authenticated():
]
expected_ids = {str(document.id) for document in documents}
response = client.get(
"/api/v1.0/documents/",
)
with django_assert_num_queries(3):
response = client.get(
"/api/v1.0/documents/",
)
assert response.status_code == 200
results = response.json()["results"]
@@ -224,6 +274,143 @@ def test_api_documents_list_authenticated_distinct():
assert content["results"][0]["id"] == str(document.id)
def test_api_documents_list_favorites_no_extra_queries(django_assert_num_queries):
"""
Ensure that marking documents as favorite does not generate additional queries
when fetching the document list.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
special_documents = factories.DocumentFactory.create_batch(3, users=[user])
factories.DocumentFactory.create_batch(2, users=[user])
url = "/api/v1.0/documents/"
with django_assert_num_queries(3):
response = client.get(url)
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 5
assert all(result["is_favorite"] is False for result in results)
# Mark documents as favorite and check results again
for document in special_documents:
models.DocumentFavorite.objects.create(document=document, user=user)
with django_assert_num_queries(3):
response = client.get(url)
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 5
# Check if the "is_favorite" annotation is correctly set for the favorited documents
favorited_ids = {str(doc.id) for doc in special_documents}
for result in results:
if result["id"] in favorited_ids:
assert result["is_favorite"] is True
else:
assert result["is_favorite"] is False
def test_api_documents_list_filter_and_access_rights():
"""Filtering on querystring parameters should respect access rights."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
other_user = factories.UserFactory()
def random_favorited_by():
return random.choice([[], [user], [other_user]])
# Documents that should be listed to this user
listed_documents = [
factories.DocumentFactory(
link_reach="public",
link_traces=[user],
favorited_by=random_favorited_by(),
creator=random.choice([user, other_user]),
),
factories.DocumentFactory(
link_reach="authenticated",
link_traces=[user],
favorited_by=random_favorited_by(),
creator=random.choice([user, other_user]),
),
factories.DocumentFactory(
link_reach="restricted",
users=[user],
favorited_by=random_favorited_by(),
creator=random.choice([user, other_user]),
),
]
listed_ids = [str(doc.id) for doc in listed_documents]
word_list = [word for doc in listed_documents for word in doc.title.split(" ")]
# Documents that should not be listed to this user
factories.DocumentFactory(
link_reach="public",
favorited_by=random_favorited_by(),
creator=random.choice([user, other_user]),
)
factories.DocumentFactory(
link_reach="authenticated",
favorited_by=random_favorited_by(),
creator=random.choice([user, other_user]),
)
factories.DocumentFactory(
link_reach="restricted",
favorited_by=random_favorited_by(),
creator=random.choice([user, other_user]),
)
factories.DocumentFactory(
link_reach="restricted",
link_traces=[user],
favorited_by=random_favorited_by(),
creator=random.choice([user, other_user]),
)
filters = {
"link_reach": random.choice([None, *models.LinkReachChoices.values]),
"title": random.choice([None, *word_list]),
"favorite": random.choice([None, True, False]),
"creator": random.choice([None, user, other_user]),
"ordering": random.choice(
[
None,
"created_at",
"-created_at",
"is_favorite",
"-is_favorite",
"nb_accesses",
"-nb_accesses",
"title",
"-title",
"updated_at",
"-updated_at",
]
),
}
query_params = {key: value for key, value in filters.items() if value is not None}
querystring = urlencode(query_params)
response = client.get(f"/api/v1.0/documents/?{querystring:s}")
assert response.status_code == 200
results = response.json()["results"]
# Ensure all documents in results respect expected access rights
for result in results:
assert result["id"] in listed_ids
# Filters: ordering
def test_api_documents_list_ordering_default():
"""Documents should be ordered by descending "updated_at" by default"""
user = factories.UserFactory()
@@ -254,10 +441,14 @@ def test_api_documents_list_ordering_by_fields():
for parameter in [
"created_at",
"-created_at",
"updated_at",
"-updated_at",
"is_favorite",
"-is_favorite",
"nb_accesses",
"-nb_accesses",
"title",
"-title",
"updated_at",
"-updated_at",
]:
is_descending = parameter.startswith("-")
field = parameter.lstrip("-")
@@ -272,3 +463,212 @@ def test_api_documents_list_ordering_by_fields():
compare = operator.ge if is_descending else operator.le
for i in range(4):
assert compare(results[i][field], results[i + 1][field])
# Filters: is_creator_me
def test_api_documents_list_filter_is_creator_me_true():
"""
Authenticated users should be able to filter documents they created.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(3, users=[user], creator=user)
factories.DocumentFactory.create_batch(2, users=[user])
response = client.get("/api/v1.0/documents/?is_creator_me=true")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 3
# Ensure all results are created by the current user
for result in results:
assert result["creator"] == str(user.id)
def test_api_documents_list_filter_is_creator_me_false():
"""
Authenticated users should be able to filter documents created by others.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(3, users=[user], creator=user)
factories.DocumentFactory.create_batch(2, users=[user])
response = client.get("/api/v1.0/documents/?is_creator_me=false")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 2
# Ensure all results are created by other users
for result in results:
assert result["creator"] != str(user.id)
def test_api_documents_list_filter_is_creator_me_invalid():
"""Filtering with an invalid `is_creator_me` value should do nothing."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(3, users=[user], creator=user)
factories.DocumentFactory.create_batch(2, users=[user])
response = client.get("/api/v1.0/documents/?is_creator_me=invalid")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 5
# Filters: is_favorite
def test_api_documents_list_filter_is_favorite_true():
"""
Authenticated users should be able to filter documents they marked as favorite.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(3, users=[user], favorited_by=[user])
factories.DocumentFactory.create_batch(2, users=[user])
response = client.get("/api/v1.0/documents/?is_favorite=true")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 3
# Ensure all results are marked as favorite by the current user
for result in results:
assert result["is_favorite"] is True
def test_api_documents_list_filter_is_favorite_false():
"""
Authenticated users should be able to filter documents they didn't mark as favorite.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(3, users=[user], favorited_by=[user])
factories.DocumentFactory.create_batch(2, users=[user])
response = client.get("/api/v1.0/documents/?is_favorite=false")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 2
# Ensure all results are not marked as favorite by the current user
for result in results:
assert result["is_favorite"] is False
def test_api_documents_list_filter_is_favorite_invalid():
"""Filtering with an invalid `is_favorite` value should do nothing."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(3, users=[user], favorited_by=[user])
factories.DocumentFactory.create_batch(2, users=[user])
response = client.get("/api/v1.0/documents/?is_favorite=invalid")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 5
# Filters: link_reach
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
def test_api_documents_list_filter_link_reach(reach):
"""Authenticated users should be able to filter documents by link reach."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(5, users=[user])
response = client.get(f"/api/v1.0/documents/?link_reach={reach:s}")
assert response.status_code == 200
results = response.json()["results"]
# Ensure all results have the chosen link reach
for result in results:
assert result["link_reach"] == reach
def test_api_documents_list_filter_link_reach_invalid():
"""Filtering with an invalid `link_reach` value should raise an error."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(3, users=[user])
response = client.get("/api/v1.0/documents/?link_reach=invalid")
assert response.status_code == 400
assert response.json() == {
"link_reach": [
"Select a valid choice. invalid is not one of the available choices."
]
}
# Filters: title
@pytest.mark.parametrize(
"query,nb_results",
[
("Project Alpha", 1), # Exact match
("project", 2), # Partial match (case-insensitive)
("Guide", 1), # Word match within a title
("Special", 0), # No match (nonexistent keyword)
("2024", 2), # Match by numeric keyword
("", 5), # Empty string
],
)
def test_api_documents_list_filter_title(query, nb_results):
"""Authenticated users should be able to search documents by their title."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Create documents with predefined titles
titles = [
"Project Alpha Documentation",
"Project Beta Overview",
"User Guide",
"Financial Report 2024",
"Annual Review 2024",
]
for title in titles:
factories.DocumentFactory(title=title, users=[user])
# Perform the search query
response = client.get(f"/api/v1.0/documents/?title={query:s}")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == nb_results
# Ensure all results contain the query in their title
for result in results:
assert query.lower().strip() in result["title"].lower()

View File

@@ -20,7 +20,7 @@ from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
def test_api_documents_retrieve_auth_anonymous_public():
def test_api_documents_media_auth_anonymous_public():
"""Anonymous users should be able to retrieve attachments linked to a public document"""
document = factories.DocumentFactory(link_reach="public")
@@ -36,7 +36,7 @@ def test_api_documents_retrieve_auth_anonymous_public():
original_url = f"http://localhost/media/{key:s}"
response = APIClient().get(
"/api/v1.0/documents/retrieve-auth/", HTTP_X_ORIGINAL_URL=original_url
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 200
@@ -65,7 +65,7 @@ def test_api_documents_retrieve_auth_anonymous_public():
@pytest.mark.parametrize("reach", ["authenticated", "restricted"])
def test_api_documents_retrieve_auth_anonymous_authenticated_or_restricted(reach):
def test_api_documents_media_auth_anonymous_authenticated_or_restricted(reach):
"""
Anonymous users should not be allowed to retrieve attachments linked to a document
with link reach set to authenticated or restricted.
@@ -76,7 +76,7 @@ def test_api_documents_retrieve_auth_anonymous_authenticated_or_restricted(reach
media_url = f"http://localhost/media/{document.pk!s}/attachments/{filename:s}"
response = APIClient().get(
"/api/v1.0/documents/retrieve-auth/", HTTP_X_ORIGINAL_URL=media_url
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=media_url
)
assert response.status_code == 403
@@ -84,7 +84,7 @@ def test_api_documents_retrieve_auth_anonymous_authenticated_or_restricted(reach
@pytest.mark.parametrize("reach", ["public", "authenticated"])
def test_api_documents_retrieve_auth_authenticated_public_or_authenticated(reach):
def test_api_documents_media_auth_authenticated_public_or_authenticated(reach):
"""
Authenticated users who are not related to a document should be able to retrieve
attachments related to a document with public or authenticated link reach.
@@ -107,7 +107,7 @@ def test_api_documents_retrieve_auth_authenticated_public_or_authenticated(reach
original_url = f"http://localhost/media/{key:s}"
response = client.get(
"/api/v1.0/documents/retrieve-auth/", HTTP_X_ORIGINAL_URL=original_url
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 200
@@ -135,7 +135,7 @@ def test_api_documents_retrieve_auth_authenticated_public_or_authenticated(reach
assert response.content.decode("utf-8") == "my prose"
def test_api_documents_retrieve_auth_authenticated_restricted():
def test_api_documents_media_auth_authenticated_restricted():
"""
Authenticated users who are not related to a document should not be allowed to
retrieve attachments linked to a document that is restricted.
@@ -150,7 +150,7 @@ def test_api_documents_retrieve_auth_authenticated_restricted():
media_url = f"http://localhost/media/{document.pk!s}/attachments/{filename:s}"
response = client.get(
"/api/v1.0/documents/retrieve-auth/", HTTP_X_ORIGINAL_URL=media_url
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=media_url
)
assert response.status_code == 403
@@ -158,7 +158,7 @@ def test_api_documents_retrieve_auth_authenticated_restricted():
@pytest.mark.parametrize("via", VIA)
def test_api_documents_retrieve_auth_related(via, mock_user_teams):
def test_api_documents_media_auth_related(via, mock_user_teams):
"""
Users who have a specific access to a document, whatever the role, should be able to
retrieve related attachments.
@@ -186,7 +186,7 @@ def test_api_documents_retrieve_auth_related(via, mock_user_teams):
original_url = f"http://localhost/media/{key:s}"
response = client.get(
"/api/v1.0/documents/retrieve-auth/", HTTP_X_ORIGINAL_URL=original_url
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 200

View File

@@ -21,13 +21,18 @@ def test_api_documents_retrieve_anonymous_public():
assert response.json() == {
"id": str(document.id),
"abilities": {
"accesses_manage": False,
"accesses_view": False,
"ai_transform": document.link_role == "editor",
"ai_translate": document.link_role == "editor",
"attachment_upload": document.link_role == "editor",
"collaboration_auth": True,
"destroy": False,
# Anonymous user can't favorite a document even with read access
"favorite": False,
"invite_owner": False,
"link_configuration": False,
"manage_accesses": False,
"media_auth": True,
"partial_update": document.link_role == "editor",
"retrieve": True,
"update": document.link_role == "editor",
@@ -35,12 +40,14 @@ def test_api_documents_retrieve_anonymous_public():
"versions_list": False,
"versions_retrieve": False,
},
"accesses": [],
"link_reach": "public",
"link_role": document.link_role,
"title": document.title,
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"is_favorite": False,
"link_reach": "public",
"link_role": document.link_role,
"nb_accesses": 0,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
}
@@ -78,13 +85,17 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
assert response.json() == {
"id": str(document.id),
"abilities": {
"accesses_manage": False,
"accesses_view": False,
"ai_transform": document.link_role == "editor",
"ai_translate": document.link_role == "editor",
"attachment_upload": document.link_role == "editor",
"link_configuration": False,
"collaboration_auth": True,
"destroy": False,
"favorite": True,
"invite_owner": False,
"manage_accesses": False,
"media_auth": True,
"link_configuration": False,
"partial_update": document.link_role == "editor",
"retrieve": True,
"update": document.link_role == "editor",
@@ -92,12 +103,14 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
"versions_list": False,
"versions_retrieve": False,
},
"accesses": [],
"link_reach": reach,
"link_role": document.link_role,
"title": document.title,
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"is_favorite": False,
"link_reach": reach,
"link_role": document.link_role,
"nb_accesses": 0,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
}
assert (
@@ -166,43 +179,26 @@ def test_api_documents_retrieve_authenticated_related_direct():
client.force_login(user)
document = factories.DocumentFactory()
access1 = factories.UserDocumentAccessFactory(document=document, user=user)
factories.UserDocumentAccessFactory(document=document, user=user)
access2 = factories.UserDocumentAccessFactory(document=document)
access1_user = serializers.UserSerializer(instance=user).data
access2_user = serializers.UserSerializer(instance=access2.user).data
serializers.UserSerializer(instance=user)
serializers.UserSerializer(instance=access2.user)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/",
)
assert response.status_code == 200
content = response.json()
assert sorted(content.pop("accesses"), key=lambda x: x["id"]) == sorted(
[
{
"id": str(access1.id),
"user": access1_user,
"team": "",
"role": access1.role,
"abilities": access1.get_abilities(user),
},
{
"id": str(access2.id),
"user": access2_user,
"team": "",
"role": access2.role,
"abilities": access2.get_abilities(user),
},
],
key=lambda x: x["id"],
)
assert response.json() == {
"id": str(document.id),
"title": document.title,
"content": document.content,
"abilities": document.get_abilities(user),
"content": document.content,
"creator": str(document.creator.id),
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"is_favorite": False,
"link_reach": document.link_reach,
"link_role": document.link_role,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"nb_accesses": 2,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
}
@@ -255,7 +251,7 @@ def test_api_documents_retrieve_authenticated_related_team_members(
):
"""
Authenticated users should be allowed to retrieve a document to which they
are related via a team whatever the role and see all its accesses.
are related via a team whatever the role.
"""
mock_user_teams.return_value = teams
@@ -266,81 +262,34 @@ def test_api_documents_retrieve_authenticated_related_team_members(
document = factories.DocumentFactory(link_reach="restricted")
access_reader = factories.TeamDocumentAccessFactory(
factories.TeamDocumentAccessFactory(
document=document, team="readers", role="reader"
)
access_editor = factories.TeamDocumentAccessFactory(
factories.TeamDocumentAccessFactory(
document=document, team="editors", role="editor"
)
access_administrator = factories.TeamDocumentAccessFactory(
factories.TeamDocumentAccessFactory(
document=document, team="administrators", role="administrator"
)
access_owner = factories.TeamDocumentAccessFactory(
document=document, team="owners", role="owner"
)
other_access = factories.TeamDocumentAccessFactory(document=document)
factories.TeamDocumentAccessFactory(document=document, team="owners", role="owner")
factories.TeamDocumentAccessFactory(document=document)
factories.TeamDocumentAccessFactory()
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
# pylint: disable=R0801
assert response.status_code == 200
content = response.json()
expected_abilities = {
"destroy": False,
"retrieve": True,
"set_role_to": [],
"update": False,
"partial_update": False,
}
assert sorted(content.pop("accesses"), key=lambda x: x["id"]) == sorted(
[
{
"id": str(access_reader.id),
"user": None,
"team": "readers",
"role": access_reader.role,
"abilities": expected_abilities,
},
{
"id": str(access_editor.id),
"user": None,
"team": "editors",
"role": access_editor.role,
"abilities": expected_abilities,
},
{
"id": str(access_administrator.id),
"user": None,
"team": "administrators",
"role": access_administrator.role,
"abilities": expected_abilities,
},
{
"id": str(access_owner.id),
"user": None,
"team": "owners",
"role": access_owner.role,
"abilities": expected_abilities,
},
{
"id": str(other_access.id),
"user": None,
"team": other_access.team,
"role": other_access.role,
"abilities": expected_abilities,
},
],
key=lambda x: x["id"],
)
assert response.json() == {
"id": str(document.id),
"title": document.title,
"content": document.content,
"abilities": document.get_abilities(user),
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"is_favorite": False,
"link_reach": "restricted",
"link_role": document.link_role,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"nb_accesses": 5,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
}
@@ -358,7 +307,7 @@ def test_api_documents_retrieve_authenticated_related_team_administrators(
):
"""
Authenticated users should be allowed to retrieve a document to which they
are related via a team whatever the role and see all its accesses.
are related via a team whatever the role.
"""
mock_user_teams.return_value = teams
@@ -369,98 +318,34 @@ def test_api_documents_retrieve_authenticated_related_team_administrators(
document = factories.DocumentFactory(link_reach="restricted")
access_reader = factories.TeamDocumentAccessFactory(
factories.TeamDocumentAccessFactory(
document=document, team="readers", role="reader"
)
access_editor = factories.TeamDocumentAccessFactory(
factories.TeamDocumentAccessFactory(
document=document, team="editors", role="editor"
)
access_administrator = factories.TeamDocumentAccessFactory(
factories.TeamDocumentAccessFactory(
document=document, team="administrators", role="administrator"
)
access_owner = factories.TeamDocumentAccessFactory(
document=document, team="owners", role="owner"
)
other_access = factories.TeamDocumentAccessFactory(document=document)
factories.TeamDocumentAccessFactory(document=document, team="owners", role="owner")
factories.TeamDocumentAccessFactory(document=document)
factories.TeamDocumentAccessFactory()
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
# pylint: disable=R0801
assert response.status_code == 200
content = response.json()
assert sorted(content.pop("accesses"), key=lambda x: x["id"]) == sorted(
[
{
"id": str(access_reader.id),
"user": None,
"team": "readers",
"role": "reader",
"abilities": {
"destroy": True,
"retrieve": True,
"set_role_to": ["administrator", "editor"],
"update": True,
"partial_update": True,
},
},
{
"id": str(access_editor.id),
"user": None,
"team": "editors",
"role": "editor",
"abilities": {
"destroy": True,
"retrieve": True,
"set_role_to": ["administrator", "reader"],
"update": True,
"partial_update": True,
},
},
{
"id": str(access_administrator.id),
"user": None,
"team": "administrators",
"role": "administrator",
"abilities": {
"destroy": True,
"retrieve": True,
"set_role_to": ["editor", "reader"],
"update": True,
"partial_update": True,
},
},
{
"id": str(access_owner.id),
"user": None,
"team": "owners",
"role": "owner",
"abilities": {
"destroy": False,
"retrieve": True,
"set_role_to": [],
"update": False,
"partial_update": False,
},
},
{
"id": str(other_access.id),
"user": None,
"team": other_access.team,
"role": other_access.role,
"abilities": other_access.get_abilities(user),
},
],
key=lambda x: x["id"],
)
assert response.json() == {
"id": str(document.id),
"title": document.title,
"content": document.content,
"abilities": document.get_abilities(user),
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"is_favorite": False,
"link_reach": "restricted",
"link_role": document.link_role,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"nb_accesses": 5,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
}
@@ -479,7 +364,7 @@ def test_api_documents_retrieve_authenticated_related_team_owners(
):
"""
Authenticated users should be allowed to retrieve a restricted document to which
they are related via a team whatever the role and see all its accesses.
they are related via a team whatever the role.
"""
mock_user_teams.return_value = teams
@@ -490,100 +375,33 @@ def test_api_documents_retrieve_authenticated_related_team_owners(
document = factories.DocumentFactory(link_reach="restricted")
access_reader = factories.TeamDocumentAccessFactory(
factories.TeamDocumentAccessFactory(
document=document, team="readers", role="reader"
)
access_editor = factories.TeamDocumentAccessFactory(
factories.TeamDocumentAccessFactory(
document=document, team="editors", role="editor"
)
access_administrator = factories.TeamDocumentAccessFactory(
factories.TeamDocumentAccessFactory(
document=document, team="administrators", role="administrator"
)
access_owner = factories.TeamDocumentAccessFactory(
document=document, team="owners", role="owner"
)
other_access = factories.TeamDocumentAccessFactory(document=document)
factories.TeamDocumentAccessFactory(document=document, team="owners", role="owner")
factories.TeamDocumentAccessFactory(document=document)
factories.TeamDocumentAccessFactory()
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
# pylint: disable=R0801
assert response.status_code == 200
content = response.json()
assert sorted(content.pop("accesses"), key=lambda x: x["id"]) == sorted(
[
{
"id": str(access_reader.id),
"user": None,
"team": "readers",
"role": "reader",
"abilities": {
"destroy": True,
"retrieve": True,
"set_role_to": ["owner", "administrator", "editor"],
"update": True,
"partial_update": True,
},
},
{
"id": str(access_editor.id),
"user": None,
"team": "editors",
"role": "editor",
"abilities": {
"destroy": True,
"retrieve": True,
"set_role_to": ["owner", "administrator", "reader"],
"update": True,
"partial_update": True,
},
},
{
"id": str(access_administrator.id),
"user": None,
"team": "administrators",
"role": "administrator",
"abilities": {
"destroy": True,
"retrieve": True,
"set_role_to": ["owner", "editor", "reader"],
"update": True,
"partial_update": True,
},
},
{
"id": str(access_owner.id),
"user": None,
"team": "owners",
"role": "owner",
"abilities": {
# editable only if there is another owner role than the user's team...
"destroy": other_access.role == "owner",
"retrieve": True,
"set_role_to": ["administrator", "editor", "reader"]
if other_access.role == "owner"
else [],
"update": other_access.role == "owner",
"partial_update": other_access.role == "owner",
},
},
{
"id": str(other_access.id),
"user": None,
"team": other_access.team,
"role": other_access.role,
"abilities": other_access.get_abilities(user),
},
],
key=lambda x: x["id"],
)
assert response.json() == {
"id": str(document.id),
"title": document.title,
"content": document.content,
"abilities": document.get_abilities(user),
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"is_favorite": False,
"link_reach": "restricted",
"link_role": document.link_role,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"nb_accesses": 5,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
}

View File

@@ -132,7 +132,14 @@ def test_api_documents_update_anonymous_or_authenticated_unrelated(
document = models.Document.objects.get(pk=document.pk)
document_values = serializers.DocumentSerializer(instance=document).data
for key, value in document_values.items():
if key in ["id", "accesses", "created_at", "link_reach", "link_role"]:
if key in [
"id",
"accesses",
"created_at",
"creator",
"link_reach",
"link_role",
]:
assert value == old_document_values[key]
elif key == "updated_at":
assert value > old_document_values[key]
@@ -216,7 +223,14 @@ def test_api_documents_update_authenticated_editor_administrator_or_owner(
document = models.Document.objects.get(pk=document.pk)
document_values = serializers.DocumentSerializer(instance=document).data
for key, value in document_values.items():
if key in ["id", "accesses", "created_at", "link_reach", "link_role"]:
if key in [
"id",
"created_at",
"creator",
"link_reach",
"link_role",
"nb_accesses",
]:
assert value == old_document_values[key]
elif key == "updated_at":
assert value > old_document_values[key]
@@ -255,7 +269,14 @@ def test_api_documents_update_authenticated_owners(via, mock_user_teams):
document = models.Document.objects.get(pk=document.pk)
document_values = serializers.DocumentSerializer(instance=document).data
for key, value in document_values.items():
if key in ["id", "accesses", "created_at", "link_reach", "link_role"]:
if key in [
"id",
"created_at",
"creator",
"link_reach",
"link_role",
"nb_accesses",
]:
assert value == old_document_values[key]
elif key == "updated_at":
assert value > old_document_values[key]

View File

@@ -22,7 +22,7 @@ def test_api_templates_retrieve_anonymous_public():
"abilities": {
"destroy": False,
"generate_document": True,
"manage_accesses": False,
"accesses_manage": False,
"partial_update": False,
"retrieve": True,
"update": False,
@@ -68,7 +68,7 @@ def test_api_templates_retrieve_authenticated_unrelated_public():
"abilities": {
"destroy": False,
"generate_document": True,
"manage_accesses": False,
"accesses_manage": False,
"partial_update": False,
"retrieve": True,
"update": False,

View File

@@ -0,0 +1,47 @@
"""
Test config API endpoints in the Impress core app.
"""
from django.test import override_settings
import pytest
from rest_framework.status import (
HTTP_200_OK,
)
from rest_framework.test import APIClient
from core import factories
pytestmark = pytest.mark.django_db
@override_settings(
COLLABORATION_WS_URL="http://testcollab/",
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])
def test_api_config(is_authenticated):
"""Anonymous users should be allowed to get the configuration."""
client = APIClient()
if is_authenticated:
user = factories.UserFactory()
client.force_login(user)
response = client.get("/api/v1.0/config/")
assert response.status_code == HTTP_200_OK
assert response.json() == {
"COLLABORATION_WS_URL": "http://testcollab/",
"CRISP_WEBSITE_ID": "123",
"ENVIRONMENT": "test",
"FRONTEND_THEME": "test-theme",
"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",
}

View File

@@ -69,6 +69,48 @@ def test_api_users_list_query_email():
assert user_ids == [str(nicole.id), str(frank.id)]
def test_api_users_list_query_email_matching():
"""While filtering by email, results should be filtered and sorted by similarity"""
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")
response = client.get(
"/api/v1.0/users/?q=michael.johnson@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)]
response = client.get("/api/v1.0/users/?q=michael.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), 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)]
def test_api_users_list_query_email_exclude_doc_user():
"""
Authenticated users should be able to list users

View File

@@ -32,15 +32,22 @@ def test_models_documents_id_unique():
factories.DocumentFactory(id=document.id)
def test_models_documents_creator_required():
"""No field should be required on the Document model."""
models.Document.objects.create()
def test_models_documents_title_null():
"""The "title" field can be null."""
document = models.Document.objects.create(title=None)
document = models.Document.objects.create(
title=None, creator=factories.UserFactory()
)
assert document.title is None
def test_models_documents_title_empty():
"""The "title" field can be empty."""
document = models.Document.objects.create(title="")
document = models.Document.objects.create(title="", creator=factories.UserFactory())
assert document.title == ""
@@ -83,13 +90,17 @@ def test_models_documents_get_abilities_forbidden(is_authenticated, reach, role)
user = factories.UserFactory() if is_authenticated else AnonymousUser()
abilities = document.get_abilities(user)
assert abilities == {
"accesses_manage": False,
"accesses_view": False,
"ai_transform": False,
"ai_translate": False,
"attachment_upload": False,
"link_configuration": False,
"collaboration_auth": False,
"destroy": False,
"favorite": False,
"invite_owner": False,
"manage_accesses": False,
"media_auth": False,
"link_configuration": False,
"partial_update": False,
"retrieve": False,
"update": False,
@@ -116,13 +127,17 @@ def test_models_documents_get_abilities_reader(is_authenticated, reach):
user = factories.UserFactory() if is_authenticated else AnonymousUser()
abilities = document.get_abilities(user)
assert abilities == {
"accesses_manage": False,
"accesses_view": False,
"ai_transform": False,
"ai_translate": False,
"attachment_upload": False,
"collaboration_auth": True,
"destroy": False,
"link_configuration": False,
"favorite": is_authenticated,
"invite_owner": False,
"manage_accesses": False,
"link_configuration": False,
"media_auth": True,
"partial_update": False,
"retrieve": True,
"update": False,
@@ -149,13 +164,17 @@ def test_models_documents_get_abilities_editor(is_authenticated, reach):
user = factories.UserFactory() if is_authenticated else AnonymousUser()
abilities = document.get_abilities(user)
assert abilities == {
"accesses_manage": False,
"accesses_view": False,
"ai_transform": True,
"ai_translate": True,
"attachment_upload": True,
"collaboration_auth": True,
"destroy": False,
"link_configuration": False,
"favorite": is_authenticated,
"invite_owner": False,
"manage_accesses": False,
"link_configuration": False,
"media_auth": True,
"partial_update": True,
"retrieve": True,
"update": True,
@@ -171,13 +190,17 @@ def test_models_documents_get_abilities_owner():
access = factories.UserDocumentAccessFactory(role="owner", user=user)
abilities = access.document.get_abilities(access.user)
assert abilities == {
"accesses_manage": True,
"accesses_view": True,
"ai_transform": True,
"ai_translate": True,
"attachment_upload": True,
"collaboration_auth": True,
"destroy": True,
"link_configuration": True,
"favorite": True,
"invite_owner": True,
"manage_accesses": True,
"link_configuration": True,
"media_auth": True,
"partial_update": True,
"retrieve": True,
"update": True,
@@ -192,13 +215,17 @@ def test_models_documents_get_abilities_administrator():
access = factories.UserDocumentAccessFactory(role="administrator")
abilities = access.document.get_abilities(access.user)
assert abilities == {
"accesses_manage": True,
"accesses_view": True,
"ai_transform": True,
"ai_translate": True,
"attachment_upload": True,
"collaboration_auth": True,
"destroy": False,
"link_configuration": True,
"favorite": True,
"invite_owner": False,
"manage_accesses": True,
"link_configuration": True,
"media_auth": True,
"partial_update": True,
"retrieve": True,
"update": True,
@@ -216,13 +243,17 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
abilities = access.document.get_abilities(access.user)
assert abilities == {
"accesses_manage": False,
"accesses_view": True,
"ai_transform": True,
"ai_translate": True,
"attachment_upload": True,
"collaboration_auth": True,
"destroy": False,
"link_configuration": False,
"favorite": True,
"invite_owner": False,
"manage_accesses": False,
"link_configuration": False,
"media_auth": True,
"partial_update": True,
"retrieve": True,
"update": True,
@@ -242,13 +273,17 @@ def test_models_documents_get_abilities_reader_user(django_assert_num_queries):
abilities = access.document.get_abilities(access.user)
assert abilities == {
"accesses_manage": False,
"accesses_view": True,
"ai_transform": False,
"ai_translate": False,
"attachment_upload": False,
"collaboration_auth": True,
"destroy": False,
"link_configuration": False,
"favorite": True,
"invite_owner": False,
"manage_accesses": False,
"link_configuration": False,
"media_auth": True,
"partial_update": False,
"retrieve": True,
"update": False,
@@ -269,13 +304,17 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
abilities = access.document.get_abilities(access.user)
assert abilities == {
"accesses_manage": False,
"accesses_view": True,
"ai_transform": False,
"ai_translate": False,
"attachment_upload": False,
"collaboration_auth": True,
"destroy": False,
"link_configuration": False,
"favorite": True,
"invite_owner": False,
"manage_accesses": False,
"link_configuration": False,
"media_auth": True,
"partial_update": False,
"retrieve": True,
"update": False,
@@ -388,8 +427,8 @@ def test_models_documents__email_invitation__success():
assert len(mail.outbox) == 0
sender = factories.UserFactory(full_name="Test Sender", email="sender@example.com")
document.email_invitation(
"en", "guest@example.com", models.RoleChoices.EDITOR, sender
document.send_invitation_email(
"guest@example.com", models.RoleChoices.EDITOR, sender, "en"
)
# pylint: disable-next=no-member
@@ -402,8 +441,8 @@ def test_models_documents__email_invitation__success():
email_content = " ".join(email.body.split())
assert (
f'Test Sender (sender@example.com) invited you with the role "editor" '
f"on the following document : {document.title}" in email_content
f"Test Sender (sender@example.com) invited you with the role &quot;editor&quot; "
f"on the following document: {document.title}" in email_content
)
assert f"docs/{document.id}/" in email_content
@@ -420,11 +459,11 @@ def test_models_documents__email_invitation__success_fr():
sender = factories.UserFactory(
full_name="Test Sender2", email="sender2@example.com"
)
document.email_invitation(
"fr-fr",
document.send_invitation_email(
"guest2@example.com",
models.RoleChoices.OWNER,
sender,
"fr-fr",
)
# pylint: disable-next=no-member
@@ -437,8 +476,8 @@ def test_models_documents__email_invitation__success_fr():
email_content = " ".join(email.body.split())
assert (
f'Test Sender2 (sender2@example.com) vous a invité avec le rôle "propriétaire" '
f"sur le document suivant : {document.title}" in email_content
f"Test Sender2 (sender2@example.com) vous a invité avec le rôle &quot;propriétaire&quot; "
f"sur le document suivant: {document.title}" in email_content
)
assert f"docs/{document.id}/" in email_content
@@ -456,11 +495,11 @@ def test_models_documents__email_invitation__failed(mock_logger, _mock_send_mail
assert len(mail.outbox) == 0
sender = factories.UserFactory()
document.email_invitation(
"en",
document.send_invitation_email(
"guest3@example.com",
models.RoleChoices.ADMIN,
sender,
"en",
)
# No email has been sent
@@ -472,9 +511,9 @@ def test_models_documents__email_invitation__failed(mock_logger, _mock_send_mail
(
_,
email,
emails,
exception,
) = mock_logger.call_args.args
assert email == "guest3@example.com"
assert emails == ["guest3@example.com"]
assert isinstance(exception, smtplib.SMTPException)

View File

@@ -144,7 +144,7 @@ def test_models_invitationd_new_user_filter_expired_invitations():
).exists()
@pytest.mark.parametrize("num_invitations, num_queries", [(0, 3), (1, 6), (20, 6)])
@pytest.mark.parametrize("num_invitations, num_queries", [(0, 3), (1, 7), (20, 7)])
def test_models_invitationd_new_userd_user_creation_constant_num_queries(
django_assert_num_queries, num_invitations, num_queries
):

View File

@@ -62,7 +62,7 @@ def test_models_templates_get_abilities_anonymous_public():
"destroy": False,
"retrieve": True,
"update": False,
"manage_accesses": False,
"accesses_manage": False,
"partial_update": False,
"generate_document": True,
}
@@ -76,7 +76,7 @@ def test_models_templates_get_abilities_anonymous_not_public():
"destroy": False,
"retrieve": False,
"update": False,
"manage_accesses": False,
"accesses_manage": False,
"partial_update": False,
"generate_document": False,
}
@@ -90,7 +90,7 @@ def test_models_templates_get_abilities_authenticated_public():
"destroy": False,
"retrieve": True,
"update": False,
"manage_accesses": False,
"accesses_manage": False,
"partial_update": False,
"generate_document": True,
}
@@ -104,7 +104,7 @@ def test_models_templates_get_abilities_authenticated_not_public():
"destroy": False,
"retrieve": False,
"update": False,
"manage_accesses": False,
"accesses_manage": False,
"partial_update": False,
"generate_document": False,
}
@@ -119,7 +119,7 @@ def test_models_templates_get_abilities_owner():
"destroy": True,
"retrieve": True,
"update": True,
"manage_accesses": True,
"accesses_manage": True,
"partial_update": True,
"generate_document": True,
}
@@ -133,7 +133,7 @@ def test_models_templates_get_abilities_administrator():
"destroy": False,
"retrieve": True,
"update": True,
"manage_accesses": True,
"accesses_manage": True,
"partial_update": True,
"generate_document": True,
}
@@ -150,7 +150,7 @@ def test_models_templates_get_abilities_editor_user(django_assert_num_queries):
"destroy": False,
"retrieve": True,
"update": True,
"manage_accesses": False,
"accesses_manage": False,
"partial_update": True,
"generate_document": True,
}
@@ -167,7 +167,7 @@ def test_models_templates_get_abilities_reader_user(django_assert_num_queries):
"destroy": False,
"retrieve": True,
"update": False,
"manage_accesses": False,
"accesses_manage": False,
"partial_update": False,
"generate_document": True,
}
@@ -185,7 +185,7 @@ def test_models_templates_get_abilities_preset_role(django_assert_num_queries):
"destroy": False,
"retrieve": True,
"update": False,
"manage_accesses": False,
"accesses_manage": False,
"partial_update": False,
"generate_document": True,
}

View File

@@ -102,3 +102,24 @@ def test_api_ai__success_sanitize(mock_create):
response = AIService().transform("hello", "prompt")
assert response == {"answer": "Salut\n \tle \nmonde"}
@override_settings(
AI_BASE_URL="http://example.com", AI_API_KEY="test-key", AI_MODEL="test-model"
)
@patch("openai.resources.chat.completions.Completions.create")
def test_api_ai__success_when_sanitize_fails(mock_create):
"""The AI request should work as expected even with badly formatted response."""
# pylint: disable=C0303
answer = """{
"answer" :
"Salut le monde"
}"""
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content=answer))]
)
response = AIService().transform("hello", "prompt")
assert response == {"answer": "Salut le monde"}

View File

@@ -0,0 +1,185 @@
"""
This module contains tests for the CollaborationService class in the
core.services.collaboration_services module.
"""
import json
import re
from contextlib import contextmanager
from django.core.exceptions import ImproperlyConfigured
import pytest
import requests
import responses
from core.services.collaboration_services import CollaborationService
@pytest.fixture
def mock_reset_connections(settings):
"""
Creates a context manager to mock the reset-connections endpoint for collaboration services.
Args:
settings: A settings object that contains the configuration for the collaboration API.
Returns:
A context manager function that mocks the reset-connections endpoint.
The context manager function takes the following parameters:
document_id (str): The ID of the document for which connections are being reset.
user_id (str, optional): The ID of the user making the request. Defaults to None.
Usage:
with mock_reset_connections(settings)(document_id, user_id) as mock:
# Your test code here
The context manager performs the following actions:
- Mocks the reset-connections endpoint using responses.RequestsMock.
- Sets the COLLABORATION_API_URL and COLLABORATION_SERVER_SECRET in the settings.
- Verifies that the reset-connections endpoint is called exactly once.
- Checks that the request URL and headers are correct.
- If user_id is provided, checks that the X-User-Id header is correct.
"""
@contextmanager
def _mock_reset_connections(document_id, user_id=None):
with responses.RequestsMock() as rsps:
# Mock the reset-connections endpoint
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
endpoint_url = (
f"{settings.COLLABORATION_API_URL}reset-connections/?room={document_id}"
)
rsps.add(
responses.POST,
endpoint_url,
json={},
status=200,
)
yield
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 (
request.headers.get("Authorization")
== settings.COLLABORATION_SERVER_SECRET
), "Incorrect Authorization header"
if user_id:
assert (
request.headers.get("X-User-Id") == user_id
), "Incorrect X-User-Id header"
return _mock_reset_connections
def test_init_without_api_url(settings):
"""Test that ImproperlyConfigured is raised when COLLABORATION_API_URL is None."""
settings.COLLABORATION_API_URL = None
with pytest.raises(ImproperlyConfigured):
CollaborationService()
def test_init_with_api_url(settings):
"""Test that the service initializes correctly when COLLABORATION_API_URL is set."""
settings.COLLABORATION_API_URL = "http://example.com/"
service = CollaborationService()
assert isinstance(service, CollaborationService)
@responses.activate
def test_reset_connections_with_user_id(settings):
"""Test reset_connections with a provided user_id."""
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
service = CollaborationService()
room = "room1"
user_id = "user123"
endpoint_url = "http://example.com/reset-connections/?room=" + room
responses.add(responses.POST, endpoint_url, json={}, status=200)
service.reset_connections(room, user_id)
assert len(responses.calls) == 1
request = responses.calls[0].request
assert request.url == endpoint_url
assert request.headers.get("Authorization") == "secret-token"
assert request.headers.get("X-User-Id") == "user123"
@responses.activate
def test_reset_connections_without_user_id(settings):
"""Test reset_connections without a user_id."""
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
service = CollaborationService()
room = "room1"
user_id = None
endpoint_url = "http://example.com/reset-connections/?room=" + room
responses.add(
responses.POST,
endpoint_url,
json={},
status=200,
)
service.reset_connections(room, user_id)
assert len(responses.calls) == 1
request = responses.calls[0].request
assert request.url == endpoint_url
assert request.headers.get("Authorization") == "secret-token"
assert request.headers.get("X-User-Id") is None
@responses.activate
def test_reset_connections_non_200_response(settings):
"""Test that an HTTPError is raised when the response status is not 200."""
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
service = CollaborationService()
room = "room1"
user_id = "user123"
endpoint_url = "http://example.com/reset-connections/?room=" + room
response_body = {"error": "Internal Server Error"}
responses.add(responses.POST, endpoint_url, json=response_body, status=500)
expected_exception_message = re.escape(
"Failed to notify WebSocket server. Status code: 500, Response: "
) + re.escape(json.dumps(response_body))
with pytest.raises(requests.HTTPError, match=expected_exception_message):
service.reset_connections(room, user_id)
assert len(responses.calls) == 1
@responses.activate
def test_reset_connections_request_exception(settings):
"""Test that an HTTPError is raised when a RequestException occurs."""
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
service = CollaborationService()
room = "room1"
user_id = "user123"
endpoint_url = "http://example.com/reset-connections?room=" + room
responses.add(
responses.POST,
endpoint_url,
body=requests.exceptions.ConnectionError("Network error"),
)
with pytest.raises(requests.HTTPError, match="Failed to notify WebSocket server."):
service.reset_connections(room, user_id)
assert len(responses.calls) == 1

View File

@@ -0,0 +1,147 @@
"""Test converter services."""
from unittest.mock import MagicMock, patch
import pytest
import requests
from core.services.converter_services import (
InvalidResponseError,
MissingContentError,
ServiceUnavailableError,
ValidationError,
YdocConverter,
)
def test_auth_header(settings):
"""Test authentication header generation."""
settings.Y_PROVIDER_API_KEY = "test-key"
converter = YdocConverter()
assert converter.auth_header == "test-key"
def test_convert_markdown_empty_text():
"""Should raise ValidationError when text is empty."""
converter = YdocConverter()
with pytest.raises(ValidationError, match="Input text cannot be empty"):
converter.convert_markdown("")
@patch("requests.post")
def test_convert_markdown_service_unavailable(mock_post):
"""Should raise ServiceUnavailableError when service is unavailable."""
converter = YdocConverter()
mock_post.side_effect = requests.RequestException("Connection error")
with pytest.raises(
ServiceUnavailableError,
match="Failed to connect to conversion service",
):
converter.convert_markdown("test text")
@patch("requests.post")
def test_convert_markdown_http_error(mock_post):
"""Should raise ServiceUnavailableError when HTTP error occurs."""
converter = YdocConverter()
mock_response = MagicMock()
mock_response.raise_for_status.side_effect = requests.HTTPError("HTTP Error")
mock_post.return_value = mock_response
with pytest.raises(
ServiceUnavailableError,
match="Failed to connect to conversion service",
):
converter.convert_markdown("test text")
@patch("requests.post")
def test_convert_markdown_invalid_json_response(mock_post):
"""Should raise InvalidResponseError when response is not valid JSON."""
converter = YdocConverter()
mock_response = MagicMock()
mock_response.json.side_effect = ValueError("Invalid JSON")
mock_post.return_value = mock_response
with pytest.raises(
InvalidResponseError,
match="Could not parse conversion service response",
):
converter.convert_markdown("test text")
@patch("requests.post")
def test_convert_markdown_missing_content_field(mock_post, settings):
"""Should raise MissingContentError when response is missing required field."""
settings.CONVERSION_API_CONTENT_FIELD = "expected_field"
converter = YdocConverter()
mock_response = MagicMock()
mock_response.json.return_value = {"wrong_field": "content"}
mock_post.return_value = mock_response
with pytest.raises(
MissingContentError,
match="Response missing required field: expected_field",
):
converter.convert_markdown("test text")
@patch("requests.post")
def test_convert_markdown_full_integration(mock_post, settings):
"""Test full integration with all settings."""
settings.Y_PROVIDER_API_BASE_URL = "http://test.com/"
settings.Y_PROVIDER_API_KEY = "test-key"
settings.CONVERSION_API_ENDPOINT = "conversion-endpoint"
settings.CONVERSION_API_TIMEOUT = 5
settings.CONVERSION_API_CONTENT_FIELD = "content"
converter = YdocConverter()
expected_content = {"converted": "content"}
mock_response = MagicMock()
mock_response.json.return_value = {"content": expected_content}
mock_post.return_value = mock_response
result = converter.convert_markdown("test markdown")
assert result == expected_content
mock_post.assert_called_once_with(
"http://test.com/conversion-endpoint/",
json={"content": "test markdown"},
headers={
"Authorization": "test-key",
"Content-Type": "application/json",
},
timeout=5,
verify=False,
)
@patch("requests.post")
def test_convert_markdown_timeout(mock_post):
"""Should raise ServiceUnavailableError when request times out."""
converter = YdocConverter()
mock_post.side_effect = requests.Timeout("Request timed out")
with pytest.raises(
ServiceUnavailableError,
match="Failed to connect to conversion service",
):
converter.convert_markdown("test text")
def test_convert_markdown_none_input():
"""Should raise ValidationError when input is None."""
converter = YdocConverter()
with pytest.raises(ValidationError, match="Input text cannot be empty"):
converter.convert_markdown(None)

View File

@@ -0,0 +1,30 @@
"""
Unit tests for the User model
"""
import pytest
from impress.settings import Base
def test_invalid_settings_oidc_email_configuration():
"""
The OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION and OIDC_ALLOW_DUPLICATE_EMAILS settings
should not be both set to True simultaneously.
"""
class TestSettings(Base):
"""Fake test settings."""
OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
OIDC_ALLOW_DUPLICATE_EMAILS = True
# The validation is performed during post_setup
with pytest.raises(ValueError) as excinfo:
TestSettings().post_setup()
# Check the exception message
assert str(excinfo.value) == (
"Both OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION and "
"OIDC_ALLOW_DUPLICATE_EMAILS cannot be set to True simultaneously. "
)

View File

@@ -55,4 +55,5 @@ urlpatterns = [
]
),
),
path(f"api/{settings.API_VERSION}/config/", viewsets.ConfigView.as_view()),
]

View File

@@ -132,10 +132,13 @@ def create_demo(stdout):
)
queue.flush()
users_ids = list(models.User.objects.values_list("id", flat=True))
with Timeit(stdout, "Creating documents"):
for _ in range(defaults.NB_OBJECTS["docs"]):
queue.push(
models.Document(
creator_id=random.choice(users_ids),
title=fake.sentence(nb_words=4),
link_reach=models.LinkReachChoices.AUTHENTICATED
if random_true_with_probability(0.5)
@@ -147,7 +150,6 @@ def create_demo(stdout):
with Timeit(stdout, "Creating docs accesses"):
docs_ids = list(models.Document.objects.values_list("id", flat=True))
users_ids = list(models.User.objects.values_list("id", flat=True))
for doc_id in docs_ids:
for user_id in random.sample(
users_ids,

View File

@@ -10,8 +10,9 @@ For the full list of settings and their values, see
https://docs.djangoproject.com/en/3.1/ref/settings/
"""
import json
import os
import tomllib
from socket import gethostbyname, gethostname
from django.utils.translation import gettext_lazy as _
@@ -27,19 +28,12 @@ DATA_DIR = os.path.join("/", "data")
def get_release():
"""
Get the current release of the application
By release, we mean the release from the version.json file à la Mozilla [1]
(if any). If this file has not been found, it defaults to "NA".
[1]
https://github.com/mozilla-services/Dockerflow/blob/master/docs/version_object.md
"""
# Try to get the current release from the version.json file generated by the
# CI during the Docker image build
try:
with open(os.path.join(BASE_DIR, "version.json"), encoding="utf8") as version:
return json.load(version)["version"]
except FileNotFoundError:
with open(os.path.join(BASE_DIR, "pyproject.toml"), "rb") as f:
pyproject_data = tomllib.load(f)
return pyproject_data["project"]["version"]
except (FileNotFoundError, KeyError):
return "NA" # Default: not available
@@ -56,7 +50,7 @@ class Base(Configuration):
You may also want to override default configuration by setting the following environment
variables:
* DJANGO_SENTRY_DSN
* SENTRY_DSN
* DB_NAME
* DB_HOST
* DB_PASSWORD
@@ -71,6 +65,7 @@ class Base(Configuration):
# Security
ALLOWED_HOSTS = values.ListValue([])
SECRET_KEY = values.Value(None)
SERVER_TO_SERVER_API_TOKENS = values.ListValue([])
# Application definition
ROOT_URLCONF = "impress.urls"
@@ -104,6 +99,9 @@ class Base(Configuration):
STATIC_ROOT = os.path.join(DATA_DIR, "static")
MEDIA_URL = "/media/"
MEDIA_ROOT = os.path.join(DATA_DIR, "media")
MEDIA_BASE_URL = values.Value(
None, environ_name="MEDIA_BASE_URL", environ_prefix=None
)
SITE_ID = 1
@@ -237,6 +235,7 @@ class Base(Configuration):
(
("en-us", _("English")),
("fr-fr", _("French")),
("de-de", _("German")),
)
)
@@ -353,9 +352,11 @@ class Base(Configuration):
# Mail
EMAIL_BACKEND = values.Value("django.core.mail.backends.smtp.EmailBackend")
EMAIL_BRAND_NAME = values.Value(None)
EMAIL_HOST = values.Value(None)
EMAIL_HOST_USER = values.Value(None)
EMAIL_HOST_PASSWORD = values.Value(None)
EMAIL_LOGO_IMG = values.Value(None)
EMAIL_PORT = values.PositiveIntegerValue(None)
EMAIL_USE_TLS = values.BooleanValue(False)
EMAIL_USE_SSL = values.BooleanValue(False)
@@ -371,7 +372,33 @@ class Base(Configuration):
CORS_ALLOWED_ORIGIN_REGEXES = values.ListValue([])
# Sentry
SENTRY_DSN = values.Value(None, environ_name="SENTRY_DSN")
SENTRY_DSN = values.Value(None, environ_name="SENTRY_DSN", environ_prefix=None)
# Collaboration
COLLABORATION_API_URL = values.Value(
None, environ_name="COLLABORATION_API_URL", environ_prefix=None
)
COLLABORATION_SERVER_SECRET = values.Value(
None, environ_name="COLLABORATION_SERVER_SECRET", environ_prefix=None
)
COLLABORATION_WS_URL = values.Value(
None, environ_name="COLLABORATION_WS_URL", environ_prefix=None
)
# Frontend
FRONTEND_THEME = values.Value(
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
)
# Easy thumbnails
THUMBNAIL_EXTENSION = "webp"
@@ -452,9 +479,34 @@ class Base(Configuration):
environ_prefix=None,
)
# WARNING: Enabling this setting allows multiple user accounts to share the same email
# address. This may cause security issues and is not recommended for production use when
# email is activated as fallback for identification (see previous setting).
OIDC_ALLOW_DUPLICATE_EMAILS = values.BooleanValue(
default=False,
environ_name="OIDC_ALLOW_DUPLICATE_EMAILS",
environ_prefix=None,
)
USER_OIDC_ESSENTIAL_CLAIMS = values.ListValue(
default=[], environ_name="USER_OIDC_ESSENTIAL_CLAIMS", environ_prefix=None
)
USER_OIDC_FIELDS_TO_FULLNAME = values.ListValue(
default=["first_name", "last_name"],
environ_name="USER_OIDC_FIELDS_TO_FULLNAME",
environ_prefix=None,
)
USER_OIDC_FIELD_TO_SHORTNAME = values.Value(
default="first_name",
environ_name="USER_OIDC_FIELD_TO_SHORTNAME",
environ_prefix=None,
)
ALLOW_LOGOUT_GET_METHOD = values.BooleanValue(
default=True, environ_name="ALLOW_LOGOUT_GET_METHOD", environ_prefix=None
)
# AI service
AI_API_KEY = values.Value(None, environ_name="AI_API_KEY", environ_prefix=None)
AI_BASE_URL = values.Value(None, environ_name="AI_BASE_URL", environ_prefix=None)
AI_MODEL = values.Value(None, environ_name="AI_MODEL", environ_prefix=None)
@@ -470,17 +522,74 @@ class Base(Configuration):
"day": 200,
}
USER_OIDC_FIELDS_TO_FULLNAME = values.ListValue(
default=["first_name", "last_name"],
environ_name="USER_OIDC_FIELDS_TO_FULLNAME",
# Y provider microservice
Y_PROVIDER_API_KEY = values.Value(
environ_name="Y_PROVIDER_API_KEY",
environ_prefix=None,
)
USER_OIDC_FIELD_TO_SHORTNAME = values.Value(
default="first_name",
environ_name="USER_OIDC_FIELD_TO_SHORTNAME",
Y_PROVIDER_API_BASE_URL = values.Value(
environ_name="Y_PROVIDER_API_BASE_URL",
environ_prefix=None,
)
# Conversion endpoint
CONVERSION_API_ENDPOINT = values.Value(
default="convert-markdown",
environ_name="CONVERSION_API_ENDPOINT",
environ_prefix=None,
)
CONVERSION_API_CONTENT_FIELD = values.Value(
default="content",
environ_name="CONVERSION_API_CONTENT_FIELD",
environ_prefix=None,
)
CONVERSION_API_TIMEOUT = values.Value(
default=30,
environ_name="CONVERSION_API_TIMEOUT",
environ_prefix=None,
)
CONVERSION_API_SECURE = values.Value(
default=False,
environ_name="CONVERSION_API_SECURE",
environ_prefix=None,
)
# Logging
# We want to make it easy to log to console but by default we log production
# to Sentry and don't want to log to console.
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"handlers": {
"console": {
"class": "logging.StreamHandler",
"level": values.Value(
"ERROR",
environ_name="LOGGING_LEVEL_HANDLERS_CONSOLE",
environ_prefix=None,
),
},
},
# Override root logger to send it to console
"root": {
"handlers": ["console"],
"level": values.Value(
"INFO", environ_name="LOGGING_LEVEL_LOGGERS_ROOT", environ_prefix=None
),
},
"loggers": {
"core": {
"handlers": ["console"],
"level": values.Value(
"INFO",
environ_name="LOGGING_LEVEL_LOGGERS_APP",
environ_prefix=None,
),
"propagate": False,
},
},
}
# pylint: disable=invalid-name
@property
def ENVIRONMENT(self):
@@ -530,6 +639,15 @@ class Base(Configuration):
with sentry_sdk.configure_scope() as scope:
scope.set_extra("application", "backend")
if (
cls.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION
and cls.OIDC_ALLOW_DUPLICATE_EMAILS
):
raise ValueError(
"Both OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION and "
"OIDC_ALLOW_DUPLICATE_EMAILS cannot be set to True simultaneously. "
)
class Build(Base):
"""Settings used when the application is built.
@@ -576,23 +694,6 @@ class Development(Base):
class Test(Base):
"""Test environment settings"""
LOGGING = values.DictValue(
{
"version": 1,
"disable_existing_loggers": False,
"handlers": {
"console": {
"class": "logging.StreamHandler",
},
},
"loggers": {
"impress": {
"handlers": ["console"],
"level": "DEBUG",
},
},
}
)
PASSWORD_HASHERS = [
"django.contrib.auth.hashers.MD5PasswordHasher",
]
@@ -623,7 +724,13 @@ class Production(Base):
"""
# Security
ALLOWED_HOSTS = values.ListValue(None)
# Add allowed host from environment variables.
# The machine hostname is added by default,
# it makes the application pingable by a load balancer on the same machine by example
ALLOWED_HOSTS = [
*values.ListValue([], environ_name="ALLOWED_HOSTS"),
gethostbyname(gethostname()),
]
CSRF_TRUSTED_ORIGINS = values.ListValue([])
SECURE_BROWSER_XSS_FILTER = True
SECURE_CONTENT_TYPE_NOSNIFF = True

View File

@@ -0,0 +1,352 @@
msgid ""
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"
"Last-Translator: \n"
"Language-Team: German\n"
"Language: de_DE\n"
"MIME-Version: 1.0\n"
"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-docs\n"
"X-Crowdin-Project-ID: 754523\n"
"X-Crowdin-Language: de\n"
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:33 core/admin.py:33
msgid "Personal info"
msgstr "Persönliche Daten"
#: build/lib/core/admin.py:46 core/admin.py:46
msgid "Permissions"
msgstr "Berechtigungen"
#: build/lib/core/admin.py:58 core/admin.py:58
msgid "Important dates"
msgstr "Wichtige Daten"
#: build/lib/core/api/filters.py:16 core/api/filters.py:16
msgid "Creator is me"
msgstr "Ersteller bin ich"
#: build/lib/core/api/filters.py:19 core/api/filters.py:19
msgid "Favorite"
msgstr "Favorit"
#: build/lib/core/api/filters.py:22 core/api/filters.py:22
msgid "Title"
msgstr "Titel"
#: build/lib/core/api/serializers.py:317 core/api/serializers.py:317
msgid "A new document was created on your behalf!"
msgstr "Ein neues Dokument wurde in Ihrem Namen erstellt!"
#: build/lib/core/api/serializers.py:321 core/api/serializers.py:321
msgid "You have been granted ownership of a new document:"
msgstr "Sie sind Besitzer eines neuen Dokuments:"
#: build/lib/core/api/serializers.py:422 core/api/serializers.py:422
msgid "Body"
msgstr "Inhalt"
#: build/lib/core/api/serializers.py:425 core/api/serializers.py:425
msgid "Body type"
msgstr "Typ"
#: build/lib/core/api/serializers.py:431 core/api/serializers.py:431
msgid "Format"
msgstr ""
#: build/lib/core/authentication/backends.py:61
#: core/authentication/backends.py:61
msgid "Invalid response format or token verification failed"
msgstr "Ungültiges Antwortformat oder Token-Verifizierung fehlgeschlagen"
#: build/lib/core/authentication/backends.py:108
#: core/authentication/backends.py:108
msgid "User account is disabled"
msgstr "Benutzerkonto ist deaktiviert"
#: build/lib/core/models.py:63 build/lib/core/models.py:70 core/models.py:63
#: core/models.py:70
msgid "Reader"
msgstr "Lesen"
#: build/lib/core/models.py:64 build/lib/core/models.py:71 core/models.py:64
#: core/models.py:71
msgid "Editor"
msgstr "Bearbeiten"
#: build/lib/core/models.py:72 core/models.py:72
msgid "Administrator"
msgstr ""
#: build/lib/core/models.py:73 core/models.py:73
msgid "Owner"
msgstr "Besitzer"
#: build/lib/core/models.py:84 core/models.py:84
msgid "Restricted"
msgstr "Beschränkt"
#: build/lib/core/models.py:88 core/models.py:88
msgid "Authenticated"
msgstr "Authentifiziert"
#: build/lib/core/models.py:90 core/models.py:90
msgid "Public"
msgstr "Öffentlich"
#: build/lib/core/models.py:112 core/models.py:112
msgid "id"
msgstr ""
#: build/lib/core/models.py:113 core/models.py:113
msgid "primary key for the record as UUID"
msgstr ""
#: build/lib/core/models.py:119 core/models.py:119
msgid "created on"
msgstr "Erstellt"
#: build/lib/core/models.py:120 core/models.py:120
msgid "date and time at which a record was created"
msgstr "Datum und Uhrzeit, an dem ein Datensatz erstellt wurde"
#: build/lib/core/models.py:125 core/models.py:125
msgid "updated on"
msgstr "Aktualisiert"
#: build/lib/core/models.py:126 core/models.py:126
msgid "date and time at which a record was last updated"
msgstr "Datum und Uhrzeit, an dem zuletzt aktualisiert wurde"
#: 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 "Geben Sie eine gültige Unterseite ein. Dieser Wert darf nur Buchstaben, Zahlen und die @/./+/-/_/: Zeichen enthalten."
#: build/lib/core/models.py:181 core/models.py:181
msgid "sub"
msgstr "unter"
#: build/lib/core/models.py:183 core/models.py:183
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
msgstr "Erforderlich. 255 Zeichen oder weniger. Buchstaben, Zahlen und die Zeichen @/./+/-/_/:"
#: build/lib/core/models.py:192 core/models.py:192
msgid "full name"
msgstr "Name"
#: build/lib/core/models.py:193 core/models.py:193
msgid "short name"
msgstr "Kurzbezeichnung"
#: build/lib/core/models.py:195 core/models.py:195
msgid "identity email address"
msgstr "Identitäts-E-Mail-Adresse"
#: build/lib/core/models.py:200 core/models.py:200
msgid "admin email address"
msgstr "Admin E-Mail-Adresse"
#: build/lib/core/models.py:207 core/models.py:207
msgid "language"
msgstr "Sprache"
#: build/lib/core/models.py:208 core/models.py:208
msgid "The language in which the user wants to see the interface."
msgstr "Die Sprache, in der der Benutzer die Benutzeroberfläche sehen möchte."
#: build/lib/core/models.py:214 core/models.py:214
msgid "The timezone in which the user wants to see times."
msgstr "Die Zeitzone, in der der Nutzer Zeiten sehen möchte."
#: build/lib/core/models.py:217 core/models.py:217
msgid "device"
msgstr "Gerät"
#: build/lib/core/models.py:219 core/models.py:219
msgid "Whether the user is a device or a real user."
msgstr "Ob der Benutzer ein Gerät oder ein echter Benutzer ist."
#: build/lib/core/models.py:222 core/models.py:222
msgid "staff status"
msgstr "Status des Teammitgliedes"
#: build/lib/core/models.py:224 core/models.py:224
msgid "Whether the user can log into this admin site."
msgstr "Gibt an, ob der Benutzer sich in diese Admin-Seite einloggen kann."
#: build/lib/core/models.py:227 core/models.py:227
msgid "active"
msgstr "aktiviert"
#: 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 "Ob dieser Benutzer als aktiviert behandelt werden soll. Deaktivieren Sie diese Option, anstatt Konten zu löschen."
#: build/lib/core/models.py:242 core/models.py:242
msgid "user"
msgstr "Benutzer"
#: build/lib/core/models.py:243 core/models.py:243
msgid "users"
msgstr "Benutzer"
#: build/lib/core/models.py:382 build/lib/core/models.py:758 core/models.py:382
#: core/models.py:758
msgid "title"
msgstr "Titel"
#: build/lib/core/models.py:404 core/models.py:404
msgid "Document"
msgstr "Dokument"
#: build/lib/core/models.py:405 core/models.py:405
msgid "Documents"
msgstr "Dokumente"
#: build/lib/core/models.py:408 core/models.py:408
msgid "Untitled Document"
msgstr "Unbenanntes Dokument"
#: build/lib/core/models.py:633 core/models.py:633
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} hat ein Dokument mit Ihnen geteilt!"
#: 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 "{name} hat Sie mit der Rolle \"{role}\" zu folgendem Dokument eingeladen:"
#: build/lib/core/models.py:640 core/models.py:640
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} hat ein Dokument mit Ihnen geteilt: {title}"
#: build/lib/core/models.py:663 core/models.py:663
msgid "Document/user link trace"
msgstr "Dokument/Benutzer Linkverfolgung"
#: build/lib/core/models.py:664 core/models.py:664
msgid "Document/user link traces"
msgstr "Dokument/Benutzer Linkverfolgung"
#: build/lib/core/models.py:670 core/models.py:670
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:693 core/models.py:693
msgid "Document favorite"
msgstr "Dokumentenfavorit"
#: build/lib/core/models.py:694 core/models.py:694
msgid "Document favorites"
msgstr "Dokumentfavoriten"
#: 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 "Dieses Dokument ist bereits durch den gleichen Benutzer favorisiert worden."
#: build/lib/core/models.py:722 core/models.py:722
msgid "Document/user relation"
msgstr "Dokument/Benutzerbeziehung"
#: build/lib/core/models.py:723 core/models.py:723
msgid "Document/user relations"
msgstr "Dokument/Benutzerbeziehungen"
#: build/lib/core/models.py:729 core/models.py:729
msgid "This user is already in this document."
msgstr "Dieser Benutzer befindet sich bereits in diesem Dokument."
#: build/lib/core/models.py:735 core/models.py:735
msgid "This team is already in this document."
msgstr "Dieses Team befindet sich bereits in diesem Dokument."
#: 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 "Benutzer oder Team müssen gesetzt werden, nicht beides."
#: build/lib/core/models.py:759 core/models.py:759
msgid "description"
msgstr "Beschreibung"
#: build/lib/core/models.py:760 core/models.py:760
msgid "code"
msgstr "Code"
#: build/lib/core/models.py:761 core/models.py:761
msgid "css"
msgstr "CSS"
#: build/lib/core/models.py:763 core/models.py:763
msgid "public"
msgstr "öffentlich"
#: build/lib/core/models.py:765 core/models.py:765
msgid "Whether this template is public for anyone to use."
msgstr "Ob diese Vorlage für jedermann öffentlich ist."
#: build/lib/core/models.py:771 core/models.py:771
msgid "Template"
msgstr "Vorlage"
#: build/lib/core/models.py:772 core/models.py:772
msgid "Templates"
msgstr "Vorlagen"
#: build/lib/core/models.py:911 core/models.py:911
msgid "Template/user relation"
msgstr "Vorlage/Benutzer-Beziehung"
#: build/lib/core/models.py:912 core/models.py:912
msgid "Template/user relations"
msgstr "Vorlage/Benutzerbeziehungen"
#: build/lib/core/models.py:918 core/models.py:918
msgid "This user is already in this template."
msgstr "Dieser Benutzer ist bereits in dieser Vorlage."
#: build/lib/core/models.py:924 core/models.py:924
msgid "This team is already in this template."
msgstr "Dieses Team ist bereits in diesem Template."
#: build/lib/core/models.py:947 core/models.py:947
msgid "email address"
msgstr "E-Mail-Adresse"
#: build/lib/core/models.py:966 core/models.py:966
msgid "Document invitation"
msgstr "Einladung zum Dokument"
#: build/lib/core/models.py:967 core/models.py:967
msgid "Document invitations"
msgstr "Dokumenteinladungen"
#: build/lib/core/models.py:987 core/models.py:987
msgid "This email is already associated to a registered user."
msgstr "Diese E-Mail ist bereits einem registrierten Benutzer zugeordnet."
#: build/lib/impress/settings.py:236 impress/settings.py:236
msgid "English"
msgstr "Englisch"
#: build/lib/impress/settings.py:237 impress/settings.py:237
msgid "French"
msgstr "Französisch"
#: build/lib/impress/settings.py:238 impress/settings.py:238
msgid "German"
msgstr "Deutsch"

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-people\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-10-15 07:19+0000\n"
"PO-Revision-Date: 2024-10-15 07:23\n"
"POT-Creation-Date: 2024-12-17 15:50+0000\n"
"PO-Revision-Date: 2024-12-17 15:53\n"
"Last-Translator: \n"
"Language-Team: English\n"
"Language: en_US\n"
@@ -29,15 +29,35 @@ msgstr ""
msgid "Important dates"
msgstr ""
#: core/api/serializers.py:253
#: core/api/filters.py:16
msgid "Creator is me"
msgstr ""
#: core/api/filters.py:19
msgid "Favorite"
msgstr ""
#: core/api/filters.py:22
msgid "Title"
msgstr ""
#: core/api/serializers.py:307
msgid "A new document was created on your behalf!"
msgstr ""
#: core/api/serializers.py:311
msgid "You have been granted ownership of a new document:"
msgstr ""
#: core/api/serializers.py:414
msgid "Body"
msgstr ""
#: core/api/serializers.py:256
#: core/api/serializers.py:417
msgid "Body type"
msgstr ""
#: core/api/serializers.py:262
#: core/api/serializers.py:423
msgid "Format"
msgstr ""
@@ -49,6 +69,10 @@ msgstr ""
msgid "User info contained no recognizable user identification"
msgstr ""
#: core/authentication/backends.py:88
msgid "User account is disabled"
msgstr ""
#: core/models.py:62 core/models.py:69
msgid "Reader"
msgstr ""
@@ -65,224 +89,246 @@ msgstr ""
msgid "Owner"
msgstr ""
#: core/models.py:80
#: core/models.py:83
msgid "Restricted"
msgstr ""
#: core/models.py:84
#: core/models.py:87
msgid "Authenticated"
msgstr ""
#: core/models.py:86
#: core/models.py:89
msgid "Public"
msgstr ""
#: core/models.py:98
#: core/models.py:101
msgid "id"
msgstr ""
#: core/models.py:99
#: core/models.py:102
msgid "primary key for the record as UUID"
msgstr ""
#: core/models.py:105
#: core/models.py:108
msgid "created on"
msgstr ""
#: core/models.py:106
#: core/models.py:109
msgid "date and time at which a record was created"
msgstr ""
#: core/models.py:111
#: core/models.py:114
msgid "updated on"
msgstr ""
#: core/models.py:112
#: core/models.py:115
msgid "date and time at which a record was last updated"
msgstr ""
#: core/models.py:132
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters."
#: core/models.py:135
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
msgstr ""
#: core/models.py:138
#: core/models.py:141
msgid "sub"
msgstr ""
#: core/models.py:140
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
msgstr ""
#: core/models.py:149
msgid "full name"
msgstr ""
#: core/models.py:150
msgid "short name"
#: core/models.py:143
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
msgstr ""
#: core/models.py:152
msgid "full name"
msgstr ""
#: core/models.py:153
msgid "short name"
msgstr ""
#: core/models.py:155
msgid "identity email address"
msgstr ""
#: core/models.py:157
#: core/models.py:160
msgid "admin email address"
msgstr ""
#: core/models.py:164
#: core/models.py:167
msgid "language"
msgstr ""
#: core/models.py:165
#: core/models.py:168
msgid "The language in which the user wants to see the interface."
msgstr ""
#: core/models.py:171
#: core/models.py:174
msgid "The timezone in which the user wants to see times."
msgstr ""
#: core/models.py:174
#: core/models.py:177
msgid "device"
msgstr ""
#: core/models.py:176
#: core/models.py:179
msgid "Whether the user is a device or a real user."
msgstr ""
#: core/models.py:179
#: core/models.py:182
msgid "staff status"
msgstr ""
#: core/models.py:181
#: core/models.py:184
msgid "Whether the user can log into this admin site."
msgstr ""
#: core/models.py:184
#: core/models.py:187
msgid "active"
msgstr ""
#: core/models.py:187
#: core/models.py:190
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr ""
#: core/models.py:199
#: core/models.py:202
msgid "user"
msgstr ""
#: core/models.py:200
#: core/models.py:203
msgid "users"
msgstr ""
#: core/models.py:332 core/models.py:638
#: core/models.py:342 core/models.py:718
msgid "title"
msgstr ""
#: core/models.py:347
#: core/models.py:364
msgid "Document"
msgstr ""
#: core/models.py:348
#: core/models.py:365
msgid "Documents"
msgstr ""
#: core/models.py:351
#: core/models.py:368
msgid "Untitled Document"
msgstr ""
#: core/models.py:530
#, python-format
msgid "%(sender_name)s shared a document with you: %(document)s"
#: core/models.py:593
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr ""
#: core/models.py:574
#: core/models.py:597
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr ""
#: core/models.py:600
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr ""
#: core/models.py:623
msgid "Document/user link trace"
msgstr ""
#: core/models.py:575
#: core/models.py:624
msgid "Document/user link traces"
msgstr ""
#: core/models.py:581
#: core/models.py:630
msgid "A link trace already exists for this document/user."
msgstr ""
#: core/models.py:602
#: core/models.py:653
msgid "Document favorite"
msgstr ""
#: core/models.py:654
msgid "Document favorites"
msgstr ""
#: core/models.py:660
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: core/models.py:682
msgid "Document/user relation"
msgstr ""
#: core/models.py:603
#: core/models.py:683
msgid "Document/user relations"
msgstr ""
#: core/models.py:609
#: core/models.py:689
msgid "This user is already in this document."
msgstr ""
#: core/models.py:615
#: core/models.py:695
msgid "This team is already in this document."
msgstr ""
#: core/models.py:621 core/models.py:810
#: core/models.py:701 core/models.py:890
msgid "Either user or team must be set, not both."
msgstr ""
#: core/models.py:639
#: core/models.py:719
msgid "description"
msgstr ""
#: core/models.py:640
#: core/models.py:720
msgid "code"
msgstr ""
#: core/models.py:641
#: core/models.py:721
msgid "css"
msgstr ""
#: core/models.py:643
#: core/models.py:723
msgid "public"
msgstr ""
#: core/models.py:645
#: core/models.py:725
msgid "Whether this template is public for anyone to use."
msgstr ""
#: core/models.py:651
#: core/models.py:731
msgid "Template"
msgstr ""
#: core/models.py:652
#: core/models.py:732
msgid "Templates"
msgstr ""
#: core/models.py:791
#: core/models.py:871
msgid "Template/user relation"
msgstr ""
#: core/models.py:792
#: core/models.py:872
msgid "Template/user relations"
msgstr ""
#: core/models.py:798
#: core/models.py:878
msgid "This user is already in this template."
msgstr ""
#: core/models.py:804
#: core/models.py:884
msgid "This team is already in this template."
msgstr ""
#: core/models.py:827
#: core/models.py:907
msgid "email address"
msgstr ""
#: core/models.py:844
#: core/models.py:926
msgid "Document invitation"
msgstr ""
#: core/models.py:845
#: core/models.py:927
msgid "Document invitations"
msgstr ""
#: core/models.py:862
#: core/models.py:944
msgid "This email is already associated to a registered user."
msgstr ""
@@ -308,36 +354,25 @@ msgstr ""
msgid "This mail has been sent to %(email)s by <a href=\"%(href)s\">%(name)s</a>"
msgstr ""
#: core/templates/mail/html/invitation.html:159
#: core/templates/mail/html/invitation.html:162
#: core/templates/mail/text/invitation.txt:3
msgid "La Suite Numérique"
msgid "Logo email"
msgstr ""
#: core/templates/mail/html/invitation.html:189
#: core/templates/mail/text/invitation.txt:6
#, python-format
msgid " %(sender_name)s shared a document with you ! "
msgstr ""
#: core/templates/mail/html/invitation.html:196
#: core/templates/mail/text/invitation.txt:8
#, python-format
msgid " %(sender_name_email)s invited you with the role \"%(role)s\" on the following document : "
msgstr ""
#: core/templates/mail/html/invitation.html:205
#: core/templates/mail/html/invitation.html:209
#: core/templates/mail/text/invitation.txt:10
msgid "Open"
msgstr ""
#: core/templates/mail/html/invitation.html:222
#: 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:229
#: core/templates/mail/html/invitation.html:233
#: core/templates/mail/text/invitation.txt:16
msgid "Brought to you by La Suite Numérique"
#, python-format
msgid " Brought to you by %(brandname)s "
msgstr ""
#: core/templates/mail/text/hello.txt:8
@@ -345,11 +380,15 @@ msgstr ""
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
msgstr ""
#: impress/settings.py:176
#: impress/settings.py:236
msgid "English"
msgstr ""
#: impress/settings.py:177
#: impress/settings.py:237
msgid "French"
msgstr ""
#: impress/settings.py:238
msgid "German"
msgstr ""

View File

@@ -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-10-15 07:19+0000\n"
"PO-Revision-Date: 2024-10-15 07:23\n"
"POT-Creation-Date: 2025-01-15 21:00+0000\n"
"PO-Revision-Date: 2025-01-16 19:53\n"
"Last-Translator: \n"
"Language-Team: French\n"
"Language: fr_FR\n"
@@ -11,345 +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: fr\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 "Infos Personnelles"
#: core/admin.py:46
#: build/lib/core/admin.py:46 core/admin.py:46
msgid "Permissions"
msgstr "Permissions"
msgstr ""
#: core/admin.py:58
#: build/lib/core/admin.py:58 core/admin.py:58
msgid "Important dates"
msgstr "Dates importantes"
#: core/api/serializers.py:253
#: build/lib/core/api/filters.py:16 core/api/filters.py:16
msgid "Creator is me"
msgstr ""
#: build/lib/core/api/filters.py:19 core/api/filters.py:19
msgid "Favorite"
msgstr ""
#: build/lib/core/api/filters.py:22 core/api/filters.py:22
msgid "Title"
msgstr ""
#: build/lib/core/api/serializers.py:317 core/api/serializers.py:317
msgid "A new document was created on your behalf!"
msgstr "Un nouveau document a été créé pour vous !"
#: build/lib/core/api/serializers.py:321 core/api/serializers.py:321
msgid "You have been granted ownership of a new document:"
msgstr "Vous avez été déclaré propriétaire d'un nouveau document :"
#: build/lib/core/api/serializers.py:422 core/api/serializers.py:422
msgid "Body"
msgstr ""
#: core/api/serializers.py:256
#: build/lib/core/api/serializers.py:425 core/api/serializers.py:425
msgid "Body type"
msgstr ""
#: core/api/serializers.py:262
#: 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"
#: 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 "Lecteur"
#: 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 "Éditeur"
#: core/models.py:71
#: build/lib/core/models.py:72 core/models.py:72
msgid "Administrator"
msgstr "Administrateur"
#: core/models.py:72
#: build/lib/core/models.py:73 core/models.py:73
msgid "Owner"
msgstr "Propriétaire"
#: core/models.py:80
#: build/lib/core/models.py:84 core/models.py:84
msgid "Restricted"
msgstr "Restreint"
#: core/models.py:84
#: build/lib/core/models.py:88 core/models.py:88
msgid "Authenticated"
msgstr "Authentifié"
#: core/models.py:86
#: build/lib/core/models.py:90 core/models.py:90
msgid "Public"
msgstr "Public"
msgstr ""
#: core/models.py:98
#: build/lib/core/models.py:112 core/models.py:112
msgid "id"
msgstr ""
#: core/models.py:99
#: build/lib/core/models.py:113 core/models.py:113
msgid "primary key for the record as UUID"
msgstr ""
#: core/models.py:105
#: build/lib/core/models.py:119 core/models.py:119
msgid "created on"
msgstr ""
#: core/models.py:106
#: build/lib/core/models.py:120 core/models.py:120
msgid "date and time at which a record was created"
msgstr ""
#: core/models.py:111
#: build/lib/core/models.py:125 core/models.py:125
msgid "updated on"
msgstr ""
#: core/models.py:112
#: 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:132
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters."
#: 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 ""
#: core/models.py:138
#: 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 ""
#: build/lib/core/models.py:181 core/models.py:181
msgid "sub"
msgstr ""
#: core/models.py:140
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
#: 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:149
#: build/lib/core/models.py:192 core/models.py:192
msgid "full name"
msgstr ""
#: core/models.py:150
#: build/lib/core/models.py:193 core/models.py:193
msgid "short name"
msgstr ""
#: core/models.py:152
#: build/lib/core/models.py:195 core/models.py:195
msgid "identity email address"
msgstr ""
#: core/models.py:157
#: build/lib/core/models.py:200 core/models.py:200
msgid "admin email address"
msgstr ""
#: core/models.py:164
#: build/lib/core/models.py:207 core/models.py:207
msgid "language"
msgstr ""
#: core/models.py:165
#: 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:171
#: 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:174
#: build/lib/core/models.py:217 core/models.py:217
msgid "device"
msgstr ""
#: core/models.py:176
#: 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:179
#: build/lib/core/models.py:222 core/models.py:222
msgid "staff status"
msgstr ""
#: core/models.py:181
#: build/lib/core/models.py:224 core/models.py:224
msgid "Whether the user can log into this admin site."
msgstr ""
#: core/models.py:184
#: build/lib/core/models.py:227 core/models.py:227
msgid "active"
msgstr ""
#: core/models.py:187
#: 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:199
#: build/lib/core/models.py:242 core/models.py:242
msgid "user"
msgstr ""
#: core/models.py:200
#: build/lib/core/models.py:243 core/models.py:243
msgid "users"
msgstr ""
#: core/models.py:332 core/models.py:638
#: 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:347
#: build/lib/core/models.py:404 core/models.py:404
msgid "Document"
msgstr ""
#: core/models.py:348
#: build/lib/core/models.py:405 core/models.py:405
msgid "Documents"
msgstr ""
#: core/models.py:351
#: build/lib/core/models.py:408 core/models.py:408
msgid "Untitled Document"
msgstr ""
#: core/models.py:530
#, python-format
msgid "%(sender_name)s shared a document with you: %(document)s"
msgstr "%(sender_name)s a partagé un document avec vous: %(document)s"
#: build/lib/core/models.py:633 core/models.py:633
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} a partagé un document avec vous!"
#: core/models.py:574
#: 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 "{name} vous a invité avec le rôle \"{role}\" sur le document suivant:"
#: build/lib/core/models.py:640 core/models.py:640
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} a partagé un document avec vous: {title}"
#: build/lib/core/models.py:663 core/models.py:663
msgid "Document/user link trace"
msgstr ""
#: core/models.py:575
#: build/lib/core/models.py:664 core/models.py:664
msgid "Document/user link traces"
msgstr ""
#: core/models.py:581
#: build/lib/core/models.py:670 core/models.py:670
msgid "A link trace already exists for this document/user."
msgstr ""
#: core/models.py:602
#: build/lib/core/models.py:693 core/models.py:693
msgid "Document favorite"
msgstr ""
#: build/lib/core/models.py:694 core/models.py:694
msgid "Document favorites"
msgstr ""
#: 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 ""
#: build/lib/core/models.py:722 core/models.py:722
msgid "Document/user relation"
msgstr ""
#: core/models.py:603
#: build/lib/core/models.py:723 core/models.py:723
msgid "Document/user relations"
msgstr ""
#: core/models.py:609
#: build/lib/core/models.py:729 core/models.py:729
msgid "This user is already in this document."
msgstr ""
#: core/models.py:615
#: build/lib/core/models.py:735 core/models.py:735
msgid "This team is already in this document."
msgstr ""
#: core/models.py:621 core/models.py:810
#: 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:639
#: build/lib/core/models.py:759 core/models.py:759
msgid "description"
msgstr ""
#: core/models.py:640
#: build/lib/core/models.py:760 core/models.py:760
msgid "code"
msgstr ""
#: core/models.py:641
#: build/lib/core/models.py:761 core/models.py:761
msgid "css"
msgstr ""
#: core/models.py:643
#: build/lib/core/models.py:763 core/models.py:763
msgid "public"
msgstr ""
#: core/models.py:645
#: build/lib/core/models.py:765 core/models.py:765
msgid "Whether this template is public for anyone to use."
msgstr ""
#: core/models.py:651
#: build/lib/core/models.py:771 core/models.py:771
msgid "Template"
msgstr ""
#: core/models.py:652
#: build/lib/core/models.py:772 core/models.py:772
msgid "Templates"
msgstr ""
#: core/models.py:791
#: build/lib/core/models.py:911 core/models.py:911
msgid "Template/user relation"
msgstr ""
#: core/models.py:792
#: build/lib/core/models.py:912 core/models.py:912
msgid "Template/user relations"
msgstr ""
#: core/models.py:798
#: build/lib/core/models.py:918 core/models.py:918
msgid "This user is already in this template."
msgstr ""
#: core/models.py:804
#: build/lib/core/models.py:924 core/models.py:924
msgid "This team is already in this template."
msgstr ""
#: core/models.py:827
#: build/lib/core/models.py:947 core/models.py:947
msgid "email address"
msgstr ""
#: core/models.py:844
#: build/lib/core/models.py:966 core/models.py:966
msgid "Document invitation"
msgstr ""
#: core/models.py:845
#: build/lib/core/models.py:967 core/models.py:967
msgid "Document invitations"
msgstr ""
#: core/models.py:862
#: 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 <a href=\"%(href)s\">%(name)s</a>"
msgstr ""
#: core/templates/mail/html/invitation.html:159
#: core/templates/mail/text/invitation.txt:3
msgid "La Suite Numérique"
msgstr ""
#: core/templates/mail/html/invitation.html:189
#: core/templates/mail/text/invitation.txt:6
#, python-format
msgid " %(sender_name)s shared a document with you ! "
msgstr " %(sender_name)s a partagé un document avec vous ! "
#: core/templates/mail/html/invitation.html:196
#: core/templates/mail/text/invitation.txt:8
#, python-format
msgid " %(sender_name_email)s invited you with the role \"%(role)s\" on the following document : "
msgstr " %(sender_name_email)s vous a invité avec le rôle \"%(role)s\" sur le document suivant : "
#: core/templates/mail/html/invitation.html:205
#: core/templates/mail/text/invitation.txt:10
msgid "Open"
msgstr "Ouvrir"
#: core/templates/mail/html/invitation.html:222
#: 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 " Docs, votre nouvel outil incontournable pour organiser, partager et collaborer sur vos documents en équipe. "
#: core/templates/mail/html/invitation.html:229
#: core/templates/mail/text/invitation.txt:16
msgid "Brought to you by La Suite Numérique"
msgstr "Proposé par La Suite Numérique"
#: 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:176
#: build/lib/impress/settings.py:236 impress/settings.py:236
msgid "English"
msgstr ""
#: impress/settings.py:177
#: build/lib/impress/settings.py:237 impress/settings.py:237
msgid "French"
msgstr ""
#: build/lib/impress/settings.py:238 impress/settings.py:238
msgid "German"
msgstr ""

View File

@@ -0,0 +1,352 @@
msgid ""
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"
"Last-Translator: \n"
"Language-Team: Dutch\n"
"Language: nl_NL\n"
"MIME-Version: 1.0\n"
"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-docs\n"
"X-Crowdin-Project-ID: 754523\n"
"X-Crowdin-Language: nl\n"
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:33 core/admin.py:33
msgid "Personal info"
msgstr ""
#: build/lib/core/admin.py:46 core/admin.py:46
msgid "Permissions"
msgstr ""
#: build/lib/core/admin.py:58 core/admin.py:58
msgid "Important dates"
msgstr ""
#: build/lib/core/api/filters.py:16 core/api/filters.py:16
msgid "Creator is me"
msgstr ""
#: build/lib/core/api/filters.py:19 core/api/filters.py:19
msgid "Favorite"
msgstr ""
#: build/lib/core/api/filters.py:22 core/api/filters.py:22
msgid "Title"
msgstr ""
#: build/lib/core/api/serializers.py:317 core/api/serializers.py:317
msgid "A new document was created on your behalf!"
msgstr ""
#: build/lib/core/api/serializers.py:321 core/api/serializers.py:321
msgid "You have been granted ownership of a new document:"
msgstr ""
#: build/lib/core/api/serializers.py:422 core/api/serializers.py:422
msgid "Body"
msgstr ""
#: build/lib/core/api/serializers.py:425 core/api/serializers.py:425
msgid "Body type"
msgstr ""
#: build/lib/core/api/serializers.py:431 core/api/serializers.py:431
msgid "Format"
msgstr ""
#: build/lib/core/authentication/backends.py:61
#: core/authentication/backends.py:61
msgid "Invalid response format or token verification failed"
msgstr ""
#: build/lib/core/authentication/backends.py:108
#: core/authentication/backends.py:108
msgid "User account is disabled"
msgstr ""
#: build/lib/core/models.py:63 build/lib/core/models.py:70 core/models.py:63
#: core/models.py:70
msgid "Reader"
msgstr ""
#: build/lib/core/models.py:64 build/lib/core/models.py:71 core/models.py:64
#: core/models.py:71
msgid "Editor"
msgstr ""
#: build/lib/core/models.py:72 core/models.py:72
msgid "Administrator"
msgstr ""
#: build/lib/core/models.py:73 core/models.py:73
msgid "Owner"
msgstr ""
#: build/lib/core/models.py:84 core/models.py:84
msgid "Restricted"
msgstr ""
#: build/lib/core/models.py:88 core/models.py:88
msgid "Authenticated"
msgstr ""
#: build/lib/core/models.py:90 core/models.py:90
msgid "Public"
msgstr ""
#: build/lib/core/models.py:112 core/models.py:112
msgid "id"
msgstr ""
#: build/lib/core/models.py:113 core/models.py:113
msgid "primary key for the record as UUID"
msgstr ""
#: build/lib/core/models.py:119 core/models.py:119
msgid "created on"
msgstr ""
#: build/lib/core/models.py:120 core/models.py:120
msgid "date and time at which a record was created"
msgstr ""
#: build/lib/core/models.py:125 core/models.py:125
msgid "updated on"
msgstr ""
#: build/lib/core/models.py:126 core/models.py:126
msgid "date and time at which a record was last updated"
msgstr ""
#: 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 ""
#: build/lib/core/models.py:181 core/models.py:181
msgid "sub"
msgstr ""
#: build/lib/core/models.py:183 core/models.py:183
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
msgstr ""
#: build/lib/core/models.py:192 core/models.py:192
msgid "full name"
msgstr ""
#: build/lib/core/models.py:193 core/models.py:193
msgid "short name"
msgstr ""
#: build/lib/core/models.py:195 core/models.py:195
msgid "identity email address"
msgstr ""
#: build/lib/core/models.py:200 core/models.py:200
msgid "admin email address"
msgstr ""
#: build/lib/core/models.py:207 core/models.py:207
msgid "language"
msgstr ""
#: build/lib/core/models.py:208 core/models.py:208
msgid "The language in which the user wants to see the interface."
msgstr ""
#: build/lib/core/models.py:214 core/models.py:214
msgid "The timezone in which the user wants to see times."
msgstr ""
#: build/lib/core/models.py:217 core/models.py:217
msgid "device"
msgstr ""
#: build/lib/core/models.py:219 core/models.py:219
msgid "Whether the user is a device or a real user."
msgstr ""
#: build/lib/core/models.py:222 core/models.py:222
msgid "staff status"
msgstr ""
#: build/lib/core/models.py:224 core/models.py:224
msgid "Whether the user can log into this admin site."
msgstr ""
#: build/lib/core/models.py:227 core/models.py:227
msgid "active"
msgstr ""
#: 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 ""
#: build/lib/core/models.py:242 core/models.py:242
msgid "user"
msgstr ""
#: build/lib/core/models.py:243 core/models.py:243
msgid "users"
msgstr ""
#: build/lib/core/models.py:382 build/lib/core/models.py:758 core/models.py:382
#: core/models.py:758
msgid "title"
msgstr ""
#: build/lib/core/models.py:404 core/models.py:404
msgid "Document"
msgstr ""
#: build/lib/core/models.py:405 core/models.py:405
msgid "Documents"
msgstr ""
#: build/lib/core/models.py:408 core/models.py:408
msgid "Untitled Document"
msgstr ""
#: build/lib/core/models.py:633 core/models.py:633
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr ""
#: 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 ""
#: build/lib/core/models.py:640 core/models.py:640
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr ""
#: build/lib/core/models.py:663 core/models.py:663
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:664 core/models.py:664
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:670 core/models.py:670
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:693 core/models.py:693
msgid "Document favorite"
msgstr ""
#: build/lib/core/models.py:694 core/models.py:694
msgid "Document favorites"
msgstr ""
#: 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 ""
#: build/lib/core/models.py:722 core/models.py:722
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:723 core/models.py:723
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:729 core/models.py:729
msgid "This user is already in this document."
msgstr ""
#: build/lib/core/models.py:735 core/models.py:735
msgid "This team is already in this document."
msgstr ""
#: 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 ""
#: build/lib/core/models.py:759 core/models.py:759
msgid "description"
msgstr ""
#: build/lib/core/models.py:760 core/models.py:760
msgid "code"
msgstr ""
#: build/lib/core/models.py:761 core/models.py:761
msgid "css"
msgstr ""
#: build/lib/core/models.py:763 core/models.py:763
msgid "public"
msgstr ""
#: build/lib/core/models.py:765 core/models.py:765
msgid "Whether this template is public for anyone to use."
msgstr ""
#: build/lib/core/models.py:771 core/models.py:771
msgid "Template"
msgstr ""
#: build/lib/core/models.py:772 core/models.py:772
msgid "Templates"
msgstr ""
#: build/lib/core/models.py:911 core/models.py:911
msgid "Template/user relation"
msgstr ""
#: build/lib/core/models.py:912 core/models.py:912
msgid "Template/user relations"
msgstr ""
#: build/lib/core/models.py:918 core/models.py:918
msgid "This user is already in this template."
msgstr ""
#: build/lib/core/models.py:924 core/models.py:924
msgid "This team is already in this template."
msgstr ""
#: build/lib/core/models.py:947 core/models.py:947
msgid "email address"
msgstr ""
#: build/lib/core/models.py:966 core/models.py:966
msgid "Document invitation"
msgstr ""
#: build/lib/core/models.py:967 core/models.py:967
msgid "Document invitations"
msgstr ""
#: build/lib/core/models.py:987 core/models.py:987
msgid "This email is already associated to a registered user."
msgstr ""
#: build/lib/impress/settings.py:236 impress/settings.py:236
msgid "English"
msgstr ""
#: build/lib/impress/settings.py:237 impress/settings.py:237
msgid "French"
msgstr ""
#: build/lib/impress/settings.py:238 impress/settings.py:238
msgid "German"
msgstr ""

View File

@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "impress"
version = "1.7.0"
version = "2.0.1"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -17,28 +17,29 @@ classifiers = [
"License :: OSI Approved :: MIT License",
"Natural Language :: English",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.12",
]
description = "An application to print markdown to pdf from a set of managed templates."
keywords = ["Django", "Contacts", "Templates", "RBAC"]
license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.10"
requires-python = ">=3.12"
dependencies = [
"boto3==1.35.44",
"boto3==1.35.90",
"Brotli==1.1.0",
"celery[redis]==5.4.0",
"django-configurations==2.5.1",
"django-cors-headers==4.5.0",
"django-cors-headers==4.6.0",
"django-countries==7.6.1",
"django-filter==24.3",
"django-parler==2.3",
"redis==5.1.1",
"redis==5.2.1",
"django-redis==5.4.0",
"django-storages[s3]==1.14.4",
"django-timezone-field>=5.1",
"django==5.1.2",
"django==5.1.5",
"djangorestframework==3.15.2",
"drf_spectacular==0.27.2",
"drf_spectacular==0.28.0",
"dockerflow==2024.4.2",
"easy_thumbnails==2.10",
"factory_boy==3.3.1",
@@ -46,17 +47,17 @@ dependencies = [
"jsonschema==4.23.0",
"markdown==3.7",
"nested-multipart-parser==1.5.0",
"openai==1.52.0",
"openai==1.58.1",
"psycopg[binary]==3.2.3",
"PyJWT==2.9.0",
"PyJWT==2.10.1",
"pypandoc==1.14",
"python-frontmatter==1.1.0",
"python-magic==0.4.27",
"requests==2.32.3",
"sentry-sdk==2.17.0",
"sentry-sdk==2.19.2",
"url-normalize==1.4.3",
"WeasyPrint>=60.2",
"whitenoise==6.7.0",
"whitenoise==6.8.2",
"mozilla-django-oidc==4.0.1",
]
@@ -69,20 +70,20 @@ dependencies = [
[project.optional-dependencies]
dev = [
"django-extensions==3.2.3",
"drf-spectacular-sidecar==2024.7.1",
"drf-spectacular-sidecar==2024.12.1",
"freezegun==1.5.1",
"ipdb==0.13.13",
"ipython==8.28.0",
"pyfakefs==5.7.1",
"ipython==8.31.0",
"pyfakefs==5.7.3",
"pylint-django==2.6.1",
"pylint==3.3.1",
"pytest-cov==5.0.0",
"pylint==3.3.3",
"pytest-cov==6.0.0",
"pytest-django==4.9.0",
"pytest==8.3.3",
"pytest==8.3.4",
"pytest-icdiff==0.9",
"pytest-xdist==3.6.1",
"responses==0.25.3",
"ruff==0.7.0",
"ruff==0.8.4",
"types-requests==2.32.0.20241016",
]
@@ -127,6 +128,7 @@ select = [
[tool.ruff.lint.isort]
section-order = ["future","standard-library","django","third-party","impress","first-party","local-folder"]
sections = { impress=["core"], django=["django"] }
extra-standard-library = ["tomllib"]
[tool.ruff.lint.per-file-ignores]
"**/tests/*" = ["S", "SLF"]

View File

@@ -1,33 +1,3 @@
FROM node:20-alpine AS frontend-deps-y-provider
WORKDIR /home/frontend/
COPY ./src/frontend/package.json ./package.json
COPY ./src/frontend/yarn.lock ./yarn.lock
COPY ./src/frontend/servers/y-provider/package.json ./servers/y-provider/package.json
COPY ./src/frontend/packages/eslint-config-impress/package.json ./packages/eslint-config-impress/package.json
RUN yarn install
COPY ./src/frontend/ .
# Copy entrypoint
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
# ---- y-provider ----
FROM frontend-deps-y-provider AS y-provider
WORKDIR /home/frontend/servers/y-provider
RUN yarn build
# Un-privileged user running the application
ARG DOCKER_USER
USER ${DOCKER_USER}
ENTRYPOINT [ "/usr/local/bin/entrypoint" ]
CMD ["yarn", "start"]
FROM node:20-alpine AS frontend-deps
WORKDIR /home/frontend/
@@ -40,7 +10,9 @@ COPY ./src/frontend/packages/eslint-config-impress/package.json ./packages/eslin
RUN yarn install --frozen-lockfile
COPY .dockerignore ./.dockerignore
COPY ./src/frontend/ .
COPY ./src/frontend/.prettierrc.js ./.prettierrc.js
COPY ./src/frontend/packages/eslint-config-impress ./packages/eslint-config-impress
COPY ./src/frontend/apps/impress ./apps/impress
### ---- Front-end builder image ----
FROM frontend-deps AS impress
@@ -61,18 +33,9 @@ FROM impress AS impress-builder
WORKDIR /home/frontend/apps/impress
ARG FRONTEND_THEME
ENV NEXT_PUBLIC_THEME=${FRONTEND_THEME}
ARG Y_PROVIDER_URL
ENV NEXT_PUBLIC_Y_PROVIDER_URL=${Y_PROVIDER_URL}
ARG API_ORIGIN
ENV NEXT_PUBLIC_API_ORIGIN=${API_ORIGIN}
ARG MEDIA_URL
ENV NEXT_PUBLIC_MEDIA_URL=${MEDIA_URL}
ARG SW_DEACTIVATED
ENV NEXT_PUBLIC_SW_DEACTIVATED=${SW_DEACTIVATED}

View File

@@ -36,18 +36,25 @@ export const createDoc = async (
await page
.getByRole('button', {
name: 'Create a new document',
name: 'New doc',
})
.click();
await page.getByRole('heading', { name: 'Untitled document' }).click();
await page.keyboard.type(randomDocs[i]);
await page.getByText('Created at ').click();
const input = page.getByRole('textbox', { name: 'doc title input' });
await input.click();
await input.fill(randomDocs[i]);
await input.blur();
}
return randomDocs;
};
export const verifyDocName = async (page: Page, docName: string) => {
const input = page.getByRole('textbox', { name: 'doc title input' });
await expect(input).toBeVisible();
await expect(input).toHaveText(docName);
};
export const addNewMember = async (
page: Page,
index: number,
@@ -60,7 +67,9 @@ export const addNewMember = async (
response.status() === 200,
);
const inputSearch = page.getByLabel(/Find a member to add to the document/);
const inputSearch = page.getByRole('combobox', {
name: 'Quick search input',
});
// Select a new user
await inputSearch.fill(fillText);
@@ -75,13 +84,9 @@ export const addNewMember = async (
await page.getByRole('option', { name: users[index].email }).click();
// Choose a role
await page.getByRole('combobox', { name: /Choose a role/ }).click();
await page.getByRole('option', { name: role }).click();
await page.getByRole('button', { name: 'Validate' }).click();
await expect(
page.getByText(`User ${users[index].email} added to the document.`),
).toBeVisible();
await page.getByLabel('doc-role-dropdown').click();
await page.getByRole('button', { name: role }).click();
await page.getByRole('button', { name: 'Invite' }).click();
return users[index].email;
};
@@ -97,24 +102,22 @@ export const goToGridDoc = async (
const header = page.locator('header').first();
await header.locator('h2').getByText('Docs').click();
const datagrid = page.getByLabel('Datagrid of the documents page 1');
const datagridTable = datagrid.getByRole('table');
const docsGrid = page.getByTestId('docs-grid');
await expect(docsGrid).toBeVisible();
await expect(docsGrid.getByTestId('grid-loader')).toBeHidden();
await expect(datagrid.getByLabel('Loading data')).toBeHidden({
timeout: 10000,
});
const rows = docsGrid.getByRole('row');
const rows = datagridTable.getByRole('row');
const row = title
? rows.filter({
hasText: title,
})
: rows.nth(nthRow);
const docTitleCell = row.getByRole('cell').nth(1);
const docTitle = await docTitleCell.textContent();
await expect(row).toBeVisible();
const docTitleContent = row.locator('[aria-describedby="doc-title"]').first();
const docTitle = await docTitleContent.textContent();
expect(docTitle).toBeDefined();
await row.getByRole('link').first().click();
@@ -144,7 +147,7 @@ export const mockedDocument = async (page: Page, json: object) => {
versions_destroy: false,
versions_list: true,
versions_retrieve: true,
manage_accesses: false, // Means not admin
accesses_manage: false, // Means not admin
update: false,
partial_update: false, // Means not editor
retrieve: true,

View File

@@ -0,0 +1,163 @@
import path from 'path';
import { expect, test } from '@playwright/test';
import { createDoc, verifyDocName } from './common';
const config = {
CRISP_WEBSITE_ID: null,
COLLABORATION_WS_URL: 'ws://localhost:8083/collaboration/ws/',
ENVIRONMENT: 'development',
FRONTEND_THEME: 'dsfr',
MEDIA_BASE_URL: 'http://localhost:8083',
LANGUAGES: [
['en-us', 'English'],
['fr-fr', 'French'],
['de-de', 'German'],
],
LANGUAGE_CODE: 'en-us',
POSTHOG_KEY: {},
SENTRY_DSN: null,
};
test.describe('Config', () => {
test('it checks the config api is called', async ({ page }) => {
const responsePromise = page.waitForResponse(
(response) =>
response.url().includes('/config/') && response.status() === 200,
);
await page.goto('/');
const response = await responsePromise;
expect(response.ok()).toBeTruthy();
expect(await response.json()).toStrictEqual(config);
});
test('it checks that sentry is trying to init from config endpoint', async ({
page,
}) => {
await page.route('**/api/v1.0/config/', async (route) => {
const request = route.request();
if (request.method().includes('GET')) {
await route.fulfill({
json: {
...config,
SENTRY_DSN: 'https://sentry.io/123',
},
});
} else {
await route.continue();
}
});
const invalidMsg = 'Invalid Sentry Dsn: https://sentry.io/123';
const consoleMessage = page.waitForEvent('console', {
timeout: 5000,
predicate: (msg) => msg.text().includes(invalidMsg),
});
await page.goto('/');
expect((await consoleMessage).text()).toContain(invalidMsg);
});
test('it checks that theme is configured from config endpoint', async ({
page,
}) => {
const responsePromise = page.waitForResponse(
(response) =>
response.url().includes('/config/') && response.status() === 200,
);
await page.goto('/');
const response = await responsePromise;
expect(response.ok()).toBeTruthy();
const jsonResponse = await response.json();
expect(jsonResponse.FRONTEND_THEME).toStrictEqual('dsfr');
const footer = page.locator('footer').first();
// alt 'Gouvernement Logo' comes from the theme
await expect(footer.getByAltText('Gouvernement Logo')).toBeVisible();
});
test('it checks that media server is configured from config endpoint', async ({
page,
browserName,
}) => {
await page.goto('/');
await createDoc(page, 'doc-media', browserName, 1);
const fileChooserPromise = page.waitForEvent('filechooser');
await page.locator('.bn-block-outer').last().fill('Anything');
await page.locator('.bn-block-outer').last().fill('/');
await page.getByText('Resizable image with caption').click();
await page.getByText('Upload image').click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(
path.join(__dirname, 'assets/logo-suite-numerique.png'),
);
const image = page.getByRole('img', { name: 'logo-suite-numerique.png' });
await expect(image).toBeVisible();
// Check src of image
expect(await image.getAttribute('src')).toMatch(
/http:\/\/localhost:8083\/media\/.*\/attachments\/.*.png/,
);
});
test('it checks that collaboration server is configured from config endpoint', async ({
page,
browserName,
}) => {
const webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
return webSocket.url().includes('ws://localhost:8083/collaboration/ws/');
});
await page.goto('/');
const randomDoc = await createDoc(
page,
'doc-collaboration',
browserName,
1,
);
await verifyDocName(page, randomDoc[0]);
const webSocket = await webSocketPromise;
expect(webSocket.url()).toContain('ws://localhost:8083/collaboration/ws/');
});
test('it checks that Crisp is trying to init from config endpoint', async ({
page,
}) => {
await page.route('**/api/v1.0/config/', async (route) => {
const request = route.request();
if (request.method().includes('GET')) {
await route.fulfill({
json: {
...config,
CRISP_WEBSITE_ID: '1234',
},
});
} else {
await route.continue();
}
});
await page.goto('/');
await expect(
page.locator('#crisp-chatbox').getByText('Invalid website'),
).toBeVisible();
});
});

View File

@@ -1,6 +1,12 @@
import { expect, test } from '@playwright/test';
import { createDoc } from './common';
import {
createDoc,
goToGridDoc,
keyCloakSignIn,
randomName,
verifyDocName,
} from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
@@ -18,14 +24,55 @@ test.describe('Doc Create', () => {
const header = page.locator('header').first();
await header.locator('h2').getByText('Docs').click();
const datagrid = page.getByLabel('Datagrid of the documents page 1');
const datagridTable = datagrid.getByRole('table');
await expect(page.getByTestId('grid-loader')).toBeVisible();
await expect(datagrid.getByLabel('Loading data')).toBeHidden({
timeout: 10000,
});
await expect(datagridTable.getByText(docTitle)).toBeVisible({
timeout: 5000,
});
const docsGrid = page.getByTestId('docs-grid');
await expect(docsGrid).toBeVisible();
await expect(page.getByTestId('grid-loader')).toBeHidden();
await expect(docsGrid.getByText(docTitle)).toBeVisible();
});
});
test.describe('Doc Create: Not loggued', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('it creates a doc server way', async ({
page,
browserName,
request,
}) => {
const markdown = `This is a normal text\n\n# And this is a large heading`;
const [title] = randomName('My server way doc create', browserName, 1);
const data = {
title,
content: markdown,
sub: `user@${browserName}.e2e`,
email: `user@${browserName}.e2e`,
};
const newDoc = await request.post(
`http://localhost:8071/api/v1.0/documents/create-for-owner/`,
{
data,
headers: {
Authorization: 'Bearer test-e2e',
format: 'json',
},
},
);
expect(newDoc.ok()).toBeTruthy();
await keyCloakSignIn(page, browserName);
await goToGridDoc(page, { title });
await verifyDocName(page, title);
const editor = page.locator('.ProseMirror');
await expect(editor.getByText('This is a normal text')).toBeVisible();
await expect(
editor.locator('h1').getByText('And this is a large heading'),
).toBeVisible();
});
});

View File

@@ -2,7 +2,12 @@ import path from 'path';
import { expect, test } from '@playwright/test';
import { createDoc, goToGridDoc, mockedDocument } from './common';
import {
createDoc,
goToGridDoc,
mockedDocument,
verifyDocName,
} from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
@@ -81,26 +86,67 @@ test.describe('Doc Editor', () => {
).toBeVisible();
});
test('checks the Doc is connected to the provider server', async ({
/**
* We check:
* - connection to the collaborative server
* - signal of the backend to the collaborative server (connection should close)
* - reconnection to the collaborative server
*/
test('checks the connection with collaborative server', async ({
page,
browserName,
}) => {
const webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
return webSocket.url().includes('ws://localhost:4444/');
let webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
return webSocket
.url()
.includes('ws://localhost:8083/collaboration/ws/?room=');
});
const randomDoc = await createDoc(page, 'doc-editor', browserName, 1);
await expect(page.locator('h2').getByText(randomDoc[0])).toBeVisible();
await verifyDocName(page, randomDoc[0]);
const webSocket = await webSocketPromise;
expect(webSocket.url()).toContain('ws://localhost:4444/');
let webSocket = await webSocketPromise;
expect(webSocket.url()).toContain(
'ws://localhost:8083/collaboration/ws/?room=',
);
const framesentPromise = webSocket.waitForEvent('framesent');
// Is connected
let framesentPromise = webSocket.waitForEvent('framesent');
await page.locator('.ProseMirror.bn-editor').click();
await page.locator('.ProseMirror.bn-editor').fill('Hello World');
const framesent = await framesentPromise;
let framesent = await framesentPromise;
expect(framesent.payload).not.toBeNull();
await page.getByRole('button', { name: 'Share' }).click();
const selectVisibility = page.getByLabel('Visibility', { exact: true });
// When the visibility is changed, the ws should closed the connection (backend signal)
const wsClosePromise = webSocket.waitForEvent('close');
await selectVisibility.click();
await page
.getByRole('button', {
name: 'Connected',
})
.click();
// Assert that the doc reconnects to the ws
const wsClose = await wsClosePromise;
expect(wsClose.isClosed()).toBeTruthy();
// Checkt the ws is connected again
webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
return webSocket
.url()
.includes('ws://localhost:8083/collaboration/ws/?room=');
});
webSocket = await webSocketPromise;
framesentPromise = webSocket.waitForEvent('framesent');
framesent = await framesentPromise;
expect(framesent.payload).not.toBeNull();
});
@@ -110,7 +156,7 @@ test.describe('Doc Editor', () => {
}) => {
const randomDoc = await createDoc(page, 'doc-markdown', browserName, 1);
await expect(page.locator('h2').getByText(randomDoc[0])).toBeVisible();
await verifyDocName(page, randomDoc[0]);
const editor = page.locator('.ProseMirror');
await editor.click();
@@ -135,7 +181,7 @@ test.describe('Doc Editor', () => {
}) => {
// Check the first doc
const [firstDoc] = await createDoc(page, 'doc-switch-1', browserName, 1);
await expect(page.locator('h2').getByText(firstDoc)).toBeVisible();
await verifyDocName(page, firstDoc);
const editor = page.locator('.ProseMirror');
await editor.click();
@@ -144,7 +190,8 @@ test.describe('Doc Editor', () => {
// Check the second doc
const [secondDoc] = await createDoc(page, 'doc-switch-2', browserName, 1);
await expect(page.locator('h2').getByText(secondDoc)).toBeVisible();
await verifyDocName(page, secondDoc);
await expect(editor.getByText('Hello World Doc 1')).toBeHidden();
await editor.click();
await editor.fill('Hello World Doc 2');
@@ -154,9 +201,18 @@ test.describe('Doc Editor', () => {
await goToGridDoc(page, {
title: firstDoc,
});
await expect(page.locator('h2').getByText(firstDoc)).toBeVisible();
await verifyDocName(page, firstDoc);
await expect(editor.getByText('Hello World Doc 2')).toBeHidden();
await expect(editor.getByText('Hello World Doc 1')).toBeVisible();
await page
.getByRole('button', {
name: 'New doc',
})
.click();
await expect(editor.getByText('Hello World Doc 1')).toBeHidden();
await expect(editor.getByText('Hello World Doc 2')).toBeHidden();
});
test('it saves the doc when we change pages', async ({
@@ -165,7 +221,7 @@ test.describe('Doc Editor', () => {
}) => {
// Check the first doc
const [doc] = await createDoc(page, 'doc-saves-change', browserName, 1);
await expect(page.locator('h2').getByText(doc)).toBeVisible();
await verifyDocName(page, doc);
const editor = page.locator('.ProseMirror');
await editor.click();
@@ -176,7 +232,7 @@ test.describe('Doc Editor', () => {
nthRow: 2,
});
await expect(page.locator('h2').getByText(secondDoc)).toBeVisible();
await verifyDocName(page, secondDoc);
await goToGridDoc(page, {
title: doc,
@@ -191,7 +247,8 @@ test.describe('Doc Editor', () => {
// Check the first doc
const doc = await goToGridDoc(page);
await expect(page.locator('h2').getByText(doc)).toBeVisible();
await verifyDocName(page, doc);
const editor = page.locator('.ProseMirror');
await editor.click();
@@ -215,7 +272,7 @@ test.describe('Doc Editor', () => {
versions_destroy: false,
versions_list: true,
versions_retrieve: true,
manage_accesses: false, // Means not admin
accesses_manage: false, // Means not admin
update: false,
partial_update: false, // Means not editor
retrieve: true,
@@ -224,13 +281,14 @@ test.describe('Doc Editor', () => {
await goToGridDoc(page);
await expect(
page.getByText('Read only, you cannot edit this document.'),
).toBeVisible();
const card = page.getByLabel('It is the card information');
await expect(card).toBeVisible();
await expect(card.getByText('Reader')).toBeVisible();
});
test('it adds an image to the doc editor', async ({ page }) => {
await goToGridDoc(page);
test('it adds an image to the doc editor', async ({ page, browserName }) => {
await createDoc(page, 'doc-image', browserName, 1);
const fileChooserPromise = page.waitForEvent('filechooser');

Some files were not shown because too many files have changed in this diff Show More