Compare commits

..

190 Commits

Author SHA1 Message Date
Lebaud Antoine 9eb4a1e9e3 🔧(backend) udpate email invitation sender
Replace the placeholder setting by a randomly picked 'do not reply'
email adress. Might be improved later on.
2024-04-02 23:26:38 +02:00
Lebaud Antoine d8b6bcc9eb 📱(frontend) handle extremely long name
Currently, with a very long group name (~100 char), text was overflowing
the flex elements.

I reworked the current layout to prevent this issue in Team info and
Team pannel.

I needed to debug some buggy flex layout issues. I decided to refactor
some styling, and ended up doing a big refactoring.

Working with Box and Text components cause high friction when
manipulating them. They use mix styling approaches ... which is quite
misleading for any developper joining the project.

It's mainly due to the fact that creating such generic wrappers,
relying on (heavy) interpolation functions might be against
styled component spirit.

I love manipulating inline styling, but styled component might not
be the best tool to take this approach.

I tried to align main screen with Johann's design to be pixel-perfect.
However, the actual design is not appropriate for responsive screen.
2024-04-02 23:21:44 +02:00
Lebaud Antoine 418bed945a 🩹(frontend) display email or name in delete modal
User's name could be null, leading to an empty text box when opening the
delete modal.

Prevent it by displaying user's name or email when deleting an user.
2024-04-02 22:53:43 +02:00
Anthony LC 1223732fa9 🐛(CI) improve caching
When we restored the frontend cache, we were restoring
old code as well, we don't want that, we want to only
restore the node_modules.
This commit fixes that.
We improve the build-front caching as well, to cache
only the desk build app.
2024-04-02 16:12:32 +02:00
Anthony LC e8180bc49b 💄(app-desk) retouch design grid members
The update of Cunningham seems to have lightly
broken the design of the grid members.
This commit fixes the design of the grid members.
2024-04-02 16:12:32 +02:00
Anthony LC ffe997a658 ️(frontend) clean yarn.lock
The yarn.lock file get full of garbage and old
dependencies after a while. This commit cleans it.
2024-04-02 16:12:32 +02:00
renovate[bot] d4c23ce5b9 ⬆️(dependencies) update js dependencies 2024-04-02 16:12:32 +02:00
Lebaud Antoine 5ed05b96a5 🩹(frontend) disable submission button while a request is pending
If any request is taking longer than expected, the user could interact with
the frontend, thinking the previous submission was not taken into account,
and would re-submit a request.

It happened to me while sending an invitation. Replaying request can either
lead to inconsistencies in db for the user, or to errors in requests' response.

I propose to disable all interactive button while submitting a request.
It's good enough for this actual sprint, these types of interactivity issues
could be improved later on.
2024-04-02 15:07:59 +02:00
Anthony LC df15b41a87 🚸(app-desk) cannot select user or email already selected
- Add the teamid to the useUsers query, to not get
  the users that are already in the team
- Add a check to not select a user or email
  that is already selected
2024-04-02 11:45:27 +02:00
Anthony LC 591045b0ec ️(app-desk) clean build pages
Prob:
Next.js transpiles all the files present in the
`pages` directory. But we don't want to transpile
the providers neither the Layout components.

Solution:
We export these components to a core folder.
2024-04-02 11:34:40 +02:00
Sabrina Demagny 775b32ff45 (backend) enhance search users to add in a team
Exclude from the result all users already members of the current team
2024-04-02 11:12:08 +02:00
renovate[bot] e9a628f816 ⬆️(dependencies) update python dependencies 2024-04-02 11:11:42 +02:00
Anthony LC bf1450cfa7 ️(app-desk) remove firefox from e2e tests
- Remove Firefox testing, Firefox browser seems unstable with
Playwright, most of the time the failing tests are the one
with Firefox, Firefox is only 3% of the browser.
- Improve some naming in the test creation to avoid
conflit name.
2024-04-02 10:54:04 +02:00
Anthony LC 480d8277cc ️(CI) persist the frontend between jobs
To improve the speed of the CI, we cache the frontend
install. It will even be reused between pull request
until the yarn.lock has a change.
We cache as well the desk build app, in another cache,
this cache persist only per workflow. It will increase the
speed if we have e2e flaky tests and that we have to relaunch
the e2e job.
2024-04-02 10:54:04 +02:00
Lebaud Antoine 97cec8901c 📱(frontend) enhance team info style to avoid layout shift
On small screens, padding and alignment were causing texts to render wrapped.
It might be a personal choice, but I decided to give more space to the text,
thus avoiding text to wrap and ending up in increasing team info's height.

This issue arises when closing / opening the panel menu.
2024-03-29 15:32:52 +01:00
Lebaud Antoine e8aba29a68 (frontend) make the team pannel closable
Team panel was quite wide, and took too much space on small screens.
Johann decided to make it closable. Thus, user that needs to navigate quickly
between their team can open it, use it and then close it.

This animation is a first draft, and should be improved later on. Also, some
accessibility issues might ariase, I have ignored them in this first draft.
2024-03-29 15:32:52 +01:00
Lebaud Antoine ebaa1360e7 🍱(frontend) update icon button 'add team' in teams panel
Johann recently changed the icon use by the 'add team' button. He preferred
having a square one to contrast more with the 'collapse menu' icon that will
be added later on.
2024-03-29 15:32:52 +01:00
Anthony LC 6d8e05b746 🚨(app-desk) remove warning next.js about css from Head
Importing the css from Head was causing a flickering
effect on the button, because of the time the css
load. Next.js was emitting as well a warning about
css being loaded from the Head component.
We moved the css import to the _document.tsx file
as recommended by the Next.js documentation.
2024-03-29 14:30:34 +01:00
Sabrina Demagny e7049632ab 📝(frontend) add missing info in the README
add missing info about how to run front and accesses
2024-03-29 10:05:19 +01:00
daproclaima a54bcbcb1e 💄(app-desk) integrate design 404 page
Introduce a first draft of 404 page based on Johann's design. Please
refer to the Figma file for more info. This page is tested through
tests e2e. It closes the issue #112
2024-03-27 17:26:54 +01:00
daproclaima 4af4c4de50 🙈(common) ignore .tools-version
add .tools-version to .gitignore as I use
asdf as a package and version manager.
2024-03-27 17:26:54 +01:00
Anthony LC 1c5499b2ab (app-desk) remove warning request not mocked
A request was not mocked in the test,
so the warning a warning was displayed.
This commit mocks the request to avoid the warning.
2024-03-27 14:31:49 +01:00
Anthony LC 91f755306b (app-desk) improve sorting teams test e2e
This tests was becoming very flaky because we create
teams in parallel with the other tests.
We use another approch, we checks the aria are
changing according to the sort, we check
as well the api request and that the response
is ok.
2024-03-27 14:31:49 +01:00
Anthony LC 224025c3fb ♻️(app-desk) add hook useWhoAmI
The hook useWhoAmI is a custom hook that gives informations
about the current member.
2024-03-27 14:31:49 +01:00
Anthony LC 724bbe550c (app-desk) modal delete member
We can now delete a member from a team.
We take care of usecases like:
- it is the last owner of the team (cannot delete)
- other owner of the team (cannot delete)
- role hierarchy
2024-03-27 14:31:49 +01:00
Anthony LC 016232ad2d (app-desk) add useDeleteTeamAccess react-query hook
Add the hook useDeleteTeamAccess, it will be used to
delete member from a team.
2024-03-27 14:31:49 +01:00
Lebaud Antoine 6de24d973b 🔇(helm) silence some Django system checks
Django logs some security warnings we can ignored when deploying over K8s.
Inspired by fun project, I added the Django setting SILENCED_SYSTEM_CHECKS,
and silenced the two that were logging a lot of warning.
2024-03-27 12:14:36 +01:00
Lebaud Antoine 04c107cfdb 🐛(helm) enable SSL when sending email
Email settings were wrongly configured. It led to unsent email and timeout
response from the backend server.

I forgot to enable the SSL while using the Email service from scalingo.
2024-03-27 12:14:36 +01:00
Lebaud Antoine cbfc67f010 🔒️(helmfile) generate Django secret key
Generate a proper Django secret key ready for production,
using the provided get_random_secret_key() function.

Store its value in a k8s secret. I generated two values one for
dev and one for staging.

Previous values were triggering security logs.
2024-03-27 12:14:36 +01:00
Anthony LC 0fe0175622 💬(app-desk) translate en plurals keys correctly
We added the english language in Crowdin.
We can now translate the plural keys in
the english language.
2024-03-27 07:15:42 +01:00
Anthony LC fab1329712 🌐(i18n) don't keep unused keys
The potential unused keys was the ones from other
PR, but we don't use crowdin with every PR, so we don't
need to keep the unused keys in the translation files.
2024-03-27 07:15:42 +01:00
Anthony LC f27b347d1c 💬(frontend) synch the translations with crowdin
We stopped the use Crowdin for every PR, so we need to
synch the translations here and there to be sure
all the translations are up to date.
2024-03-27 07:15:42 +01:00
Lebaud Antoine cc35757c9e 🚧(frontend) add applications menu PoC
Based on works from @manuhabitela, introduce a PoC of the future component.
ApplicationsMenu component is still under construction.

This code was committed for the Wednesday 26th demo, to showcase our future
works. This Next.js integration could be improved, and will for sure!
Don't blame me.
2024-03-26 22:49:57 +01:00
Lebaud Antoine a1065031ee (frontend) sort team's members
We are displaying all team's members in a datagrid. This propose to enable
sorting on columns, to easily find members.

Please note that few issues were faced when activating the sorting on the
Cunningham components. First, custom columns can not be sorted (yet), a PR
has been merged on Cunningham's side. We're waiting for the next release.

Second, when sorting data rows, if any of the column has some null values,
the datagrid sorting state becomes inconsistent. Thx @AntoLC for spotting the
issue. It's work in progress on Cunningham's side to fix the issue.

Finally, Cunningham export only the SortModel type, which is an array, and
doesn't export its items' type. I might have miss something but it feels weird
to redefine its items type.

Columns wiggle on sorting, because they data is set to undefined while fetching
the next batch. it's visually weird, but not a major pain.
Next release of Cunningham will allow us to set the column to a fixed size.
2024-03-26 22:15:18 +01:00
Jacques ROUSSEL 7c488a9807 🚀(helm) transform migrate job to Presync job
Apply db migration before syncing all the pods.
It avoids triggering errors when running the migrate job.
2024-03-26 17:45:53 +01:00
Jacques ROUSSEL 1c4efd523b 👷(argocd) notify argocd when new images are pushed
Add a new job in the CI, which notifies ArgoCD through a webhook that a new
docker image has been pushed to the Docker registry. Thus, ArgoCD can sync
and pull the latest image.

Thus, main will be automatically deployed to staging.
2024-03-26 17:01:15 +01:00
Lebaud Antoine 2345250c4f 🚑️(staging) fix 404 errors
Recent changes on the staging cluster created a regression.
The ingress className needs to be specified.
2024-03-26 16:39:48 +01:00
Anthony LC 6b2fb4169c 🛂(app-desk) add TeamActions component
TeamActions is a component that control the actions
that a member can performed on the team.
It contains a dropdown menu that contains the actions:
- Edit Team
- Remove Team
2024-03-26 16:28:51 +01:00
Anthony LC 73e58e274c (app-desk) modal delete team
We can now delete a team.
2024-03-26 16:28:51 +01:00
Anthony LC 55fbd661b0 (app-desk) add useRemoveTeam react-query hook
Add the hook useRemoveTeam, it will be used to
remove a team.
2024-03-26 16:28:51 +01:00
Anthony LC f591c95a92 🏷️(app-desk) move Role type to Team
We need the Role type in Team as well.
Better to move it to the highest level.
2024-03-26 16:28:51 +01:00
Anthony LC 25af872a2a 🚚(app-desk) create addMembers feature
All the code related to adding members has been moved
to the addMembers feature.
2024-03-25 18:08:44 +01:00
Anthony LC 832dae789e (app-desk) add new member to team
We integrate the endpoint to add a new member
to the team with the multi select seach user.
- If it is a unknown email, it will send an invitation,
- If it is a known user, it will add it to the team.
2024-03-25 18:08:44 +01:00
Anthony LC 904fae469d (app-desk) add useCreateTeamAccess react-query hook
Add the hook useCreateTeamAccess, it will be used to
add a member to a team.
2024-03-25 18:08:44 +01:00
Lebaud Antoine da081b9887 🔧(renovate) open pull request with a default noChangeLog label
Discussed with @sampaccoud, Renovate PR do not necessitate ChangeLog updates
Our CI system requires a 'noChangeLog' label or an updated ChangeLog. Currently,
we manually add the label for each Renovate PR.

To improve DX, we configured Renovate to apply the label automatically.
2024-03-25 17:59:26 +01:00
Anthony LC 0bb5c0c5c2 ⬇️(app-desk) compatibility issue stylelint@16.3.0
Compatibility issue with stylelint@16.3.0 and
stylelint-prettier@5.0.0.
An issue has been opened in the `stylelint` repo.
2024-03-25 13:00:23 +01:00
renovate[bot] a9bb556dfd ⬆️(dependencies) update js dependencies 2024-03-25 13:00:23 +01:00
renovate[bot] 32fa653c12 ⬆️(dependencies) update python dependencies 2024-03-25 08:54:42 +01:00
Anthony LC 7d9032b6ec 💚(app-desk) build template mail for e2e
The tests e2e were failing because the mail
template was not built.
We will use the job after the mail templates are build.
2024-03-22 17:26:32 +01:00
Anthony LC 897b68038f (app-desk) create invitation
Invite the selected members to the team.
To have a successful invitation:
- none user has this email
- an invitation is not pending for this email and
  this team
2024-03-22 17:26:32 +01:00
Anthony LC bb9edd21da (app-desk) add useCreateInvitation react-query hook
Add the hook useCreateInvitation, it will be used to
create an invitation.
2024-03-22 17:26:32 +01:00
Anthony LC bbf2695dee 🥅(app-desk) add data to APIError
We sometime need to get some data back when an
error occurs. We add a data prop to the APIError
class to allow us to do that.
2024-03-22 17:26:32 +01:00
Anthony LC 8b63807f38 ♻️(app-desk) create InputTeamName component
We created the InputTeamName component to use it
in all the places where we need to input the team name.
2024-03-22 14:53:40 +01:00
Anthony LC 88e38c4c7f 💄(app-desk) update ui
We update the theme of the app to match the design system:
- secondary button
- input error
- modal background
2024-03-22 14:53:40 +01:00
Anthony LC 8ea7b53286 (app-desk) modal update team
Integrate the modal and the logic to update a team.
2024-03-22 14:53:40 +01:00
Anthony LC 7347565f8d (app-desk) add useUpdateTeam react-query hook
Add the hook useUpdateTeam, it will be used to
update the name of a team.
2024-03-22 14:53:40 +01:00
Anthony LC 2d50920a48 ♻️(app-desk) create IconOptions component
Export from MemberAction component the icon options
to IconOptions component.
We will reuse this component in other places.
2024-03-22 14:53:40 +01:00
Anthony LC 36161972d7 ♻️(app-desk) keep team logic in teams feature
Part of the team logic was in the create team page,
we moved it to the CardCreateTeam component in
the teams feature.
It will be easier to maintain and reuse the logic.
2024-03-22 14:53:40 +01:00
Lebaud Antoine f9fde490e8 🚀(smtp) update mail server configurations in staging
Update staging configuration, so they can use the outscale mail
gateway as recommended by @rouja.
2024-03-22 13:42:22 +01:00
Lebaud Antoine 1b3869c1e9 🌐(backend) generate traductions
With the recent addition of mails' templates, Django traduction files
needed to be updated.

It seems that recents backend changes were not reflected into the
Django traduction file. Fixed them, and add traductions related to
the invitation email.

Last revision was made on 2024-01-01
2024-03-22 13:42:22 +01:00
Lebaud Antoine f6d5f737f4 💚(ci) download mails templates when testing back
build-mails job builds mails Django templates but was not persisting its
output. This steps was present in Joanie CI. It might have been removed,
when converting Circle CI worflows to Github Actions.

Artifacts are passed between build-mails and test-back jobs. test-back
job has now a dependency to  build-mails.
2024-03-22 13:42:22 +01:00
Lebaud Antoine 522914b47a (backend) email invitation to new users
When generating an Invitation object within the database, our intention
is to promptly notify the user via email. We send them an invitation
to join Desk.

This code is inspired by Joanie successful order flow.

Johann's design was missing a link to Desk, I simply added a button which
redirect to the staging url. This url is hardcoded, we should refactor it
when we will deploy Desk in pre-prod or prod environments.

Johann's design relied on Marianne font. I implemented a simpler version,
which uses a google font. That's not important for MVP.

Look and feel of this first invitation template is enough to make our PoC
functionnal, which is the more important.
2024-03-22 13:42:22 +01:00
Lebaud Antoine 1919dce3a9 🧑‍💻(views) render email's template
THis feature is inspired by Joanie. Add two new urls to render Emails
HTML and Text templates.

Developpers can render the email template they are working on. When necessary,
run make mails-build, and reload `_debug__/mail/hello_html`, it will re-render
the updated email template.

Also, I have copy/pasted one template extra tags from Joanie, which loads
bas64 string from static images. This code is necessary to render the dummy
template `hello.html`.
2024-03-22 13:42:22 +01:00
Lebaud Antoine 0141aa220f 🎨(models) extract invitation converter in a proper method
Improved code readability, by extracting this well-scoped unit of
logic in a dedicated method. Also, rename active_invitations to match
'valid' vocabulary used elsewhere in the doc. If no valid invitation
exists, early return to avoid nesting.
2024-03-22 13:31:24 +01:00
Anthony LC 159f112713 (app-desk) add role option to modal add members
When adding members to a team, the user can now
select the role of the members.
Only admin and owner can add new members to a team.
2024-03-22 11:13:24 +01:00
Anthony LC faf699544b ♻️(app-desk) create ChooseRole component
We extract the ChooseRole component from the ModalRole
component to make it reusable.
2024-03-22 11:13:24 +01:00
Anthony LC b8427d865f (app-desk) integrate multiselect search users
Integrate multiselect search users in the
modal add members.
We are using react-select to implement the
multiselect search users. We are using this
library in waiting for Cunningham to implement
the multiselect asynch component.
2024-03-22 11:13:24 +01:00
Anthony LC a48dbde0ea 🧐(CI) add dummy data to test-e2e job
To search some users we need to have some
dummy data in the database.
This commit adds dummy data to the database
like users, teams, and identities.
2024-03-22 11:13:24 +01:00
Anthony LC e9848bd199 (app-desk) add useUsers react-query hook
Add the hook useUsers, it will be used to
search users by name or email.
2024-03-22 11:13:24 +01:00
Anthony LC 1ad6ef8f96 🧑‍💻(frontend) remove CI control on traduction frontend
The CI was controlling if the traduction was made
in every PR. It makes the workflow quite grueling
when we have to change the literal, plus the synch
is complicating when we have multiple PR opened.

We remove the CI control on the traduction, we
will do dedicated PR to update the traduction.

We will add the CI control on the traduction in
the future, before a release by example.
2024-03-22 09:49:14 +01:00
Lebaud Antoine 97752e1d5f 🩹(factory) handle email uniqueness
When generating a batch of users using Faker, there's a possibility of
generating multiple users with the same email address. This breaches
the uniqueness constraint set on the email field, leading to flaky
tests that may fail when random behavior coincides unfavorably.

Implemented a method inspired by Identity's model to ensure unique
email addresses when creating user batches with Faker.
Updated relevant tests for improved stability.
2024-03-22 08:28:30 +01:00
Lebaud Antoine 99cee241f9 (api) support TeamAccess ordering on user-based fields
Important ordering fields for TeamAccess depend on user's
identities data. User and identities has a one-to-many relationship,
which forced us to prefetch the user-related data when listing
team's accesses.

Prefetch get data from the database using two SQL queries, and
join data in Python. User's data were not available in the first
SQL query.

Without annotating the query set with user main identities data,
we could not use default OrderingFilter backend code, which order_by()
the queryset.
2024-03-22 08:28:30 +01:00
Lebaud Antoine 6de0d013c3 (api) support TeamAccess ordering on their role
Enhance list capabilities, by adding the OrderingFilter as filter backend,
to the TeamAccess viewset.

API response can be ordered by TeamAccess role. More supported ordering
fields will be supported later on.
2024-03-22 08:28:30 +01:00
Lebaud Antoine 1de743e18a (pagination) add few tests on page's size
We created a custom Pagination class, were max_page_size is overriden.
I decided to add bare minimum tests to make sure we can set the maximum
page size using the 'page_size' query parameter.
2024-03-22 08:28:30 +01:00
Lebaud Antoine 756867da19 🔥(pagination) remove unused ordering field
Our Pagination class inherits from the PageNumberPagination Django class.
However, this base class as not ordering attribute. Thus, setting a
default value wont have any effect on the code.

Why did we end up passing a value to this non-existing attribute? Becasue
we copy/pasted some code sources from Joanie, and Joanie also has this
attribute set to a default value.

If you take a look at DRF pagination style documentation, the only three
attributes they set on the child class are 'page_size', 'max_page_size'
'page_size_query_param'. 'ordering' is not mentionned in the attributes
you may override. However, the CursorPagination class offers the latter
attribute, which may explain why we did end up setting this non-existing
attribute in Joanie.
2024-03-22 08:28:30 +01:00
Lebaud Antoine d15adb4421 🐛(helm) fix wrongly named ingress
Admin ingress has been partially renamed to ingressAdmin.
I forgot to update helmfile values. Fixed them.
2024-03-21 17:51:09 +01:00
Marie PUPO JEAMMET 340ddf8b1a 🐛(dependencies) modify expected details on 404 responses
djangorestframework released version 3.15.0, which includes modifications of
details upon returning 404 errors (see related issue
https://github.com/encode/django-rest-framework/pull/8051).

This commit changes the expected details of 404 responses in our tests,
to match DRF 3.15.0.
2024-03-21 15:46:42 +01:00
renovate[bot] 2d0fb0ef70 ⬆️(dependencies) update python dependencies 2024-03-21 15:46:42 +01:00
Marie PUPO JEAMMET 7ef67037c3 (backend) convert invitations to accesses
Convert related invitations to accesses upon creating a new identity.
2024-03-21 12:14:10 +01:00
Anthony LC f1124f6c09 🚸(app-desk) close modal role on click outside
The modal role will be closed when the user
clicks outside the modal.
The design does not have a close button, we removed it.
2024-03-21 11:13:17 +01:00
Anthony LC 2f8801f7eb (app-desk) add modal for adding members to a team
Create the button to open the modal.
Add a modal for adding members to a team.
This modal will open thanks to a dedicated page.
2024-03-21 11:13:17 +01:00
Anthony LC 4a141736ff 🎨(app-desk) add feature members
The feature teams is getting big, we extracted codes
related to members to a new feature members.
2024-03-21 11:13:17 +01:00
Lebaud Antoine bdddbb84a5 📝(helm) update chart's README
Run the ./generate-readme.sh script to keep the README file
up to date with the values.yaml.
2024-03-21 10:49:58 +01:00
Lebaud Antoine de4551ab30 🚀(helm) support Django Admin pages in ingress paths
Based on @rouja reco, I added a dedicated ingress to serve Django Admin
pages and Django statics. The admin route will be secured by the oauth proxy.

I simply copy/pasted the first ingress template, and adapted it.
2024-03-21 10:49:58 +01:00
Lebaud Antoine e8a241adbc 🔧(helm) enable liveness and readiness probes on backend deployment
Enable the probes to track liveness and readiness of any backend pods.
Helm values were updated to enable the relevant configuration.
2024-03-21 10:49:58 +01:00
Lebaud Antoine b3b1343796 🚀(helm) add a Redis cache service
This commit is working in progress. I have added an extra chart to take
benefits of the Redis operator developed by Indie hoster.

When using the dev environment, I used bitnami redis chart to deploy
a Redis service with authentication disable.
2024-03-21 10:49:58 +01:00
Lebaud Antoine d49cc11ef1 🩹(helm) rename mismatching environment variable
CSRF trusted origins are set using an environment variable. The env
value was wrongly name to CORS_ALLOWED_ORIGINS, which doesn't exist
in our Django configurations. I fixed this minor issue.
2024-03-21 10:49:58 +01:00
Lebaud Antoine 28adf987f7 🔐(helm) add OIDC secrets for dev environment
Set OIDC secrets for the dev environment. Please note that we use different
secrets between dev and staging. Why? Benoit created two client id, thus we
could easily tests Agent Connect feature from the local host and the staging
one.

The local host is desk.127.0.0.1.nip.io. If this value change at any time,
please consider asking Benoit to update the host value linked to the dev
client id.
2024-03-21 10:49:58 +01:00
Jacques ROUSSEL c6b8e47b29 🚀(helm) prepare staging deployment
Thx @rouja for your help on deploying Desk. This commit slightly modifies
helm charts and helmfile to prepare the initial project deployment in a
staging environment.

@rouja updates:
- added secrets files for dev and staging environments (dev's one is empty)
- disable ingress by default, to avoid any security issue
- added an extra chart to benefit from Indie hoster Postgres operator

Thx to this commit we deployed a first draft version figured out
that the Django session were broken. We are using a cache session engine,
and wrongly configure cache backend to local memory. Thus, Django server
is not able to resolve the session, and enters in an infinite loop to
log-in the user.
2024-03-21 10:49:58 +01:00
Lebaud Antoine a8a001e1e4 🚀(helm) build a minimalistic dev Helmfile
Please note that this Helmfile is uncomplete, it lacks services as
redis, celery, mail ... which are declared in the Docker Compose file
but not yet used in development and production images.

Thus, to run the Desk Helm chart, we only add a postgres database to run the
Django backend server, and apply migrations.

For now, this Helmfile is quite hard to test in dev environment, because the
frontend redirects automatically to the SSO login page. We cannot really
assess if backend and frontend are working properly. We might adjust some
configurations after the first deployment in stagging.

(We are a bit in rush, to respect the current sprint deadline.)

Development values points https://desk.127.0.0.1.nip.io URL. Please note that
the frontend image for now has been built with this URL for the backend address.
Meaning that we either need to rebuild and publish a frontend image with the
staging URL when deploying the project, or enhance our frontend image, to pass
the backend URL at runtime.
2024-03-21 10:49:58 +01:00
Lebaud Antoine bbd8e1b48d 🚀(helm) write desk Helm chart
First, thanks a LOT @rouja for your help along the way.
This commit propose a first draft of Helm chart to prepare deployment.
It follows Plane's Helm Chart, hosted on the shared team repo,
please https://github.com/numerique-gouv/helm-charts, PR #11

It offers advanced templating function under _helpers.tpl, an auto-generated
README file when running ./generate-readme.sh, and a clear files structure.

The chart itself is quite simple. We have two deployments, one for the
frontend and one for the backend. Both need a dedicated service, which are
exposed using a common ingress. Frontend is accessible from the / path and
backend's from /api path.

Please note, we added a backend job to migrate the database when deploying
backend's pods. This job should be auto-cleaning itself 100s after it completes
to avoid any error when syncing helm.

values.yaml file is quite pristine, all common env variables will be set
in helmfile configuration.

Deploying frontend static files through kubernetes is temporary, we plan to
either remplace it by an external CDN or use minio to host static output in
a S3 bucket within the cluster.
2024-03-21 10:49:58 +01:00
Anthony LC f21966cca9 🌐(app-desk) order translations asc
When we pull the translations from crowdin we
get lot of git diff noise with the json file.
We order the keys in the json file to make the
diffs more readable.
2024-03-20 14:23:42 +01:00
Lebaud Antoine e4a6b33366 🐛(docker) switch CMD form from Shell to Exec
`backend-development` and `backend-production` CMD syntaxes were
using a Shell Form. Shell form prevented Unix signals from reaching
our container correctly, such as SIGTERM. Also, the shell process
ends up being the PID 1, instead of our Python scripts.

Docker recommends to use the exec form whenever possible.
2024-03-20 09:31:19 +01:00
Lebaud Antoine 44b5999df8 🔧(backend) configure RedisCache in production settings
In development, sessions are saved in local memory. It's working well,
however it doesn't adapt to a kubernetized setup. Several pods need
to access the current sessions, which need to be stored in a single
source of truth.

With a local memory cache, pods cannot read session saved in other pods.
We end up returning 401 errors, because we cannot authenticate the user.

I preferred setting up a proper cache than storing sessions in database,
because in the long run it would be a performance bottleneck. Cache will
decrease data access latency when reading current sessions.

I added a Redis cache backend to the production settings. Sessions would
be persisted to Redis. In K8s, a Redis operator will make sure the cached
data are not lost.

Two new dependencies were added, redis and django-redis.

I followed the installation guide of django-redis dependency. These
setting were tested deploying the app to a local K8s cluster.
2024-03-19 16:57:27 +01:00
Anthony LC f503120b3c 📌(frontend) pin @types/react-dom globally
Compatibility issues with `@types/react-dom`.
Force the usage of the same version of
`@types/react-dom` across all packages and
dependencies.
2024-03-18 14:07:17 +01:00
renovate[bot] 079968b532 ⬆️(dependencies) update js dependencies 2024-03-18 14:07:17 +01:00
Lebaud Antoine 8e76a0ee79 🔧(frontend) update production value for the API_URL env var
For now, the env variable should point to the only deployed environment,
staging. It'll allow @rouja deploying for the first time our project.
2024-03-15 16:32:58 +01:00
Lebaud Antoine a2ff33663b 🚚(docker) make images naming consistent
It was quite confusing having development, production and
frontend images' names in the same Docker file. New comers
to the project would have some difficuluties when
differentiating frontend from backend images.

Try to make these naming more explicit and consistent.
Thanks @rouja for your recommendation.
2024-03-15 16:32:58 +01:00
Lebaud Antoine 78459df962 🐛(docker) build Docker images with an unprivileged user
This is a major issue. Docker Images were built and published with a
root user in the CI.

if a user manages to break out of the application running as root in the
container, he may gain root user access on host. In addition, configuring
container to user unprivileged is the best way yo prevent privilege
escalation attacks.

We mitigated this issue by creating a new environment variable DOCKER_USER.
DOCKER_USER is set with id -u and id -g outputs. Then, it is passed as a
build-args when running docker/build-push-action steps.
2024-03-15 16:32:58 +01:00
Lebaud Antoine 4579e668b6 ️(docker) add frontend dependencies to .dockerignore
Ignore frontend dependencies when coping frontend sources to build
the frontend Docker image. It would improve a bit performances locally,
when building the frontend image.
2024-03-15 16:32:58 +01:00
Lebaud Antoine 6ee39a01af 🎨(env) add missing newline at EOF
Found wrongly formatted files, fixed them.
2024-03-15 16:32:58 +01:00
Lebaud Antoine 3378d4b892 👷(frontend) push frontend image to DockerHub
Build and push the frontend image to DockerHub. Backend an Frontend
images will be stored in separate repos: people-backend and people-frontend.

It will be cleaner than managing all images in a single repo and creating
tags to discriminate frontend and backend images.

CI code is not factorized between jobs. Frontend and backend jobs could be
a bit factorized. Hovewer it might be a bit premature, and I prefer having
them decoupled for now. @rouja suggested to introduce a custom github actions
to avoid maintaining the same logic accross different repo.

Please not as the images are built from the same Dockerfile, it's important
to precise the right target.
2024-03-15 16:32:58 +01:00
Lebaud Antoine c40f656622 ⬆️(project) upgrade mail-builder Node Image
Updated to Node Image version 20 to align with the frontend image. It will
save us having two different Node versions in the same docker file, and
should not impact mail-builder.
2024-03-15 16:32:58 +01:00
Lebaud Antoine 1a3b396230 (frontend) introduce frontend Docker Image
To facilitate deployment on Kubernetes, we've introduced a Docker image for the
frontend. The Next.js project is built, and its static output is served using an
Nginx reverse proxy.

Since DevOps lacks a certified cold storage solution (e.g., S3) for serving
static files, we've decided to containerize the frontend as a quick workaround
for deploying staging environments.

Please note this Docker Image is WIP. One of the main issue still not resolved
concerns environment variables, which are only available when building the
Docker Image. Thus, having different environment variables values between
environment (dev, pre-prod, prod) will require us to build several frontend
images, and tag them with the appropriate target environment.

The `.env.production` values are not the final ones. For now, they were set to
dev values. It allows us to test the frontend image with the development setup.

Important: The frontend image is built-on top of an unprivileged Nginx image,
which exposes by default port 8080 instead of 80 for classic Nginx image.
You can find more info https://github.com/nginxinc/docker-nginx-unprivileged.

The Docker Compose Nginx service is used to proxy OIDC requests to keycloak,
in order to share the same host when initiating an OIDC flow, from outside and
inside docker virtual network.

All Nginx configurations related to serve frontend static build were moved to a
newly created conf file under src/frontend/apps/desk. When starting the frontend
image, we desire to start the minimum Nignx config required to serve frontend
statics.
2024-03-15 16:32:58 +01:00
Samuel Paccoud - DINUM 759c06a289 🧑‍💻(demo) improve distribution in number of identities per user
The current implementation of our product demo via the make command lacks
user identity for a significant portion of generated users, limiting the
realism of the showcased scenarios. As it stands, users created by the make
command lack complete information, such as full names and email addresses,
because they don't have any identity.

I tried to come up with the simplest solution:
We now generate a very small portion of our users with 0 identities. The
probability for users to have only 1 identity is the highest but they
can have up to 4 with decreasing probabilities. I removed the possibility
to set a maximum number of identities as it doesn't bring any value.

3% percent of the identities created will have no email and 3% no name.

Fixes https://github.com/numerique-gouv/people/issues/90
2024-03-14 19:39:22 +01:00
Anthony LC 97d9714a0d 🐛(app-desk) close dropDown when click outside
When we were clicking outside the dropdown,
the dropdown was not closing.
This commit fixes that.
2024-03-14 09:14:25 +01:00
Anthony LC c9e4d47d9d ️(frontend) clean yarn.lock
The yarn.lock file get full of garbage and old
dependencies after a while. This commit cleans it.
2024-03-13 11:31:50 +01:00
Anthony LC b30bb6ce2f ♻️(app-desk) improve useCunninghamTheme
Some tokens were not available from the hook.

We only had the tokens of the currentTheme available
but actually the theme is an augmentation of the
default theme, so we should use the default theme
tokens as a base and then override them with the
currentTheme tokens.
It is what this commit does.
2024-03-13 11:31:50 +01:00
Anthony LC 8ae7b4e8e9 ♻️(app-desk) cunningham theme more dsfr
Mockup doesn't seem totally synch with DSFR design,
this commit will adapt buttons and input in a more
DSFR way.
2024-03-13 11:31:50 +01:00
Anthony LC 8b014e289a (app-desk) component BoxButton
We often need unstyled button to wrap around some content,
we were using Cunningham's button for this purpose,
but it is not the best choice as lot of style is applied
to their buttons.
This component is a simple wrapper around the button
element with all the Box functionalities. Usefull
for wrapping icons by example.
2024-03-13 11:31:50 +01:00
Lebaud Antoine 7d65de1938 (backend) search user on her email and name
Compute Trigram similarity on user's name, and sum it up
with existing one based on user's email.

This approach is inspired by Contact search feature, which
computes a Trigram similarity score on first name and last
name, to sum up their scores.

With a similarity score influenced by both email and name,
API results would reflect both email and name user's attributes.

As we sum up similarities, I increased the similarity threshold.
Its value is empirical, and was finetuned to avoid breaking
existing tests. Please note, the updated value is closer to the
threshold used to search contacts.

Email or Name can be None. Summing two similarity scores with
one of them None, results in a None total score. To mitigate
this issue, I added a default empty string value, to replace
None values. Thus, the similarity score on this default empty
string value is equal to 0 and not to None anymore.
2024-03-11 20:23:05 +01:00
Lebaud Antoine b2d68df646 (backend) mock identities' name when searching a user
When testing user search, we generated few identities
with mocked emails.

Name attribute was introduced on Identity model. Currently
names are freely and randomly generated by the factory.

To make this mocked data more realist, mock also identities'
names to match their email.

It should not break existing tests, and will make them more
predictable when introducing advanced search features.
2024-03-11 20:23:05 +01:00
renovate[bot] 4f9f49ac9a ⬆️(dependencies) update js dependencies 2024-03-11 12:55:01 +01:00
renovate[bot] 421ef899da ⬆️(dependencies) update python dependencies 2024-03-11 12:25:23 +01:00
Lebaud Antoine b416c57bbe 🩹(frontend) fix layout overflow in Team info
Few minor layout issues were fixed.

First display label and dates inline, so they wrap nicely
when screen's size decreases. It also fixes the text overflow
when the screen's size is tiny.

Then, align screen with the Figma design, where items are
justified on the left of the Team info component.
2024-03-11 12:17:17 +01:00
Marie PUPO JEAMMET fa88f70cee 🐛(admin) prevent updating of invitations
Invitations cannot be updated for now. To reflect api behaviour,
we disable update in django admin as well.
2024-03-11 11:39:02 +01:00
Marie PUPO JEAMMET b2956e42d3 🛂(abilities) fix anonymous and unrelated users accessing resources
The function computing abilities return "True" for method get,
even if role of request user was None.
2024-03-11 11:39:02 +01:00
Marie PUPO JEAMMET 18971a10e0 🚨(tests) fix back-end tests warnings
Fixes a warnings in back-end tests suite:
- post_generation hooks save
- ordering for invitation and user viewsets
2024-03-11 11:39:02 +01:00
Marie PUPO JEAMMET 62758763df (api) add invitations CRUD
Nest invitation router below team router and add create endpoints for
authenticated administrators/owners to invite new members to their team,
list valid and expired invitations or delete invite altogether.

Update will not be handled for now. Delete and recreate if needed.
2024-03-11 11:39:02 +01:00
Anthony LC a15e46a2f9 🌐(app-desk) translate role in member grid
The roles in the member grid were not being translated.
This commit adds the translation for
the roles in the member grid.
2024-03-08 16:46:07 +01:00
Anthony LC e15c7cb2f4 (app-desk) integrate modal to update roles
Integrate the design and functionality
for updating a member's role.
Managed use cases:
 - when the user is an admin
 - when the user is the last owner
 - when the user want to update other orner
2024-03-08 15:55:26 +01:00
Anthony LC 0648c2e8d3 (app-desk) integrate grid member action
Integrate the action button dropdown in
the member grid. For the moment it will be
used to update the role of a member.
Manage use cases:
 - Does not display when member's role
 - Does not display when member is an admin
   that wants to update owner role.
2024-03-08 15:55:26 +01:00
Anthony LC b0d3f73ba2 🏷️(app-desk) update interfaces business logic
Update interfaces of User / Team / Access
to get what is needed for the frontend.
2024-03-08 15:55:26 +01:00
Anthony LC 9be973a776 (app-desk) add useUpdateTeamAccess react-query hook
Add the hook useUpdateTeamAccess, it will be used to
change the role of a member.
2024-03-08 15:55:26 +01:00
Anthony LC 150258b5a4 ⬆️(app-desk) upgrade Cunningham design system
The last version of the Cunningham design system
has some new features that we need in this
feature.
2024-03-08 15:55:26 +01:00
Anthony LC b41fd1ab69 (app-desk) component DropButton
Button that opens a dropdown menu when clicked.
It will manage all the state related to
the dropdown menu.
Can be used controlled or uncontrolled.
2024-03-08 15:55:26 +01:00
Lebaud Antoine b5ce19a28e 📝(backend) clarify how team accesses are queried
Break copy/pasted comment from Joanie in several inline
comments, that are more specific and easy to read.

Hopefully, it will help future myself understanding this
queryset and explaining it.
2024-03-07 19:55:53 +01:00
Lebaud Antoine 163f987132 🐛(backend) fix team accesses abilities
To compute accesses's abilities, we need to determine
which is the user's role in the team.

We opted for a subquery, which retrieves the user's role
within the team and annotate queryset's results.

The current subquery was broken, and retrieved other
users than the request's user. It led to compute accesses'
abilities based on a randomly picked user.
2024-03-07 19:55:53 +01:00
Lebaud Antoine e9482a985f (backend) enhance tests when listing team accesses
Abilities on team accesses are computed based on request user role.
Thus, members' roles in relation with user's role matters a lot, to
ensure the abilities were correctly computed.

Complexified the test that lists team accesses while being authenticated.
More members are added to the team with privileged roles. The user
is added last to the less with the less privileged role, "member".

Order matters, because when computing the sub query to determine
user's role within the team, code use the first result value to set the
role to compute abilities.
2024-03-07 19:55:53 +01:00
Lebaud Antoine 43d802a73b 🎨(backend) early return in User factory
Avoid unnecessary nesting when code can early return.
Also, rename "item" to a more explicit name "user_entry".

it's very nit-picking, sorry.
2024-03-07 11:42:34 +01:00
Lebaud Antoine b4e4940fd7 🚨(backend) update Ruff config to suppress deprecation warning
When running make ruff-check, a warning informs the user that
some config are deprecated, and gives her the step to migrate.

This warning appears after Ruff released its v0.2.0.
Fix it, by keeping our pyproject.toml up to date.
2024-03-07 11:31:31 +01:00
Lebaud Antoine 5ec0dcf206 🚨(backend) follow Ruff 2024.2 style introduced in v0.3.0
We recently updated Ruff from 0.2.2 to v0.3, which introduced
Ruff 2024.2 style. This new style updated Ruff formatter's behavior,
making our make lint command fails.

Ruff 2024.2 style add a blank line after the module docstring.
Please take a look at Ruff ChangeLog to get more info.
2024-03-07 11:31:31 +01:00
renovate[bot] dad81c8d73 ⬆️(dependencies) update python dependencies 2024-03-07 11:31:31 +01:00
Anthony LC b010a7b5a7 🤡(app-desk) remove mock endpoint teams accesses
The endpoint teams/accesses is ready.
We remove the mock and the related libraries
and use the real endpoint.
2024-03-04 17:52:52 +01:00
Anthony LC e16f51ca20 (app-desk) integrate member list design
Integrate the member list design in the team page
based on the mockup.
2024-03-04 15:49:50 +01:00
Anthony LC 9d30bc88f1 🤡(app-desk) mock endpoint teams/:teamId/accesses/
We intercept the request to the endpoint teams/:teamId/accesses/
and return a json with dummy accesses of the team.
2024-03-04 15:49:50 +01:00
Anthony LC 1da978e121 (app-desk) add useTeamAccesses react-query hook
Add the hook useTeamAccesses, it queries the accesses
if a team. It is paginated.
2024-03-04 15:49:50 +01:00
Anthony LC 3bf8965209 🚚(app-desk) rename and group team Panel
- add folder Panel
- rename PanelTeams to TeamList
- rename PanelTeam to TeamItem
2024-03-04 15:49:50 +01:00
renovate[bot] 5b9d2cccc5 ⬆️(dependencies) update js dependencies 2024-03-04 15:16:15 +01:00
Marie PUPO JEAMMET 81243cfc9a (api) return user id, name and email on /team/<id>/accesses/
Add serializers to return basic user info when listing /team/<id>/accesses/
endpoint. This will allow front-end to retrieve members info without having
to query API for each user.id.
2024-03-03 23:00:05 +01:00
Marie PUPO JEAMMET 70b1b996df 🏗️(tests) separate team accesses tests by action
Small commit to separate team accesses tests into diferent files.
2024-03-03 23:00:05 +01:00
renovate[bot] 29d274ab7c ⬆️(dependencies) update python dependencies 2024-02-28 14:21:49 +01:00
Anthony LC f17771fc9b (app-desk) fix error warning jest test logout
We had a error warning in the jest test logout with fetchApi,
window.location.replace had to be mocked to avoid the error.
2024-02-26 16:31:02 +01:00
Anthony LC 65e78cde68 ⬇️(app-desk) downgrade @openfun/cunningham-react
Downgrade @openfun/cunningham-react to 2.4.0, because of a
compatibility problem with Jest.

We add this package with this version to the ignore list
in renovate.json, when we will have a new compatible version, we will
remove it from the ignore list.
2024-02-26 16:31:02 +01:00
Anthony LC 33288ab225 ⬇️(app-desk) downgrade @types/react-dom
Downgrade @types/react-dom to 18.2.18.
The lastest version seems to have lot of compatibility
issues with other packages:
- @openfun/cunningham-react
- @tanstack/react-query-devtools
- next

We add this package with this version to the ignore list
in renovate.json, when we will have a new compatible version, we will
remove it from the ignore list.
2024-02-26 16:31:02 +01:00
renovate[bot] a3c0069697 ⬆️(dependencies) update js dependencies 2024-02-26 16:31:02 +01:00
Anthony LC b307b373bb (app-desk) add luxon to display date
Add luxon to display date in the team description.
The date are internationalized and formatted as the
mockup requested.
2024-02-25 20:48:51 +01:00
Anthony LC f21740e5e5 👔(backend) add read fields to teams api
Some fields are missing for the frontend.
Add read fields to teams api:
- created_at
- updated_at
2024-02-25 20:48:51 +01:00
Anthony LC 035a7a1fcc 🏷️(app-desk) rename type TeamResponse to Team
Rename type TeamResponse to Team, the components
using this type don't need to know that the data
is coming from the API.
2024-02-25 20:48:51 +01:00
Anthony LC 3f7e5c88bc (app-desk) change backend settings for e2e tests
When we run e2e tests with the CI, we are doing lot of
calls to the backend in a short amount of time. This can
lead to a rate limit particulary on the "user/me" endpoint.
To avoid this, we will use different backend settings
for the e2e tests.
2024-02-25 20:31:27 +01:00
Anthony LC 51064ec236 🥅(app-desk) better error management
We don't know how the error body returned by the
api will be, so we handle it in a more generic way.
2024-02-25 20:31:27 +01:00
Anthony LC 195e738c3c 🚸(app-desk) add 404 page
- Add a 404 page.
- Redirect to 404 page when a team is not found.
2024-02-25 20:31:27 +01:00
Samuel Paccoud - DINUM 54497c1261 🔒️(settings) remove default value for setting OIDC_RP_CLIENT_SECRET
Secret settings should not contain any default value as we risk shipping
them to production. The default value can be set via an environment variable
in the `env.d/development/common` file: OIDC_RP_CLIENT_SECRET
2024-02-23 17:15:46 +01:00
Anthony LC 8d7c545d1a 🗃️(backend) add name field to identity
We need a name for the user when we display the members in the
frontend. This commit adds the name column to the identity model.
We sync the Keycloak user with the identity model when the user
logs in to fill and udpate the name automatically.
2024-02-23 17:15:46 +01:00
Anthony LC 8cbfb38cc4 🚚(app-desk) alias home with teams url path
In order the keep the url path consistent and correctly
structured, the homepage is aliased with the teams page.
2024-02-22 16:20:39 +01:00
Anthony LC 4bd8095975 🐛(i18n) dot in key was not added
The parser was not adding the dot in the key to the
json file sent to crowdin. Some translations were
not being translated correctly.
2024-02-22 14:28:04 +01:00
Anthony LC fc8dc24ba2 (app-desk) add team info component
Add the team info component to the team page.
This component shows some informations about the team:
  - name
  - amount of members
  - date created
  - date updated
2024-02-22 14:28:04 +01:00
Anthony LC 95219a33b3 💄(app-desk) change the text color
The text color from the mockup is not blue but
a dark grey. This commit changes the base color of
the Text component to match the mockup.
2024-02-22 14:28:04 +01:00
Lebaud Antoine 26fbe9fbe7 ✏️(project) fix minor typos
Found typos and fixed them.
2024-02-22 11:59:36 +01:00
Lebaud Antoine 4cacfd3a45 ♻️(frontend) switch to Authorization Code flow
Instead of interacting with Keycloak, the frontend navigate to the
/authenticate endpoint, which starts the Authorization code flow.

When the flow is done, the backend redirect back to the SPA,
passing a session cookie and a csrf cookie.

Done:
- Query GET user/me to determine if user is authenticated yet
- Remove Keycloak js dependency, as all the OIDC logic is handled by the backend
- Store user's data instead of the JWT token
2024-02-22 11:59:36 +01:00
Lebaud Antoine 38c4d33791 (backend) support Authorization code flow
Integrate 'mozilla-django-oidc' dependency, to support
Authorization Code flow, which is required by Agent Connect.

Thus, we provide a secure back channel OIDC flow, and return
to the client only a session cookie.

Done:
- Replace JWT authentication by Session based authentication in DRF
- Update Django settings to make OIDC configurations easily editable
- Add 'mozilla-django-oidc' routes to our router
- Implement a custom Django Authentication class to adapt
'mozilla-django-oidc' to our needs

'mozilla-django-oidc' routes added are:
- /authenticate
- /callback (the redirect_uri called back by the Idp)
- /logout
2024-02-22 11:59:36 +01:00
Lebaud Antoine ec28c28d47 (backend) drop JWT authentication in API tests
Force login to bypass authorization checks when necessary.

Note: Generating a session cookie through OIDC flow
is not supported while testing our API.
2024-02-22 11:59:36 +01:00
Lebaud Antoine 927d0e5a22 🔧(project) proxy Keycloak with nginx
Backend and Frontend send requests to Keycloak through Nginx.

Thus, all requests from frontend and backend shared a same host
when received by Keycloak.

Otherwise, the flow is initiated from http://localhost:8080. When the Backend
calls token endpoint from Keycloak container at http://keycloak:8080,
the JWT token issuer and sender are mismatching.
2024-02-22 11:59:36 +01:00
Lebaud Antoine 699854e76b 🔧(project) configure standard OIDC flow in Keycloak
Enforce Authorization Code flow, and disable Implicit flow.

Done:
- Rename client people-front to people
- Add a client secret shared with the backend
- Add allowed redirect uris
- Disable implicit flow and enable Authorization Code flow without PCKE
- Sign userinfo endpoint to return application/jwt content
2024-02-22 11:59:36 +01:00
Marie PUPO JEAMMET 63e059a4e6 🔥(backend) remove users systematic return of profile_contact
Custom UserManaged returned profile_contact field when returning users.
While this may be useful later, we'd currently rather have it return users.
2024-02-21 17:49:19 +01:00
Anthony LC 5113eb013b 💄(app-desk) highlight team selected
Highlight the selected team in the team list.
2024-02-20 16:25:31 +01:00
Anthony LC 45d05873e2 🔥(app-desk) remove FAQ part in header
The FAQ part in the header is not displayed in
the mockup anymore, we remove it.
2024-02-20 16:25:31 +01:00
Anthony LC 1f3ab759d7 ⬇️(app-desk) downgrade @types/react-dom
Downgrade @types/react-dom to 18.2.18.
The lastest version seems to have lot of compatibility
issues with other packages:
- @openfun/cunningham-react
- @tanstack/react-query-devtools
- next
2024-02-19 16:58:23 +01:00
renovate[bot] 8b5f5bf092 ⬆️(dependencies) update js dependencies 2024-02-19 16:58:23 +01:00
Anthony LC cd2efbe40d ️(frontend) add stale cache
We add a stale cache to reduce the number of requests to
the server.
This will help to improve the performance of the application.
2024-02-19 15:22:09 +01:00
Anthony LC 8b0c20dbdc (app-desk) add team page
- add the team page, you can access to
the team page with the id of the team.
- Add link to the panel team to access to the
team page.
2024-02-19 15:22:09 +01:00
Anthony LC d0562029e8 (app-desk) integrate team endpoint
Integrate team endpoint with react-query.
It will be used to get the team on the team page.
2024-02-19 15:22:09 +01:00
Anthony LC 77efb1a89c 💄(app-desk) do not count the owner in item list icon
In every team, the owner is always included in it,
so we shouldn't count the owner when we display the icon
to say if a team has members.
2024-02-19 15:04:53 +01:00
Anthony LC 4566e132e1 💄(app-desk) integrate the create team design
- Integrate the create team design based from the
mockup
- Manage the different states of the create team
2024-02-19 15:04:53 +01:00
Anthony LC f818715a45 🚚(app-desk) change next router to pages
2 routers exists in Next.js, "app" router and "page" router.
The "app" router has a bug introduced in Next.js 13.4.14, which is
not fixed yet. For the moment we cannot use dynamic routes with
"app" router with an SPA. As advised by the Next.js team, we
migrated to the "pages" router.
2024-02-19 15:04:53 +01:00
renovate[bot] 7d90092020 ⬆️(dependencies) update python dependencies 2024-02-19 10:08:28 +01:00
Lebaud Antoine 469903c9eb ✏️(project) fix minor typos
Found typos, fixed them.
2024-02-16 16:03:52 +01:00
Lebaud Antoine 1f1253ab21 🔧(backend) activate container liveness probes
Enabled Dockerflow Django app by activating liveness probes. The previously
unavailable routes such as `__heartbeat__` and `__lbheartbeat__` are now
accessible. New endpoints include:
* GET /__version__
* GET /__heartbeat__
* GET /__lbheartbeat__
2024-02-16 15:16:30 +01:00
Lebaud Antoine 6620932371 🐛(project) run production image locally with docker-compose
The local deployment of the Production image through docker-compose was
failing due to issues in the Django configurations, influenced by Joanie.

The bug stemmed from a dependency on a development-specific package
(drf-spectacular-sidecar) while attempting to run the application in
production mode.

Changes Made:
- Introduced new Django settings for local demo environments.
- Uncommented the nginx configuration to address the production image
  deployment issues.
2024-02-16 15:16:30 +01:00
Marie PUPO JEAMMET 8e537d962c 🗃️(database) create invitation model
Create invitation model, factory and related tests to prepare back-end
for invitation endpoints. We chose to use a separate dedicated model
for separation of concerns, see
https://github.com/numerique-gouv/people/issues/25
2024-02-15 19:42:40 +01:00
Lebaud Antoine 6080af961a 🩹(backend) fix Django Admin UserAdmin page
*Broken Identity string representation
Resolving a format error in the Identity string representation caused by
potential None values in the email field. This issue was discovered when
attempting to access the User details page in the Django Admin

*Broken User creation form
The replacement of the User's username with an email led to errors in
the UserAdmin class. The base class used the 'username' field in the
'add_fieldsets' attribute. This problem was discovered while attempting to
create a new user in the Django Admin.
2024-02-15 15:54:07 +01:00
Anthony LC aba376702f 🐛(app-desk) fix circular dependency problem
Some circular dependency problems started to appear with Jest.
This commit fixes the problem by removing the feature index
file and moving the exports to the respective feature.
2024-02-15 09:56:07 +01:00
Anthony LC 1e38174c1b ️(e2e) add workers to playwright with CI
We have added workers to playwright to run tests in parallel,
this will help us to run tests faster.
The tests run on a commun database, so to keep the tests
stable between browsers, we created 3 different
users to run the tests, it will avoid to have commun data
stepping on each other.
2024-02-15 09:56:07 +01:00
Anthony LC 0ab9f16cb3 🌐(app-desk) improve message missing translations
- Improve the message when a translation is missing in the app,
now it will show the key of the missing translation.
- It also show the translation that are in crowdin but not in the
app.

- Add missing translations
2024-02-15 09:56:07 +01:00
Anthony LC c71463a385 🚚(app-desk) create route teams/create
- Create the routes teams/create.
- Add link to the buttons routing to this page
- Adapt design
2024-02-15 09:56:07 +01:00
Anthony LC 47ffa60a94 ️(app-desk) add infinite scrool to teams list
If we have a big list of teams, we need to add infinite
scroll to avoid loading all the teams at once.
2024-02-15 09:56:07 +01:00
Anthony LC fafffd2391 (app-desk) add interaction to sort button
In the team panel, we have the possibility to sort the teams.
This commit adds the interaction to the button.
2024-02-15 09:56:07 +01:00
Anthony LC 36e2dc2378 ♻️(backend) api teams list ordering
Give the possibility to order the teams list by
creation date.
By default the list is ordered by
creation date descending.
2024-02-15 09:56:07 +01:00
Anthony LC 4f0465fd32 (app-desk) integrate teams panel design
- Integrate teams panel design based from the mockup.
- List teams from the API.
2024-02-15 09:56:07 +01:00
Anthony LC 148ea81aa9 🎨(app-desk) box default direction column
Change the default direction of the Box component to column
to have a behavior closer to a normal div.
2024-02-15 09:56:07 +01:00
257 changed files with 14285 additions and 4690 deletions
+3
View File
@@ -31,3 +31,6 @@ db.sqlite3
.mypy_cache
.pylint.d
.pytest_cache
# Frontend dependencies
node_modules
+60 -10
View File
@@ -11,8 +11,11 @@ on:
branches:
- 'main'
env:
DOCKER_USER: 1001:127
jobs:
build-and-push:
build-and-push-backend:
runs-on: ubuntu-latest
steps:
-
@@ -23,7 +26,7 @@ jobs:
id: meta
uses: docker/metadata-action@v5
with:
images: lasuite/people
images: lasuite/people-backend
-
name: Load sops secrets
uses: rouja/actions-sops@main
@@ -35,20 +38,67 @@ jobs:
if: github.event_name != 'pull_request'
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
-
name: Build and push backend
name: Build and push
uses: docker/build-push-action@v5
with:
context: .
target: production
push: true
tags: app-${{ steps.meta.outputs.tags }}
target: backend-production
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-and-push-frontend:
runs-on: ubuntu-latest
steps:
-
name: Build and push frontend
name: Checkout
uses: actions/checkout@v4
-
name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: lasuite/people-frontend
-
name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: .github/workflows/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
-
name: Build and push
uses: docker/build-push-action@v5
with:
context: .
target: frontend
push: true
tags: frontend-${{ steps.meta.outputs.tags }}
target: frontend-production
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
notify-argocd:
needs:
- build-and-push-frontend
- build-and-push-backend
runs-on: ubuntu-latest
steps:
-
name: Checkout
uses: actions/checkout@v4
-
name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: .github/workflows/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
-
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
+101 -37
View File
@@ -58,11 +58,9 @@ jobs:
exit 1
fi
test-front:
install-front:
runs-on: ubuntu-latest
defaults:
run:
working-directory: src/frontend/
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -71,42 +69,85 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: '18.x'
cache: 'yarn'
cache-dependency-path: src/frontend/yarn.lock
- 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
run: yarn install --frozen-lockfile
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') }}
build-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') }}
- name: Build CI App
run: cd src/frontend/ && yarn ci:build
- name: Cache build frontend
uses: actions/cache@v4
with:
path: src/frontend/apps/desk/out/
key: build-front-${{ github.run_id }}
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') }}
- name: Test App
run: yarn app:test
- name: Test Translations
run: yarn i18n:test
run: cd src/frontend/ && yarn app:test
lint-front:
runs-on: ubuntu-latest
defaults:
run:
working-directory: src/frontend/
needs: install-front
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
- name: Restore the frontend cache
uses: actions/cache@v4
id: front-node_modules
with:
node-version: '18.x'
cache: 'yarn'
cache-dependency-path: src/frontend/yarn.lock
- name: Install dependencies
run: yarn install --frozen-lockfile
path: 'src/frontend/**/node_modules'
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
- name: Check linting
run: yarn lint
run: cd src/frontend/ && yarn lint
test-e2e:
runs-on: ubuntu-latest
needs: [build-mails, build-front]
timeout-minutes: 10
steps:
- name: Checkout repository
@@ -115,7 +156,28 @@ jobs:
- name: Set services env variables
run: |
make create-env-files
cat env.d/development/common.e2e.dist >> env.d/development/common
- name: Download mails' templates
uses: actions/download-artifact@v4
with:
name: mails-templates
path: src/backend/core/templates/mail
- 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: Restore the build cache
uses: actions/cache@v4
id: cache-build
with:
path: src/frontend/apps/desk/out/
key: build-front-${{ github.run_id }}
- name: Build and Start Docker Servers
env:
DOCKER_BUILDKIT: 1
@@ -124,26 +186,17 @@ jobs:
docker-compose build --pull --build-arg BUILDKIT_INLINE_CACHE=1
make run
- name: Apply DRH migrations
- name: Apply DRF migrations
run: |
make migrate
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '18.x'
cache: 'yarn'
cache-dependency-path: src/frontend/yarn.lock
- name: Install dependencies
run: cd src/frontend/ && yarn install --frozen-lockfile
- name: Add dummy data
run: |
make demo FLUSH_ARGS='--no-input'
- name: Install Playwright Browsers
run: cd src/frontend/apps/e2e && yarn install
- name: Build CI App
run: cd src/frontend/ && yarn ci:build
- name: Run e2e tests
run: cd src/frontend/ && yarn e2e:test
@@ -172,6 +225,11 @@ jobs:
run: yarn install --frozen-lockfile
- name: Build mails
run: yarn build
- name: Persist mails' templates
uses: actions/upload-artifact@v4
with:
name: mails-templates
path: src/backend/core/templates/mail
lint-back:
runs-on: ubuntu-latest
@@ -196,6 +254,7 @@ jobs:
test-back:
runs-on: ubuntu-latest
needs: build-mails
defaults:
run:
working-directory: src/backend
@@ -216,7 +275,7 @@ jobs:
DJANGO_CONFIGURATION: Test
DJANGO_SETTINGS_MODULE: people.settings
DJANGO_SECRET_KEY: ThisIsAnExampleKeyForTestPurposeOnly
DJANGO_JWT_PRIVATE_SIGNING_KEY: ThisIsAnExampleKeyForDevPurposeOnly
OIDC_OP_JWKS_ENDPOINT: /endpoint-for-test-purpose-only
DB_HOST: localhost
DB_NAME: people
DB_USER: dinum
@@ -230,6 +289,11 @@ jobs:
run: |
sudo mkdir -p /data/media && \
sudo mkdir -p /data/static
- name: Download mails' templates
uses: actions/download-artifact@v4
with:
name: mails-templates
path: src/backend/core/templates/mail
- name: Install Python
uses: actions/setup-python@v3
with:
+17 -13
View File
@@ -1,20 +1,24 @@
SOPS_PRIVATE=ENC[AES256_GCM,data:uMsnbpi1FFxO430iptp2axlH/souCPCJ/afCqh/kIDWs8xWHu0xJ2o6PlNOgb4l1gHF8sy4eSHFOW5HPKVbZ9gafRIL0JYHJr3A=,iv:IgaHDad7IuW2wFoxGALLDCs+UiSd3rEYwGNSX38wUfI=,tag:zs4wn4hDm5fghfI/7iH6CQ==,type:str]
CROWDIN_API_TOKEN=ENC[AES256_GCM,data:tZRGFs792GjKYGit+oNubCwPbjWarhlgcyQ7oStO8K3nao0lEzLrm3+ARaOEXkzEkKSal2N1c+vlYIjRyzV1X7WZ7nZQWBiihEJgAfF3NGM=,iv:hohak/1niu5kV/W4XA0KEE3UB0BuNCDxbRxb0O+Aocs=,tag:Ssi0HyEhCKb9+WFyp3/yBQ==,type:str]
CROWDIN_BASE_PATH=ENC[AES256_GCM,data:m44KrW5xJDE=,iv:bSyKrKQ0Z1yzrNaejAUyD+n1Z9z+ci92GoyS5KiiFKI=,tag:dDtpbn+6LkPwsg+qgMNWvA==,type:str]
CROWDIN_PROJECT_ID=ENC[AES256_GCM,data:Gonh6ps7,iv:yUqPty+R060m6CYrDf7na2f6h0aRpIhRCXZoJW4ThJg=,tag:ewupoenajsc9o0Aj0r/niw==,type:str]
DOCKER_HUB_PASSWORD=ENC[AES256_GCM,data:CBjUvS/UQcqdEv8=,iv:zJKGyyRnq+zxhKkYS5h3zw/J1320zrAqpiObcwiHWsA=,tag:OoQNe+4K63KXTWNsvjRZNQ==,type:str]
DOCKER_HUB_USER=ENC[AES256_GCM,data:ivs7oeBBdA==,iv:cKAQJ071XhcRuFyeHhZkscvvz7sHrKIPJg5pt76hCXg=,tag:2VRmp1ULWvVH8NHcBifBGA==,type:str]
sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBIbkIyRi9jTGEzL2RZcGFS\nVkhuN2xMN1FBcm04eGJQUnpvQVRpdmFmaFF3CjdoNVhSM0lrb0lTSXAyUytSKzhG\nNERYaC80OThmUUxCSmt2U2Q0Mno3b0kKLS0tIFRjQzYyOEcwTnRScGhzaGxwaGFL\namxEUXNaeVhWUjYwbzR2Z1Uzb3JsTlEK6tyhbRmcUP4Aql49DPkrYb5tbwvK2EdA\ntvyPQo4pPit+pzgqsVgW+O8Wo4/rYLlITfuVRrOfHEaH3wmf6hziSw==\n-----END AGE ENCRYPTED FILE-----\n
SOPS_PRIVATE=ENC[AES256_GCM,data:FAgeMw3bbPVNZEuyuJ4rMeknOH4UZ1vC7k3S0qe6aaeGG3HHfEIpWHckCjHLDTz6hNDUxWSr/WPytntbGjqcvzKwYZBeehGx9hY=,iv:O64EPSvyYSxjHOIIrdjMQYIJR9HW2CxE+6Ay0e0dvoc=,tag:64G0A4kIJ8AzNx3+CluW5Q==,type:str]
CROWDIN_API_TOKEN=ENC[AES256_GCM,data:IlmqbPlM6s9paCAxn40v4LNEGbzYlm9SVplXfP8PI8Ei/K5oGtLPquoDokK33ySEfEh5b5xLAZjlmDyICdv89ETdd7RLB/63f2LduMn8n0A=,iv:gdcCrv58wkLC6wufFvHGrHB/A6aLXAApJ036BYjlN8g=,tag:wReGIj4izqzBHY7X0rqOAg==,type:str]
CROWDIN_BASE_PATH=ENC[AES256_GCM,data:bepqdi4eaMM=,iv:nF7FSUiuRu4kTYeEQB3cN/Fwrpt0fB0dC/dm4poec7w=,tag:kbxhwe1SQYkaddKyY1UQDg==,type:str]
CROWDIN_PROJECT_ID=ENC[AES256_GCM,data:z8ZkbbR9,iv:Uc2aGQ9dAkiUcTpBqcIqD2fXt4+/ObJJz/3D8BhN9/A=,tag:ZDqDpsD7IN7iyFvKmxoI2w==,type:str]
DOCKER_HUB_PASSWORD=ENC[AES256_GCM,data:3E/4G2zOBNZqkGc=,iv:zbQzrgLYgNp99zRUZ3ICG47OkLPV1HnhcNoIu3cSmpc=,tag:CEvKG2HVd2ijjGfzkM005Q==,type:str]
DOCKER_HUB_USER=ENC[AES256_GCM,data:Qc/HR/O8nw==,iv:nvXUmLHgNFQwm6aIpBcPvOdITOkw1YT7hIyk4ptuOOw=,tag:qoqSA9NlGCbor+7OyGArfw==,type:str]
ARGOCD_WEBHOOK_URL=ENC[AES256_GCM,data:WhW6TQQV/IQteW/jQukjlTaTLeNUzEzadp+HJEC7bqrWR88ZBUlk8sfF5KV7PAAAZtvMhe4mi/yl,iv:CInpRuJpKFpxozdzHQkfDxbhQYyy+VByzrDr2ZSmJ7E=,tag:RNmIoYtA5DSi1W7TDsJHxw==,type:str]
ARGOCD_WEBHOOK_SECRET=ENC[AES256_GCM,data:KTT5ubYdm5oVzXL0sM9HdNObVKhSD1spB3KYgM6CskEpQutb3R4Lu5mImft9C9tRKIkZetxV9YyLawCPaZm5P1U=,iv:Xp+xE43a6/GODhhujnSrTfcQJ2P9u4YrRakmxLwEtEc=,tag:jYAk+4X7x44edb0vcbnKWA==,type:str]
sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBHaHpGT0lLR21qRi8wZCt2\nc0t5NGlDR29lK2JscE4rc3VZOU5ic3FBdkhnCnJYdDdvMlBROWhPc2pjbDJTbmo0\ncENxV0hDYzZKSFh5RTNnUTRyaUdGLzgKLS0tIENIeDQyQ1NzMkF6cTZVRmU1VC9D\nNy9BTElMQlBEUWhCQWxpdVI1enJNblEKbGEkN7JPlj+OqBHYhZhZwfjpdxGlkjti\nBPsDHIklHYufk9+TjnxBYL1Oe6zLn4zlG0r/w8AHrTo6oke5dUjvKg==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_0__map_recipient=age15fyxdwmg5mvldtqqus87xspuws2u0cpvwheehrtvkexj4tnsqqysw6re2x
sops_age__list_1__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBIL2NwVjVtVmd1ZE15WmFC\nVGl5cnU1OHVUcXAxWmZCQXBJbXBlcXFUN0RzClpOVXBTek9wbVB6M2s5TWlFbUo3\nelZrT3dsK3p6cEJGTGJoSTRvaEh4NW8KLS0tIGlsL29oSEthU28xV0RERFIxTnZR\nSSs3YjdUT0NyTEtlbjlmZXVOaTd3SzgK5jFJGREfJ/HVytWsCKWFsqM5JoaFSnhv\n538KzzldzcbtWfnY+bQ6A2EBjETwOzCTuQB8axAMj0URXPI+qelKIQ==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_1__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSArOG1XL1NFc29xVWFFdW9Q\nM0FrY3BEazJRRXNHMjVpS2hCVVBNSllUU1dJCkRIT00xSVAzeEozZndwTzlmY1Bn\nc3VJYmQrVzRXZTF1TjR4QXQrYXpIaG8KLS0tIEtXNTkvb2tRcjR0YWdneTI2dlBu\nY2tkYVU4UGtZdEIxQWMxQ1h6aHQ1RjQKpPQRQZtMDMkmSUhm7PQXpBHYK45Fm7Gx\nNL4gpLq1bJ6tNlnvP2zj2OOJ7NsiSeH8Vey6NON6E5wDKckBkpZUdg==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_1__map_recipient=age16hnlml8yv4ynwy0seer57g8qww075crd0g7nsundz3pj4wk7m3vqftszg7
sops_age__list_2__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBzOWtwSytMNGRmSHNQN0Zz\nV1RTWktXT1c5VjYvNTg2V0x1U3JSTFA1dmtvCnc4RHRodjVxSzcrZXJycCtJMGNy\nZ3ZwbTFDTUc4TTMxbm8rNXQ1ME90Q1UKLS0tIERPUmg1dSt5OWQwaXV0Y2FiYWdt\nVzlhcFNvUS9Nd3A5ZTYwT2lLUU5WVU0KGHSm1BQ3voKs98WiXNLO6hlqoiQmi1/F\n2RCBkE4/gOVyAmJAjOFizaF0Dhd7Ba4KS5l5QylFHloL8XtyixEhog==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_2__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBUUEM5ZzNST2RHTzRKUUxH\nbGxsOVoxTkVQWnFBa3lweExjSG5tZW02ZTBVCmhmLzRkbEVUUFNOZXdZRG5QUUJL\nL0lvMDArRHBGbmsrR1Y0NE9DcmtwbWcKLS0tIDljdmdGaTVVdnBxdjlYM0IrWTZ1\nYU03M1dHZm5nMDY0ZE9TSzl2Mk9YcG8KZ6vkG1KUIpS3BnIWcluA0nc5YX5AB3RH\nPJqFTdNwUfeB8omOyifF4jxU3Jqm53d3kVmiSPXTlaOYwqrhbT1oAw==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_2__map_recipient=age1g5keveae6zhn059e7cxkjqdz4v3u47ypejv9ujld65nwh6d5pd9qfm0ecv
sops_age__list_3__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBmYlB6cEZKK3NLbm5Ob0hi\nS3M0OFoyTHhVYVA1WmcvSHc2MUErQ2JyaUh3Cnp1NlYvU1BPUnVON29UZWUvWmEz\nOW85N013VUJ3Q1ZZUmMvTWdYclBZTUkKLS0tIG1KODdkazhTUHRPMXZXQnJFNXJY\nOFlSNjZCbkpxcm9tN1dFTkZ4K1lETFUKhiPwKEG71OlTcK/Ul1GKGayLy025vAmo\nNgQUhbqXf7KmqDAmrQNzXTsLsOpRi33l66jFSGkTsFtiXNlmjFljKg==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_3__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB0c2x0YVB4cW1jVFRZVlBP\nNFJiSHIrWk9HWGgreVRSZWhkd3cveDBDZ1RZCkQzYnFxMEhUb2s2U3drZzQwTW5D\nbU9iL0RoN2Ztb0M2UEluOUQ5M3RtMEEKLS0tIEhJRnZGekxWR3dRejV1SkNDQkda\nQkY5MGZyZW1QNzdveXFTbGd0WjRZaTAKbv+2BPThEjNn7yjK4aX4C7kvnXQgf1Qb\n6J72BKD8AtHOJVdp/d1+I41ejwXdfBbSKN5O6rnPozH60GDWFlQRwA==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_3__map_recipient=age12g6f5fse25tgrwweleh4jls3qs52hey2edh759smulwmk5lnzadslu2cp3
sops_age__list_4__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBJUzI2eDB0MU41YWoxcjAv\nOU8yMlowb3IybCtzcFBBTEVlUzdBczZkQlFNCk5tbnhLR2kweGNFZmx1OFgwaWZU\nTzRKZTBZamJjRFNYOUJlYmxTUVg4S2MKLS0tIDNGdlVVc0hLK1BPTUJVc0tPb1lK\ndVVWVi9GRkxwYXR1cXMrdnlsejJBeGsKKmVWvIMrBYH+UrDMkZPxN8KWnCgA6WK2\nbexMYr2AIF2QMbhck7XW2NuvAwvwjbJMfcd+cp9boe+EcC4YjdJZlw==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_4__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB4bzBsVUdlV1dzNVZoWGs2\nR3k0ZGh4cTlNTWdqNXlzc29KeTNTcmd6MHlZCi9wa29CNzcwWmtyT2U2bVArQXlU\nOGJXY3VaT2J5SEd1clY2WFVLa0s2a0EKLS0tIGlYZzRFT29ld2t6SU5tUUhIeUpG\nRnQwcTN0OFZJNGJicUNmeVE0eVcrU3MK96xiB4AkQSXLnP5XvVlQhvjQEQKhTZuO\nYbO1uP0EGSQ+f6uP3ENw4BFAgdB3y0YFcVm5VtTxN4Tpw3ZUVWtqew==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_4__map_recipient=age1tl80n23wq6zxegupwn70ew0yp225ua5v4dk800x7g2w6pvlxz46qk592pa
sops_lastmodified=2024-02-05T17:45:08Z
sops_mac=ENC[AES256_GCM,data:WMsYYvGId0UlRhiw5C4TTpP/7oIIgWteGRtX7Nlo3qQwBc1LSUyN+7kblkXWD+nNG51B0xfl9E5QdXO3Ao2WskQZZEJqqkmt6wSw/ScSPcH56FoIqBhoGsPKyJOZkK0zojnCJQLniTDlISG0UiiJ4KHh8FJDWYbzAy2zFEm3Et8=,iv:zB2hyxnKiA3flsMQ7XFOT/fR9hamd6YWo3BNwce4oWM=,tag:uZ2Ia8sR5R8fmURtJ+PojA==,type:str]
sops_age__list_5__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBoWkEzQlRLNVd3U3hrU2dh\nY0l1OGVlYjJNZEIzK1R5NWFCK1lnK2pMVnlBCksrR1JKZTFmdzJCekZkQStvejBL\nd05wejByZjdnN2xweVhBemNiSDUwUEUKLS0tIEJIVGYzbWNBN2hrNEtoY2xMSk9v\naWw1THRBbmpDbGc2eE1WV0VSVUtoNm8KyCP6BtJRNUfvlAiuzQ1xhpjm6YFUA66G\n/qk9WLm07qHfFD5r2dGQBksN61Nk+akF0BGa56qnAEJp63lP8u1xbg==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_5__map_recipient=age1qy04neuzwpasmvljqrcvhwnf0kz5cpyteze38c8avp0czewskasszv9pyw
sops_lastmodified=2024-03-26T10:02:48Z
sops_mac=ENC[AES256_GCM,data:p7Br4MRgpr26vSS79QBwYRXQWdb20lbrSxOdcrj385EhDVaqZM9aaycf09FItuBrQDuNO3NVSoGnJvWhekwRXXJlxXDENmjP9YBnNt25WXCa2+PLQGzvGbZjo/KHky5zqwVV5XaJ++UuO7VJZgB6m5m7RPZEBCo4cwy7rvNeyEs=,iv:XaavC9VEDckO1QpsPjUwf5BpJzFcEdxycSZvuFX8D7A=,tag:S82ex6+X4MUkkR8m9UAgqA==,type:str]
sops_unencrypted_suffix=_unencrypted
sops_version=3.8.1
+1
View File
@@ -79,3 +79,4 @@ db.sqlite3
.vscode/
*.iml
.devcontainer/
.tool-versions
+3 -1
View File
@@ -5,9 +5,11 @@ creation_rules:
# - Anthony Le-Courric key-id: age1g5keveae6zhn059e7cxkjqdz4v3u47ypejv9ujld65nwh6d5pd9qfm0ecv
# - Antoine Lebaud key-id: age12g6f5fse25tgrwweleh4jls3qs52hey2edh759smulwmk5lnzadslu2cp3
# - Marie Pupo Jeammet key-id: age1tl80n23wq6zxegupwn70ew0yp225ua5v4dk800x7g2w6pvlxz46qk592pa
# - argocd key-id: age1qy04neuzwpasmvljqrcvhwnf0kz5cpyteze38c8avp0czewskasszv9pyw
- age:
age15fyxdwmg5mvldtqqus87xspuws2u0cpvwheehrtvkexj4tnsqqysw6re2x,
age16hnlml8yv4ynwy0seer57g8qww075crd0g7nsundz3pj4wk7m3vqftszg7,
age1g5keveae6zhn059e7cxkjqdz4v3u47ypejv9ujld65nwh6d5pd9qfm0ecv,
age12g6f5fse25tgrwweleh4jls3qs52hey2edh759smulwmk5lnzadslu2cp3,
age1tl80n23wq6zxegupwn70ew0yp225ua5v4dk800x7g2w6pvlxz46qk592pa
age1tl80n23wq6zxegupwn70ew0yp225ua5v4dk800x7g2w6pvlxz46qk592pa,
age1qy04neuzwpasmvljqrcvhwnf0kz5cpyteze38c8avp0czewskasszv9pyw
+26 -13
View File
@@ -11,31 +11,44 @@ RUN apt-get update && \
apt-get -y upgrade && \
rm -rf /var/lib/apt/lists/*
### ---- Front-end dependencies image ----
FROM node:20 as frontend-deps
WORKDIR /deps
COPY ./src/frontend/package.json ./package.json
COPY ./src/frontend/yarn.lock ./yarn.lock
COPY ./src/frontend/apps/desk/package.json ./apps/desk/package.json
COPY ./src/frontend/packages/i18n/package.json ./packages/i18n/package.json
COPY ./src/frontend/packages/eslint-config-people/package.json ./packages/eslint-config-people/package.json
RUN yarn --frozen-lockfile
### ---- Front-end builder image ----
FROM node:20 as front-builder
FROM node:20 as frontend-builder
# Copy frontend app sources
COPY ./src/frontend /builder
WORKDIR /builder
WORKDIR /builder/apps/desk
COPY --from=frontend-deps /deps/node_modules ./node_modules
COPY ./src/frontend .
RUN yarn install --frozen-lockfile && \
yarn build
WORKDIR ./apps/desk
RUN yarn build
# ---- Front-end image ----
FROM nginxinc/nginx-unprivileged:1.25 as frontend
FROM nginxinc/nginx-unprivileged:1.25 as frontend-production
# Un-privileged user running the application
ARG DOCKER_USER
USER ${DOCKER_USER}
COPY --from=front-builder \
COPY --from=frontend-builder \
/builder/apps/desk/out \
/usr/share/nginx/html
COPY ./docker/files/etc/nginx/conf.d /etc/nginx/conf.d:ro
COPY ./src/frontend/apps/desk/conf/default.conf /etc/nginx/conf.d
# Copy entrypoint
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
@@ -134,7 +147,7 @@ WORKDIR /app
ENTRYPOINT [ "/usr/local/bin/entrypoint" ]
# ---- Development image ----
FROM core as development
FROM core as backend-development
# Switch back to the root user to install development dependencies
USER root:root
@@ -159,10 +172,10 @@ ENV DB_HOST=postgresql \
DB_PORT=5432
# Run django development server
CMD python manage.py runserver 0.0.0.0:8000
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
# ---- Production image ----
FROM core as production
FROM core as backend-production
ARG PEOPLE_STATIC_ROOT=/data/static
@@ -181,4 +194,4 @@ COPY --from=link-collector ${PEOPLE_STATIC_ROOT} ${PEOPLE_STATIC_ROOT}
COPY --from=mail-builder /mail/backend/core/templates/mail /app/core/templates/mail
# The default command runs gunicorn WSGI server in people's main module
CMD gunicorn -c /usr/local/etc/gunicorn/people.py people.wsgi:application
CMD ["gunicorn", "-c", "/usr/local/etc/gunicorn/people.py", "people.wsgi:application"]
+3 -4
View File
@@ -92,10 +92,8 @@ bootstrap: \
.PHONY: bootstrap
# -- Docker/compose
build: ## build the dev and demo containers
build: ## build the app-dev container
@$(COMPOSE) build app-dev
@$(COMPOSE) build nginx
@$(COMPOSE) build app
.PHONY: build
down: ## stop and remove containers, networks, images, and volumes
@@ -206,9 +204,10 @@ dbshell: ## connect to database shell
docker compose exec app-dev python manage.py dbshell
.PHONY: dbshell
resetdb: FLUSH_ARGS ?=
resetdb: ## flush database and create a superuser "admin"
@echo "$(BOLD)Flush database$(RESET)"
@$(MANAGE) flush
@$(MANAGE) flush $(FLUSH_ARGS)
@${MAKE} superuser
.PHONY: resetdb
+16
View File
@@ -69,6 +69,22 @@ You first need to create a superuser account:
$ make superuser
```
### Run frontend
Run the front with:
```bash
$ make run-front-desk
```
Then access at
[http://localhost:3000](http://localhost:3000)
user: people
password: people
## Contributing
This project is intended to be community-driven, so please, do not hesitate to
+8 -15
View File
@@ -19,11 +19,11 @@ services:
app-dev:
build:
context: .
target: development
target: backend-development
args:
DOCKER_USER: ${DOCKER_USER:-1000}
user: ${DOCKER_USER:-1000}
image: people:development
image: people:backend-development
environment:
- PYLINTHOME=/app/.pylint.d
- DJANGO_CONFIGURATION=Development
@@ -43,7 +43,7 @@ services:
celery-dev:
user: ${DOCKER_USER:-1000}
image: people:development
image: people:backend-development
command: ["celery", "-A", "people.celery_app", "worker", "-l", "DEBUG"]
environment:
- DJANGO_CONFIGURATION=Development
@@ -60,19 +60,16 @@ services:
app:
build:
context: .
target: production
target: backend-production
args:
DOCKER_USER: ${DOCKER_USER:-1000}
user: ${DOCKER_USER:-1000}
image: people:production
image: people:backend-production
environment:
- DJANGO_CONFIGURATION=Demo
- DJANGO_CORS_ALLOWED_ORIGINS=http://localhost:8088
env_file:
- env.d/development/common
- env.d/development/postgresql
ports:
- "8082:8000"
volumes:
- ./data/media:/data/media
depends_on:
@@ -81,7 +78,7 @@ services:
celery:
user: ${DOCKER_USER:-1000}
image: people:production
image: people:backend-production
command: ["celery", "-A", "people.celery_app", "worker", "-l", "INFO"]
environment:
- DJANGO_CONFIGURATION=Demo
@@ -92,12 +89,8 @@ services:
- app
nginx:
build:
context: .
target: frontend
image: people:frontend
image: nginx:1.25
ports:
- "8088:8088"
- "8083:8083"
volumes:
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
@@ -171,7 +164,7 @@ services:
KC_DB_PASSWORD: pass
KC_DB_USERNAME: people
KC_DB_SCHEMA: public
PROXY_ADDRESS_FORWARDING: true
PROXY_ADDRESS_FORWARDING: 'true'
ports:
- "8080:8080"
depends_on:
+40 -6
View File
@@ -46,6 +46,9 @@
"users": [
{
"username": "people",
"email": "people@people.world",
"firstName": "John",
"lastName": "Doe",
"enabled": true,
"credentials": [
{
@@ -56,12 +59,43 @@
"realmRoles": ["user"]
},
{
"username": "user-e2e",
"username": "user-e2e-chromium",
"email": "user@chromium.e2e",
"firstName": "E2E",
"lastName": "Chromium",
"enabled": true,
"credentials": [
{
"type": "password",
"value": "password-e2e"
"value": "password-e2e-chromium"
}
],
"realmRoles": ["user"]
},
{
"username": "user-e2e-webkit",
"email": "user@webkit.e2e",
"firstName": "E2E",
"lastName": "Webkit",
"enabled": true,
"credentials": [
{
"type": "password",
"value": "password-e2e-webkit"
}
],
"realmRoles": ["user"]
},
{
"username": "user-e2e-firefox",
"email": "user@firefox.e2e",
"firstName": "E2E",
"lastName": "Firefox",
"enabled": true,
"credentials": [
{
"type": "password",
"value": "password-e2e-firefox"
}
],
"realmRoles": ["user"]
@@ -1076,7 +1110,7 @@
},
{
"id": "79712bcf-b7f7-4ca3-b97c-418f48fded9b",
"name": "given name",
"name": "first name",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-property-mapper",
"consentRequired": false,
@@ -1085,7 +1119,7 @@
"user.attribute": "firstName",
"id.token.claim": "true",
"access.token.claim": "true",
"claim.name": "given_name",
"claim.name": "first_name",
"jsonType.label": "String"
}
},
@@ -1106,7 +1140,7 @@
},
{
"id": "7f741e96-41fe-4021-bbfd-506e7eb94e69",
"name": "family name",
"name": "last name",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-property-mapper",
"consentRequired": false,
@@ -1115,7 +1149,7 @@
"user.attribute": "lastName",
"id.token.claim": "true",
"access.token.claim": "true",
"claim.name": "family_name",
"claim.name": "last_name",
"jsonType.label": "String"
}
},
@@ -1,19 +1,3 @@
server {
listen 8088;
server_name localhost;
root /usr/share/nginx/html;
location / {
try_files $uri index.html $uri/ =404;
}
error_page 404 /404.html;
location = /404.html {
internal;
}
}
server {
listen 8083;
@@ -27,4 +11,3 @@ server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
+3
View File
@@ -0,0 +1,3 @@
# For the CI job test-e2e
SUSTAINED_THROTTLE_RATES="200/hour"
BURST_THROTTLE_RATES="200/minute"
+1 -1
View File
@@ -8,4 +8,4 @@ DB_HOST=kc_postgresql
DB_NAME=keycloak
DB_USER=people
DB_PASSWORD=pass
DB_PORT=5433
DB_PORT=5433
+1 -1
View File
@@ -8,4 +8,4 @@ DB_HOST=postgresql
DB_NAME=people
DB_USER=dinum
DB_PASSWORD=pass
DB_PORT=5432
DB_PORT=5432
+1
View File
@@ -1,6 +1,7 @@
{
"extends": ["github>numerique-gouv/renovate-configuration"],
"dependencyDashboard": true,
"labels": ["dependencies", "noChangeLog"],
"packageRules": [
{
"enabled": false,
+52
View File
@@ -1,4 +1,5 @@
"""Admin classes and registrations for People's core app."""
from django import forms
from django.contrib import admin
from django.contrib.auth import admin as auth_admin
@@ -82,6 +83,15 @@ class UserAdmin(auth_admin.UserAdmin):
),
(_("Important dates"), {"fields": ("created_at", "updated_at")}),
)
add_fieldsets = (
(
None,
{
"classes": ("wide",),
"fields": ("email", "password1", "password2"),
},
),
)
inlines = (IdentityInline, TeamAccessInline)
list_display = (
"email",
@@ -110,3 +120,45 @@ class TeamAdmin(admin.ModelAdmin):
"updated_at",
)
search_fields = ("name",)
@admin.register(models.Invitation)
class InvitationAdmin(admin.ModelAdmin):
"""Admin interface to handle invitations."""
fields = (
"email",
"team",
"role",
"created_at",
"issuer",
)
readonly_fields = (
"created_at",
"is_expired",
"issuer",
)
list_display = (
"email",
"team",
"created_at",
"is_expired",
)
def get_readonly_fields(self, request, obj=None):
if obj:
# all fields read only = disable update
return self.fields
return self.readonly_fields
def change_view(self, request, object_id, form_url="", extra_context=None):
"""Custom edit form. Remove 'save' buttons."""
extra_context = extra_context or {}
extra_context["show_save_and_continue"] = False
extra_context["show_save"] = False
extra_context["show_save_and_add_another"] = False
return super().change_view(request, object_id, extra_context=extra_context)
def save_model(self, request, obj, form, change):
obj.issuer = request.user
obj.save()
+1
View File
@@ -1,4 +1,5 @@
"""People core API endpoints"""
from django.conf import settings
from django.core.exceptions import ValidationError
+1
View File
@@ -1,4 +1,5 @@
"""Permission handlers for the People core app."""
from django.core import exceptions
from rest_framework import permissions
+119 -10
View File
@@ -1,4 +1,5 @@
"""Client serializers for the People core app."""
from rest_framework import exceptions, serializers
from timezone_field.rest_framework import TimeZoneSerializerField
@@ -26,28 +27,66 @@ class ContactSerializer(serializers.ModelSerializer):
return super().update(instance, validated_data)
class UserSerializer(serializers.ModelSerializer):
class DynamicFieldsModelSerializer(serializers.ModelSerializer):
"""
A ModelSerializer that takes an additional `fields` argument that
controls which fields should be displayed.
"""
def __init__(self, *args, **kwargs):
# Don't pass the 'fields' arg up to the superclass
fields = kwargs.pop("fields", None)
# Instantiate the superclass normally
super().__init__(*args, **kwargs)
if fields is not None:
# Drop any fields that are not specified in the `fields` argument.
allowed = set(fields)
existing = set(self.fields)
for field_name in existing - allowed:
self.fields.pop(field_name)
class UserSerializer(DynamicFieldsModelSerializer):
"""Serialize users."""
data = serializers.SerializerMethodField(read_only=True)
timezone = TimeZoneSerializerField(use_pytz=False, required=True)
name = serializers.SerializerMethodField(read_only=True)
email = serializers.SerializerMethodField(read_only=True)
class Meta:
model = models.User
fields = [
"id",
"name",
"email",
"data",
"language",
"timezone",
"is_device",
"is_staff",
]
read_only_fields = ["id", "email", "data", "is_device", "is_staff"]
read_only_fields = ["id", "name", "email", "is_device", "is_staff"]
def get_data(self, user) -> dict:
"""Return contact data for the user."""
return user.profile_contact.data if user.profile_contact else {}
def _get_main_identity_attr(self, obj, attribute_name):
"""Return the specified attribute of the main identity."""
try:
return getattr(obj.main_identity[0], attribute_name)
except TypeError:
return getattr(obj.main_identity, attribute_name)
except IndexError:
main_identity = obj.identities.filter(is_main=True).first()
return getattr(obj.main_identity, attribute_name) if main_identity else None
except AttributeError:
return None
def get_name(self, obj):
"""Return main identity's name."""
return self._get_main_identity_attr(obj, "name")
def get_email(self, obj):
"""Return main identity's email."""
return self._get_main_identity_attr(obj, "email")
class TeamAccessSerializer(serializers.ModelSerializer):
@@ -126,17 +165,52 @@ class TeamAccessSerializer(serializers.ModelSerializer):
return attrs
class TeamAccessReadOnlySerializer(TeamAccessSerializer):
"""Serialize team accesses for list and retrieve actions."""
user = UserSerializer(read_only=True, fields=["id", "name", "email"])
class Meta:
model = models.TeamAccess
fields = [
"id",
"user",
"role",
"abilities",
]
read_only_fields = [
"id",
"user",
"role",
"abilities",
]
class TeamSerializer(serializers.ModelSerializer):
"""Serialize teams."""
abilities = serializers.SerializerMethodField(read_only=True)
accesses = TeamAccessSerializer(many=True, read_only=True)
slug = serializers.SerializerMethodField()
class Meta:
model = models.Team
fields = ["id", "name", "accesses", "abilities", "slug"]
read_only_fields = ["id", "accesses", "abilities", "slug"]
fields = [
"id",
"name",
"accesses",
"abilities",
"slug",
"created_at",
"updated_at",
]
read_only_fields = [
"id",
"accesses",
"abilities",
"slug",
"created_at",
"updated_at",
]
def get_abilities(self, team) -> dict:
"""Return abilities of the logged-in user on the instance."""
@@ -148,3 +222,38 @@ class TeamSerializer(serializers.ModelSerializer):
def get_slug(self, instance):
"""Return slug from the team's name."""
return instance.get_slug()
class InvitationSerializer(serializers.ModelSerializer):
"""Serialize invitations."""
class Meta:
model = models.Invitation
fields = ["id", "created_at", "email", "team", "role", "issuer", "is_expired"]
read_only_fields = ["id", "created_at", "team", "issuer", "is_expired"]
def validate(self, attrs):
"""Validate and restrict invitation to new user based on email."""
request = self.context.get("request")
user = getattr(request, "user", None)
try:
team_id = self.context["team_id"]
except KeyError as exc:
raise exceptions.ValidationError(
"You must set a team ID in kwargs to create a new team invitation."
) from exc
if not models.TeamAccess.objects.filter(
team=team_id,
user=user,
role__in=[models.RoleChoices.OWNER, models.RoleChoices.ADMIN],
).exists():
raise exceptions.PermissionDenied(
"You are not allowed to manage invitation for this team."
)
attrs["team_id"] = team_id
attrs["issuer"] = user
return attrs
-14
View File
@@ -1,14 +0,0 @@
"""
Utils that can be useful throughout the People core app
"""
from rest_framework_simplejwt.tokens import RefreshToken
def get_tokens_for_user(user):
"""Get JWT tokens for user authentication."""
refresh = RefreshToken.for_user(user)
return {
"refresh": str(refresh),
"access": str(refresh.access_token),
}
+148 -21
View File
@@ -1,24 +1,25 @@
"""API endpoints"""
from django.contrib.postgres.search import TrigramSimilarity
from django.db.models import Func, Max, OuterRef, Q, Subquery, Value
from django.db.models import Func, Max, OuterRef, Prefetch, Q, Subquery, Value
from django.db.models.functions import Coalesce
from rest_framework import (
decorators,
exceptions,
filters,
mixins,
pagination,
response,
throttling,
viewsets,
)
from rest_framework.throttling import UserRateThrottle
from core import models
from . import permissions, serializers
EMAIL_SIMILARITY_THRESHOLD = 0.01
# TrigramSimilarity threshold is lower for searching email than for names,
# to improve matching results
SIMILARITY_THRESHOLD = 0.04
class NestedGenericViewSet(viewsets.GenericViewSet):
@@ -96,12 +97,11 @@ class SerializerPerActionMixin:
class Pagination(pagination.PageNumberPagination):
"""Pagination to display no more than 100 objects per page sorted by creation date."""
ordering = "-created_on"
max_page_size = 100
page_size_query_param = "page_size"
class BurstRateThrottle(UserRateThrottle):
class BurstRateThrottle(throttling.UserRateThrottle):
"""
Throttle rate for minutes. See DRF section in settings for default value.
"""
@@ -109,7 +109,7 @@ class BurstRateThrottle(UserRateThrottle):
scope = "burst"
class SustainedRateThrottle(UserRateThrottle):
class SustainedRateThrottle(throttling.UserRateThrottle):
"""
Throttle rate for hours. See DRF section in settings for default value.
"""
@@ -185,7 +185,7 @@ class UserViewSet(
"""
permission_classes = [permissions.IsSelf]
queryset = models.User.objects.all().select_related("profile_contact")
queryset = models.User.objects.all().order_by("-created_at")
serializer_class = serializers.UserSerializer
throttle_classes = [BurstRateThrottle, SustainedRateThrottle]
pagination_class = Pagination
@@ -198,19 +198,37 @@ class UserViewSet(
# Exclude inactive contacts
queryset = queryset.filter(
is_active=True,
).prefetch_related(
Prefetch(
"identities",
queryset=models.Identity.objects.filter(is_main=True),
to_attr="main_identity",
)
)
# Exclude all users already in the given team
if team_id := self.request.GET.get("team_id", ""):
queryset = queryset.exclude(teams__id=team_id)
# Search by case-insensitive and accent-insensitive trigram similarity
if query := self.request.GET.get("q", ""):
similarity = Max(
TrigramSimilarity(
Func("identities__email", function="unaccent"),
Coalesce(
Func("identities__email", function="unaccent"), Value("")
),
Func(Value(query), function="unaccent"),
)
+ TrigramSimilarity(
Coalesce(
Func("identities__name", function="unaccent"), Value("")
),
Func(Value(query), function="unaccent"),
)
)
queryset = (
queryset.annotate(similarity=similarity)
.filter(similarity__gte=EMAIL_SIMILARITY_THRESHOLD)
.filter(similarity__gte=SIMILARITY_THRESHOLD)
.order_by("-similarity")
)
@@ -226,9 +244,12 @@ class UserViewSet(
"""
Return information on currently logged user
"""
context = {"request": request}
user = request.user
user.main_identity = models.Identity.objects.filter(
user=user, is_main=True
).first()
return response.Response(
self.serializer_class(request.user, context=context).data
self.serializer_class(user, context={"request": request}).data
)
@@ -244,6 +265,9 @@ class TeamViewSet(
permission_classes = [permissions.AccessPermission]
serializer_class = serializers.TeamSerializer
filter_backends = [filters.OrderingFilter]
ordering_fields = ["created_at"]
ordering = ["-created_at"]
queryset = models.Team.objects.all()
def get_queryset(self):
@@ -300,8 +324,15 @@ class TeamAccessViewSet(
lookup_field = "pk"
pagination_class = Pagination
permission_classes = [permissions.AccessPermission]
queryset = models.TeamAccess.objects.all().select_related("user")
serializer_class = serializers.TeamAccessSerializer
queryset = (
models.TeamAccess.objects.all().select_related("user").order_by("-created_at")
)
list_serializer_class = serializers.TeamAccessReadOnlySerializer
detail_serializer_class = serializers.TeamAccessSerializer
filter_backends = [filters.OrderingFilter]
ordering = ["role"]
ordering_fields = ["role", "email", "name"]
def get_permissions(self):
"""User only needs to be authenticated to list team accesses"""
@@ -318,23 +349,45 @@ class TeamAccessViewSet(
context["team_id"] = self.kwargs["team_id"]
return context
def get_serializer_class(self):
if self.action in {"list", "retrieve"}:
return self.list_serializer_class
return self.detail_serializer_class
def get_queryset(self):
"""Return the queryset according to the action."""
queryset = super().get_queryset()
queryset = queryset.filter(team=self.kwargs["team_id"])
if self.action == "list":
# Limit to team access instances related to a team THAT also has a team access
# instance for the logged-in user (we don't want to list only the team access
# instances pointing to the logged-in user)
if self.action in {"list", "retrieve"}:
# Determine which role the logged-in user has in the team
user_role_query = models.TeamAccess.objects.filter(
team__accesses__user=self.request.user
user=self.request.user, team=self.kwargs["team_id"]
).values("role")[:1]
user_main_identity_query = models.Identity.objects.filter(
user=OuterRef("user_id"), is_main=True
)
queryset = (
# The logged-in user should be part of a team to see its accesses
queryset.filter(
team__accesses__user=self.request.user,
)
.annotate(user_role=Subquery(user_role_query))
.prefetch_related(
Prefetch(
"user__identities",
queryset=models.Identity.objects.filter(is_main=True),
to_attr="main_identity",
)
)
# Abilities are computed based on logged-in user's role and
# the user role on each team access
.annotate(
user_role=Subquery(user_role_query),
email=Subquery(user_main_identity_query.values("email")[:1]),
name=Subquery(user_main_identity_query.values("name")[:1]),
)
.distinct()
)
return queryset
@@ -372,3 +425,77 @@ class TeamAccessViewSet(
raise exceptions.ValidationError({"role": message})
serializer.save()
class InvitationViewset(
mixins.CreateModelMixin,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.DestroyModelMixin,
viewsets.GenericViewSet,
):
"""API ViewSet for user invitations to team.
GET /api/v1.0/teams/<team_id>/invitations/:<invitation_id>/
Return list of invitations related to that team or or one
team access if an id is provided.
POST /api/v1.0/teams/<team_id>/invitations/ with expected data:
- email: str
- role: str [owner|admin|member]
- issuer : User, automatically added from user making query, if allowed
- team : Team, automatically added from requested URI
Return newly created invitation
PUT / PATCH : Not permitted. Instead of updating your invitation,
delete and create a new one.
DELETE /api/v1.0/teams/<team_id>/invitations/<invitation_id>/
Delete targeted invitation
"""
lookup_field = "id"
pagination_class = Pagination
permission_classes = [permissions.AccessPermission]
queryset = (
models.Invitation.objects.all().select_related("team").order_by("-created_at")
)
serializer_class = serializers.InvitationSerializer
def get_permissions(self):
"""User only needs to be authenticated to list invitations"""
if self.action == "list":
permission_classes = [permissions.IsAuthenticated]
else:
return super().get_permissions()
return [permission() for permission in permission_classes]
def get_serializer_context(self):
"""Extra context provided to the serializer class."""
context = super().get_serializer_context()
context["team_id"] = self.kwargs["team_id"]
return context
def get_queryset(self):
"""Return the queryset according to the action."""
queryset = super().get_queryset()
queryset = queryset.filter(team=self.kwargs["team_id"])
if self.action == "list":
# Determine which role the logged-in user has in the team
user_role_query = models.TeamAccess.objects.filter(
user=self.request.user, team=self.kwargs["team_id"]
).values("role")[:1]
queryset = (
# The logged-in user should be part of a team to see its accesses
queryset.filter(
team__accesses__user=self.request.user,
)
# Abilities are computed based on logged-in user's role and
# the user role on each team access
.annotate(user_role=Subquery(user_role_query))
.distinct()
)
return queryset
+33 -22
View File
@@ -1,7 +1,7 @@
"""Authentication for the People core app."""
import logging
from django.conf import settings
from django.core.exceptions import SuspiciousOperation
from django.db import models
from django.utils.translation import gettext_lazy as _
@@ -9,18 +9,15 @@ import requests
from mozilla_django_oidc.auth import (
OIDCAuthenticationBackend as MozillaOIDCAuthenticationBackend,
)
from rest_framework_simplejwt.exceptions import InvalidToken
from .models import Identity
LOGGER = logging.getLogger(__name__)
class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
"""Custom OpenID Connect (OIDC) Authentication Backend.
This class overrides the default OIDC Authentication Backend to accommodate differences
in the User and Identity models, as well as the userinfo endpoint behavior in Agent Connect.
in the User and Identity models, and handles signed and/or encrypted UserInfo response.
"""
def get_userinfo(self, access_token, id_token, payload):
@@ -34,9 +31,9 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
Note: The id_token and payload parameters are unused in this implementation,
but were kept to preserve base method signature.
Note: Userinfo endpoint returns a signed JWT, and not 'application/json'. This
constraint comes from Agent Connect. It forces us to override the base method,
which deal with 'application/json' response.
Note: It handles signed and/or encrypted UserInfo Response. It is required by
Agent Connect, which follows the OIDC standard. It forces us to override the
base method, which deal with 'application/json' response.
Returns:
- dict: User details dictionary obtained from the OpenID Connect user endpoint.
@@ -70,24 +67,39 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
user_info = self.get_userinfo(access_token, id_token, payload)
email = user_info.get("email")
sub = user_info.get("sub")
# Compute user name from OIDC name fields as defined in settings
names_list = [
user_info[field]
for field in settings.USER_OIDC_FIELDS_TO_NAME
if user_info.get(field)
]
user_info["name"] = " ".join(names_list) or None
sub = user_info.get("sub")
if sub is None:
raise InvalidToken(
raise SuspiciousOperation(
_("User info contained no recognizable user identification")
)
user = (
self.UserModel.objects.filter(identities__sub=sub)
.annotate(identity_email=models.F("identities__email"))
.annotate(
identity_email=models.F("identities__email"),
identity_name=models.F("identities__name"),
)
.distinct()
.first()
)
if user:
if email and email != user.identity_email:
Identity.objects.filter(sub=sub).update(email=email)
email = user_info.get("email")
name = user_info.get("name")
if (
email
and email != user.identity_email
or name
and name != user.identity_name
):
Identity.objects.filter(sub=sub).update(email=email, name=name)
elif self.get_settings("OIDC_CREATE_USER", True):
user = self.create_user(user_info)
@@ -96,16 +108,15 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
def create_user(self, claims):
"""Return a newly created User instance."""
email = claims.get("email")
sub = claims.get("sub")
if sub is None:
raise InvalidToken(
raise SuspiciousOperation(
_("Claims contained no recognizable user identification")
)
user = self.UserModel.objects.create(password="!", email=email) # noqa: S106
Identity.objects.create(user=user, sub=sub, email=email)
user = self.UserModel.objects.create(password="!") # noqa: S106
Identity.objects.create(
user=user, sub=sub, email=claims.get("email"), name=claims.get("name")
)
return user
+1
View File
@@ -1,6 +1,7 @@
"""
Core application enums declaration
"""
from django.conf import global_settings, settings
from django.utils.translation import gettext_lazy as _
+23 -6
View File
@@ -2,6 +2,7 @@
"""
Core application factories
"""
from django.conf import settings
from django.contrib.auth.hashers import make_password
@@ -123,6 +124,7 @@ class UserFactory(factory.django.DjangoModelFactory):
class Meta:
model = models.User
django_get_or_create = ("email",)
email = factory.Faker("email")
language = factory.fuzzy.FuzzyChoice([lang[0] for lang in settings.LANGUAGES])
@@ -139,6 +141,7 @@ class IdentityFactory(factory.django.DjangoModelFactory):
user = factory.SubFactory(UserFactory)
sub = factory.Sequence(lambda n: f"user{n!s}")
email = factory.Faker("email")
name = factory.Faker("name")
class TeamFactory(factory.django.DjangoModelFactory):
@@ -147,18 +150,20 @@ class TeamFactory(factory.django.DjangoModelFactory):
class Meta:
model = models.Team
django_get_or_create = ("name",)
skip_postgeneration_save = True
name = factory.Sequence(lambda n: f"team{n}")
@factory.post_generation
def users(self, create, extracted, **kwargs):
"""Add users to team from a given list of users with or without roles."""
if create and extracted:
for item in extracted:
if isinstance(item, models.User):
TeamAccessFactory(team=self, user=item)
else:
TeamAccessFactory(team=self, user=item[0], role=item[1])
if not create or not extracted:
return
for user_entry in extracted:
if isinstance(user_entry, models.User):
TeamAccessFactory(team=self, user=user_entry)
else:
TeamAccessFactory(team=self, user=user_entry[0], role=user_entry[1])
class TeamAccessFactory(factory.django.DjangoModelFactory):
@@ -170,3 +175,15 @@ class TeamAccessFactory(factory.django.DjangoModelFactory):
team = factory.SubFactory(TeamFactory)
user = factory.SubFactory(UserFactory)
role = factory.fuzzy.FuzzyChoice([r[0] for r in models.RoleChoices.choices])
class InvitationFactory(factory.django.DjangoModelFactory):
"""A factory to create invitations for a user"""
class Meta:
model = models.Invitation
team = factory.SubFactory(TeamFactory)
email = factory.Faker("email")
role = factory.fuzzy.FuzzyChoice([role[0] for role in models.RoleChoices.choices])
issuer = factory.SubFactory(UserFactory)
+26 -4
View File
@@ -1,6 +1,6 @@
# Generated by Django 5.0.1 on 2024-02-06 15:08
# Generated by Django 5.0.2 on 2024-03-05 17:09
import core.models
import django.contrib.auth.models
import django.core.validators
import django.db.models.deletion
import timezone_field.fields
@@ -27,7 +27,7 @@ class Migration(migrations.Migration):
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created at')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated at')),
('name', models.CharField(max_length=100)),
('slug', models.SlugField(max_length=100, unique=True)),
('slug', models.SlugField(editable=False, max_length=100, unique=True)),
],
options={
'verbose_name': 'Team',
@@ -60,7 +60,7 @@ class Migration(migrations.Migration):
'db_table': 'people_user',
},
managers=[
('objects', core.models.UserManager()),
('objects', django.contrib.auth.models.UserManager()),
],
),
migrations.CreateModel(
@@ -95,6 +95,7 @@ class Migration(migrations.Migration):
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated at')),
('sub', models.CharField(help_text='Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only.', max_length=255, 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')),
('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='email address')),
('name', models.CharField(blank=True, max_length=100, null=True, verbose_name='name')),
('is_main', models.BooleanField(default=False, help_text='Designates whether the email is the main one.', verbose_name='main')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='identities', to=settings.AUTH_USER_MODEL)),
],
@@ -105,6 +106,23 @@ class Migration(migrations.Migration):
'ordering': ('-is_main', 'email'),
},
),
migrations.CreateModel(
name='Invitation',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created at')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated at')),
('email', models.EmailField(max_length=254, verbose_name='email address')),
('role', models.CharField(choices=[('member', 'Member'), ('administrator', 'Administrator'), ('owner', 'Owner')], default='member', max_length=20)),
('issuer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invitations', to=settings.AUTH_USER_MODEL)),
('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invitations', to='core.team')),
],
options={
'verbose_name': 'Team invitation',
'verbose_name_plural': 'Team invitations',
'db_table': 'people_invitation',
},
),
migrations.CreateModel(
name='TeamAccess',
fields=[
@@ -142,6 +160,10 @@ class Migration(migrations.Migration):
model_name='identity',
constraint=models.UniqueConstraint(fields=('user', 'email'), name='unique_user_email', violation_error_message='This email address is already declared for this user.'),
),
migrations.AddConstraint(
model_name='invitation',
constraint=models.UniqueConstraint(fields=('email', 'team'), name='email_and_team_unique_together'),
),
migrations.AddConstraint(
model_name='teamaccess',
constraint=models.UniqueConstraint(fields=('user', 'team'), name='unique_team_user', violation_error_message='This user is already in this team.'),
+154 -14
View File
@@ -1,22 +1,31 @@
"""
Declare and configure the models for the People core application
"""
import json
import os
import smtplib
import uuid
from datetime import timedelta
from logging import getLogger
from django.conf import settings
from django.contrib.auth import models as auth_models
from django.contrib.auth.base_user import AbstractBaseUser
from django.core import exceptions, mail, validators
from django.db import models
from django.template.loader import render_to_string
from django.utils import timezone
from django.utils.functional import lazy
from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _
from django.utils.translation import override
import jsonschema
from timezone_field import TimeZoneField
logger = getLogger(__name__)
current_dir = os.path.dirname(os.path.abspath(__file__))
contact_schema_path = os.path.join(current_dir, "jsonschema", "contact_data.json")
with open(contact_schema_path, "r", encoding="utf-8") as contact_schema_file:
@@ -145,16 +154,6 @@ class Contact(BaseModel):
raise exceptions.ValidationError({"data": [error_message]}) from e
class UserManager(auth_models.UserManager):
"""
Override user manager to get the related contact in the same query by default (Contact model)
"""
def get_queryset(self):
"""Always select the related contact when doing a query on users."""
return super().get_queryset().select_related("profile_contact")
class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
"""User model to work with OIDC only authentication."""
@@ -198,7 +197,7 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
),
)
objects = UserManager()
objects = auth_models.UserManager()
USERNAME_FIELD = "email"
REQUIRED_FIELDS = []
@@ -262,6 +261,7 @@ class Identity(BaseModel):
validators=[sub_validator],
)
email = models.EmailField(_("email address"), null=True, blank=True)
name = models.CharField(_("name"), max_length=100, null=True, blank=True)
is_main = models.BooleanField(
_("main"),
default=False,
@@ -286,14 +286,49 @@ class Identity(BaseModel):
def __str__(self):
main_str = "[main]" if self.is_main else ""
return f"{self.email:s}{main_str:s}"
id_str = self.email or self.sub
return f"{id_str:s}{main_str:s}"
def save(self, *args, **kwargs):
"""Ensure users always have one and only one main identity."""
"""
Saves identity, ensuring users always have exactly one main identity.
If it's a new identity, give its user access to the relevant teams.
"""
if self._state.adding:
self._convert_valid_invitations()
super().save(*args, **kwargs)
# Ensure users always have one and only one main identity.
if self.is_main is True:
self.user.identities.exclude(id=self.id).update(is_main=False)
def _convert_valid_invitations(self):
"""
Convert valid invitations to team accesses.
Expired invitations are ignored.
"""
valid_invitations = Invitation.objects.filter(
email=self.email,
created_at__gte=(
timezone.now()
- timedelta(seconds=settings.INVITATION_VALIDITY_DURATION)
),
).select_related("team")
if not valid_invitations.exists():
return
TeamAccess.objects.bulk_create(
[
TeamAccess(user=self.user, team=invitation.team, role=invitation.role)
for invitation in valid_invitations
]
)
valid_invitations.delete()
def clean(self):
"""Normalize the email field and clean the 'is_main' field."""
if self.email:
@@ -360,7 +395,7 @@ class Team(BaseModel):
is_owner_or_admin = role in [RoleChoices.OWNER, RoleChoices.ADMIN]
return {
"get": True,
"get": bool(role),
"patch": is_owner_or_admin,
"put": is_owner_or_admin,
"delete": role == RoleChoices.OWNER,
@@ -448,3 +483,108 @@ class TeamAccess(BaseModel):
"put": bool(set_role_to),
"set_role_to": set_role_to,
}
class Invitation(BaseModel):
"""User invitation to teams."""
email = models.EmailField(_("email address"), null=False, blank=False)
team = models.ForeignKey(
Team,
on_delete=models.CASCADE,
related_name="invitations",
)
role = models.CharField(
max_length=20, choices=RoleChoices.choices, default=RoleChoices.MEMBER
)
issuer = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name="invitations",
)
class Meta:
db_table = "people_invitation"
verbose_name = _("Team invitation")
verbose_name_plural = _("Team invitations")
constraints = [
models.UniqueConstraint(
fields=["email", "team"], name="email_and_team_unique_together"
)
]
def __str__(self):
return f"{self.email} invited to {self.team}"
def save(self, *args, **kwargs):
"""Make invitations read-only."""
if self.created_at:
raise exceptions.PermissionDenied()
super().save(*args, **kwargs)
self.email_invitation()
def clean(self):
"""Validate fields."""
super().clean()
# Check if an identity already exists for the provided email
if Identity.objects.filter(email=self.email).exists():
raise exceptions.ValidationError(
{"email": _("This email is already associated to a registered user.")}
)
@property
def is_expired(self):
"""Calculate if invitation is still valid or has expired."""
if not self.created_at:
return None
validity_duration = timedelta(seconds=settings.INVITATION_VALIDITY_DURATION)
return timezone.now() > (self.created_at + validity_duration)
def get_abilities(self, user):
"""Compute and return abilities for a given user."""
can_delete = False
role = None
if user.is_authenticated:
try:
role = self.user_role
except AttributeError:
try:
role = self.team.accesses.filter(user=user).values("role")[0][
"role"
]
except (self._meta.model.DoesNotExist, IndexError):
role = None
can_delete = role in [RoleChoices.OWNER, RoleChoices.ADMIN]
return {
"delete": can_delete,
"get": bool(role),
"patch": False,
"put": False,
}
def email_invitation(self):
"""Email invitation to the user."""
try:
with override(self.issuer.language):
template_vars = {
"title": _("Invitation to join Desk!"),
}
msg_html = render_to_string("mail/html/invitation.html", template_vars)
msg_plain = render_to_string("mail/text/invitation.txt", template_vars)
mail.send_mail(
_("Invitation to join Desk!"),
msg_plain,
settings.EMAIL_FROM,
[self.email],
html_message=msg_html,
fail_silently=False,
)
except smtplib.SMTPException as exception:
logger.error("invitation to %s was not sent: %s", self.email, exception)
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

@@ -0,0 +1,58 @@
"""Custom template tags for the core application of People."""
import base64
from django import template
from django.contrib.staticfiles import finders
from PIL import ImageFile as PillowImageFile
register = template.Library()
def image_to_base64(file_or_path, close=False):
"""
Return the src string of the base64 encoding of an image represented by its path
or file opened or not.
Inspired by Django's "get_image_dimensions"
"""
pil_parser = PillowImageFile.Parser()
if hasattr(file_or_path, "read"):
file = file_or_path
if file.closed and hasattr(file, "open"):
file_or_path.open()
file_pos = file.tell()
file.seek(0)
else:
try:
# pylint: disable=consider-using-with
file = open(file_or_path, "rb")
except OSError:
return ""
close = True
try:
image_data = file.read()
if not image_data:
return ""
pil_parser.feed(image_data)
if pil_parser.image:
mime_type = pil_parser.image.get_format_mimetype()
encoded_string = base64.b64encode(image_data)
return f"data:{mime_type:s};base64, {encoded_string.decode('utf-8'):s}"
return ""
finally:
if close:
file.close()
else:
file.seek(file_pos)
@register.simple_tag
def base64_static(path):
"""Return a static file into a base64."""
full_path = finders.find(path)
if full_path:
return image_to_base64(full_path, True)
return ""
@@ -1,6 +1,7 @@
"""
Test suite for generated openapi schema.
"""
import json
from io import StringIO
@@ -0,0 +1,176 @@
"""
Test for team accesses API endpoints in People's core app : create
"""
import random
import pytest
from rest_framework.test import APIClient
from core import factories, models
pytestmark = pytest.mark.django_db
def test_api_team_accesses_create_anonymous():
"""Anonymous users should not be allowed to create team accesses."""
user = factories.UserFactory()
team = factories.TeamFactory()
response = APIClient().post(
f"/api/v1.0/teams/{team.id!s}/accesses/",
{
"user": str(user.id),
"team": str(team.id),
"role": random.choice(models.RoleChoices.choices)[0],
},
format="json",
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
assert models.TeamAccess.objects.exists() is False
def test_api_team_accesses_create_authenticated_unrelated():
"""
Authenticated users should not be allowed to create team accesses for a team to
which they are not related.
"""
identity = factories.IdentityFactory()
user = identity.user
other_user = factories.UserFactory()
team = factories.TeamFactory()
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/teams/{team.id!s}/accesses/",
{
"user": str(other_user.id),
},
format="json",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You are not allowed to manage accesses for this team."
}
assert not models.TeamAccess.objects.filter(user=other_user).exists()
def test_api_team_accesses_create_authenticated_member():
"""Members of a team should not be allowed to create team accesses."""
identity = factories.IdentityFactory()
user = identity.user
team = factories.TeamFactory(users=[(user, "member")])
other_user = factories.UserFactory()
client = APIClient()
client.force_login(user)
for role in [role[0] for role in models.RoleChoices.choices]:
response = client.post(
f"/api/v1.0/teams/{team.id!s}/accesses/",
{
"user": str(other_user.id),
"role": role,
},
format="json",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You are not allowed to manage accesses for this team."
}
assert not models.TeamAccess.objects.filter(user=other_user).exists()
def test_api_team_accesses_create_authenticated_administrator():
"""
Administrators of a team should be able to create team accesses except for the "owner" role.
"""
identity = factories.IdentityFactory()
user = identity.user
team = factories.TeamFactory(users=[(user, "administrator")])
other_user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# It should not be allowed to create an owner access
response = client.post(
f"/api/v1.0/teams/{team.id!s}/accesses/",
{
"user": str(other_user.id),
"role": "owner",
},
format="json",
)
assert response.status_code == 403
assert response.json() == {
"detail": "Only owners of a team can assign other users as owners."
}
# It should be allowed to create a lower access
role = random.choice(
[role[0] for role in models.RoleChoices.choices if role[0] != "owner"]
)
response = client.post(
f"/api/v1.0/teams/{team.id!s}/accesses/",
{
"user": str(other_user.id),
"role": role,
},
format="json",
)
assert response.status_code == 201
assert models.TeamAccess.objects.filter(user=other_user).count() == 1
new_team_access = models.TeamAccess.objects.filter(user=other_user).get()
assert response.json() == {
"abilities": new_team_access.get_abilities(user),
"id": str(new_team_access.id),
"role": role,
"user": str(other_user.id),
}
def test_api_team_accesses_create_authenticated_owner():
"""
Owners of a team should be able to create team accesses whatever the role.
"""
identity = factories.IdentityFactory()
user = identity.user
team = factories.TeamFactory(users=[(user, "owner")])
other_user = factories.UserFactory()
role = random.choice([role[0] for role in models.RoleChoices.choices])
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/teams/{team.id!s}/accesses/",
{
"user": str(other_user.id),
"role": role,
},
format="json",
)
assert response.status_code == 201
assert models.TeamAccess.objects.filter(user=other_user).count() == 1
new_team_access = models.TeamAccess.objects.filter(user=other_user).get()
assert response.json() == {
"abilities": new_team_access.get_abilities(user),
"id": str(new_team_access.id),
"role": role,
"user": str(other_user.id),
}
@@ -0,0 +1,165 @@
"""
Test for team accesses API endpoints in People's core app : delete
"""
import random
import pytest
from rest_framework.test import APIClient
from core import factories, models
pytestmark = pytest.mark.django_db
def test_api_team_accesses_delete_anonymous():
"""Anonymous users should not be allowed to destroy a team access."""
access = factories.TeamAccessFactory()
response = APIClient().delete(
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 401
assert models.TeamAccess.objects.count() == 1
def test_api_team_accesses_delete_authenticated():
"""
Authenticated users should not be allowed to delete a team access for a
team to which they are not related.
"""
identity = factories.IdentityFactory()
user = identity.user
access = factories.TeamAccessFactory()
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 403
assert models.TeamAccess.objects.count() == 1
def test_api_team_accesses_delete_member():
"""
Authenticated users should not be allowed to delete a team access for a
team in which they are a simple member.
"""
identity = factories.IdentityFactory()
user = identity.user
team = factories.TeamFactory(users=[(user, "member")])
access = factories.TeamAccessFactory(team=team)
assert models.TeamAccess.objects.count() == 2
assert models.TeamAccess.objects.filter(user=access.user).exists()
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 403
assert models.TeamAccess.objects.count() == 2
def test_api_team_accesses_delete_administrators():
"""
Users who are administrators in a team should be allowed to delete an access
from the team provided it is not ownership.
"""
identity = factories.IdentityFactory()
user = identity.user
team = factories.TeamFactory(users=[(user, "administrator")])
access = factories.TeamAccessFactory(
team=team, role=random.choice(["member", "administrator"])
)
assert models.TeamAccess.objects.count() == 2
assert models.TeamAccess.objects.filter(user=access.user).exists()
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 204
assert models.TeamAccess.objects.count() == 1
def test_api_team_accesses_delete_owners_except_owners():
"""
Users should be able to delete the team access of another user
for a team of which they are owner provided it is not an owner access.
"""
identity = factories.IdentityFactory()
user = identity.user
team = factories.TeamFactory(users=[(user, "owner")])
access = factories.TeamAccessFactory(
team=team, role=random.choice(["member", "administrator"])
)
assert models.TeamAccess.objects.count() == 2
assert models.TeamAccess.objects.filter(user=access.user).exists()
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 204
assert models.TeamAccess.objects.count() == 1
def test_api_team_accesses_delete_owners_for_owners():
"""
Users should not be allowed to delete the team access of another owner
even for a team in which they are direct owner.
"""
identity = factories.IdentityFactory()
user = identity.user
team = factories.TeamFactory(users=[(user, "owner")])
access = factories.TeamAccessFactory(team=team, role="owner")
assert models.TeamAccess.objects.count() == 2
assert models.TeamAccess.objects.filter(user=access.user).exists()
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 403
assert models.TeamAccess.objects.count() == 2
def test_api_team_accesses_delete_owners_last_owner():
"""
It should not be possible to delete the last owner access from a team
"""
identity = factories.IdentityFactory()
user = identity.user
team = factories.TeamFactory()
access = factories.TeamAccessFactory(team=team, user=user, role="owner")
assert models.TeamAccess.objects.count() == 1
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 403
assert models.TeamAccess.objects.count() == 1
@@ -0,0 +1,285 @@
"""
Test for team accesses API endpoints in People's core app : list
"""
import pytest
from rest_framework.status import HTTP_200_OK
from rest_framework.test import APIClient
from core import factories, models
pytestmark = pytest.mark.django_db
def test_api_team_accesses_list_anonymous():
"""Anonymous users should not be allowed to list team accesses."""
team = factories.TeamFactory()
factories.TeamAccessFactory.create_batch(2, team=team)
response = APIClient().get(f"/api/v1.0/teams/{team.id!s}/accesses/")
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_team_accesses_list_authenticated_unrelated():
"""
Authenticated users should not be allowed to list team accesses for a team
to which they are not related.
"""
identity = factories.IdentityFactory()
user = identity.user
team = factories.TeamFactory()
factories.TeamAccessFactory.create_batch(3, team=team)
# Accesses for other teams to which the user is related should not be listed either
other_access = factories.TeamAccessFactory(user=user)
factories.TeamAccessFactory(team=other_access.team)
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/teams/{team.id!s}/accesses/",
)
assert response.status_code == 200
assert response.json() == {
"count": 0,
"next": None,
"previous": None,
"results": [],
}
def test_api_team_accesses_list_authenticated_related():
"""
Authenticated users should be able to list team accesses for a team
to which they are related, with a given role.
"""
identity = factories.IdentityFactory(is_main=True)
user = identity.user
team = factories.TeamFactory()
owner = factories.IdentityFactory(is_main=True)
access1 = factories.TeamAccessFactory.create(
team=team, user=owner.user, role="owner"
)
administrator = factories.IdentityFactory(is_main=True)
access2 = factories.TeamAccessFactory.create(
team=team, user=administrator.user, role="administrator"
)
# Ensure this user's role is different from other team members to test abilities' computation
user_access = models.TeamAccess.objects.create(team=team, user=user, role="member")
# Grant other team accesses to the user, they should not be listed either
other_access = factories.TeamAccessFactory(user=user)
factories.TeamAccessFactory(team=other_access.team)
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/teams/{team.id!s}/accesses/",
)
assert response.status_code == 200
assert response.json()["count"] == 3
assert sorted(response.json()["results"], key=lambda x: x["id"]) == sorted(
[
{
"id": str(user_access.id),
"user": {
"id": str(user_access.user.id),
"email": str(identity.email),
"name": str(identity.name),
},
"role": str(user_access.role),
"abilities": user_access.get_abilities(user),
},
{
"id": str(access1.id),
"user": {
"id": str(access1.user.id),
"email": str(owner.email),
"name": str(owner.name),
},
"role": str(access1.role),
"abilities": access1.get_abilities(user),
},
{
"id": str(access2.id),
"user": {
"id": str(access2.user.id),
"email": str(administrator.email),
"name": str(administrator.name),
},
"role": str(access2.role),
"abilities": access2.get_abilities(user),
},
],
key=lambda x: x["id"],
)
def test_api_team_accesses_list_authenticated_main_identity():
"""
Name and email should be returned from main identity only
"""
user = factories.UserFactory()
identity = factories.IdentityFactory(user=user, is_main=True)
factories.IdentityFactory(user=user) # additional non-main identity
team = factories.TeamFactory()
models.TeamAccess.objects.create(team=team, user=user) # random role
# other team members should appear, with correct identity
other_user = factories.UserFactory()
other_main_identity = factories.IdentityFactory(is_main=True, user=other_user)
factories.IdentityFactory(user=other_user)
factories.TeamAccessFactory.create(team=team, user=other_user)
# Accesses for other teams to which the user is related should not be listed either
other_access = factories.TeamAccessFactory(user=user)
factories.TeamAccessFactory(team=other_access.team)
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/teams/{team.id!s}/accesses/",
)
assert response.status_code == 200
assert response.json()["count"] == 2
users_info = [
(access["user"]["email"], access["user"]["name"])
for access in response.json()["results"]
]
# user information should be returned from main identity
assert sorted(users_info) == sorted(
[
(str(identity.email), str(identity.name)),
(str(other_main_identity.email), str(other_main_identity.name)),
]
)
def test_api_team_accesses_list_authenticated_constant_numqueries(
django_assert_num_queries,
):
"""
The number of queries should not depend on the amount of fetched accesses.
"""
user = factories.UserFactory()
factories.IdentityFactory(user=user, is_main=True)
team = factories.TeamFactory()
models.TeamAccess.objects.create(team=team, user=user) # random role
client = APIClient()
client.force_login(user)
# Only 4 queries are needed to efficiently fetch team accesses,
# related users and identities :
# - query retrieving logged-in user for user_role annotation
# - count from pagination
# - query prefetching users' main identity
# - distinct from viewset
with django_assert_num_queries(4):
response = client.get(
f"/api/v1.0/teams/{team.id!s}/accesses/",
)
# create 20 new team members
for _ in range(20):
extra_user = factories.IdentityFactory(is_main=True).user
factories.TeamAccessFactory(team=team, user=extra_user)
# num queries should still be 4
with django_assert_num_queries(4):
response = client.get(
f"/api/v1.0/teams/{team.id!s}/accesses/",
)
assert response.status_code == 200
assert response.json()["count"] == 21
def test_api_team_accesses_list_authenticated_ordering():
"""Team accesses can be ordered by "role"."""
user = factories.UserFactory()
factories.IdentityFactory(user=user, is_main=True)
team = factories.TeamFactory()
models.TeamAccess.objects.create(team=team, user=user)
# create 20 new team members
for _ in range(20):
extra_user = factories.IdentityFactory(is_main=True).user
factories.TeamAccessFactory(team=team, user=extra_user)
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/teams/{team.id!s}/accesses/?ordering=role",
)
assert response.status_code == HTTP_200_OK
assert response.json()["count"] == 21
results = [team_access["role"] for team_access in response.json()["results"]]
assert sorted(results) == results
response = client.get(
f"/api/v1.0/teams/{team.id!s}/accesses/?ordering=-role",
)
assert response.status_code == HTTP_200_OK
assert response.json()["count"] == 21
results = [team_access["role"] for team_access in response.json()["results"]]
assert sorted(results, reverse=True) == results
@pytest.mark.parametrize("ordering_fields", ["name", "email"])
def test_api_team_accesses_list_authenticated_ordering_user(ordering_fields):
"""Team accesses can be ordered by user's fields "email" or "name"."""
user = factories.UserFactory()
factories.IdentityFactory(user=user, is_main=True)
team = factories.TeamFactory()
models.TeamAccess.objects.create(team=team, user=user)
# create 20 new team members
for _ in range(20):
extra_user = factories.IdentityFactory(is_main=True).user
factories.TeamAccessFactory(team=team, user=extra_user)
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/teams/{team.id!s}/accesses/?ordering={ordering_fields}",
)
assert response.status_code == HTTP_200_OK
assert response.json()["count"] == 21
results = [
team_access["user"][ordering_fields]
for team_access in response.json()["results"]
]
assert sorted(results) == results
response = client.get(
f"/api/v1.0/teams/{team.id!s}/accesses/?ordering=-{ordering_fields}",
)
assert response.status_code == HTTP_200_OK
assert response.json()["count"] == 21
results = [
team_access["user"][ordering_fields]
for team_access in response.json()["results"]
]
assert sorted(results, reverse=True) == results
@@ -0,0 +1,87 @@
"""
Test for team accesses API endpoints in People's core app : retrieve
"""
import pytest
from rest_framework.test import APIClient
from core import factories
pytestmark = pytest.mark.django_db
def test_api_team_accesses_retrieve_anonymous():
"""
Anonymous users should not be allowed to retrieve a team access.
"""
access = factories.TeamAccessFactory()
response = APIClient().get(
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_team_accesses_retrieve_authenticated_unrelated():
"""
Authenticated users should not be allowed to retrieve a team access for
a team to which they are not related.
"""
identity = factories.IdentityFactory()
user = identity.user
access = factories.TeamAccessFactory(team=factories.TeamFactory())
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 404
assert response.json() == {"detail": "No TeamAccess matches the given query."}
# Accesses related to another team should be excluded even if the user is related to it
for other_access in [
factories.TeamAccessFactory(),
factories.TeamAccessFactory(user=user),
]:
response = client.get(
f"/api/v1.0/teams/{access.team.id!s}/accesses/{other_access.id!s}/",
)
assert response.status_code == 404
assert response.json() == {"detail": "No TeamAccess matches the given query."}
def test_api_team_accesses_retrieve_authenticated_related():
"""
A user who is related to a team should be allowed to retrieve the
associated team user accesses.
"""
identity = factories.IdentityFactory(is_main=True)
user = identity.user
team = factories.TeamFactory()
access = factories.TeamAccessFactory(team=team, user=user)
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 200
assert response.json() == {
"id": str(access.id),
"user": {
"id": str(access.user.id),
"email": str(identity.email),
"name": str(identity.name),
},
"role": str(access.role),
"abilities": access.get_abilities(user),
}
@@ -0,0 +1,341 @@
"""
Test for team accesses API endpoints in People's core app : update
"""
import random
from uuid import uuid4
import pytest
from rest_framework.test import APIClient
from core import factories, models
from core.api import serializers
pytestmark = pytest.mark.django_db
def test_api_team_accesses_update_anonymous():
"""Anonymous users should not be allowed to update a team access."""
access = factories.TeamAccessFactory()
old_values = serializers.TeamAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.choices)[0],
}
for field, value in new_values.items():
response = APIClient().put(
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
{**old_values, field: value},
format="json",
)
assert response.status_code == 401
access.refresh_from_db()
updated_values = serializers.TeamAccessSerializer(instance=access).data
assert updated_values == old_values
def test_api_team_accesses_update_authenticated_unrelated():
"""
Authenticated users should not be allowed to update a team access for a team to which
they are not related.
"""
identity = factories.IdentityFactory()
user = identity.user
access = factories.TeamAccessFactory()
old_values = serializers.TeamAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.choices)[0],
}
client = APIClient()
client.force_login(user)
for field, value in new_values.items():
response = client.put(
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
{**old_values, field: value},
format="json",
)
assert response.status_code == 403
access.refresh_from_db()
updated_values = serializers.TeamAccessSerializer(instance=access).data
assert updated_values == old_values
def test_api_team_accesses_update_authenticated_member():
"""Members of a team should not be allowed to update its accesses."""
identity = factories.IdentityFactory()
user = identity.user
team = factories.TeamFactory(users=[(user, "member")])
access = factories.TeamAccessFactory(team=team)
old_values = serializers.TeamAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.choices)[0],
}
client = APIClient()
client.force_login(user)
for field, value in new_values.items():
response = client.put(
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
{**old_values, field: value},
format="json",
)
assert response.status_code == 403
access.refresh_from_db()
updated_values = serializers.TeamAccessSerializer(instance=access).data
assert updated_values == old_values
def test_api_team_accesses_update_administrator_except_owner():
"""
A user who is an administrator in a team should be allowed to update a user
access for this team, as long as they don't try to set the role to owner.
"""
identity = factories.IdentityFactory()
user = identity.user
team = factories.TeamFactory(users=[(user, "administrator")])
access = factories.TeamAccessFactory(
team=team,
role=random.choice(["administrator", "member"]),
)
old_values = serializers.TeamAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": random.choice(["administrator", "member"]),
}
client = APIClient()
client.force_login(user)
for field, value in new_values.items():
new_data = {**old_values, field: value}
response = client.put(
f"/api/v1.0/teams/{team.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
assert response.status_code == 403
else:
assert response.status_code == 200
access.refresh_from_db()
updated_values = serializers.TeamAccessSerializer(instance=access).data
if field == "role":
assert updated_values == {**old_values, "role": new_values["role"]}
else:
assert updated_values == old_values
def test_api_team_accesses_update_administrator_from_owner():
"""
A user who is an administrator in a team, should not be allowed to update
the user access of an "owner" for this team.
"""
identity = factories.IdentityFactory()
user = identity.user
team = factories.TeamFactory(users=[(user, "administrator")])
other_user = factories.UserFactory()
access = factories.TeamAccessFactory(team=team, user=other_user, role="owner")
old_values = serializers.TeamAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.choices)[0],
}
client = APIClient()
client.force_login(user)
for field, value in new_values.items():
response = client.put(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
data={**old_values, field: value},
format="json",
)
assert response.status_code == 403
access.refresh_from_db()
updated_values = serializers.TeamAccessSerializer(instance=access).data
assert updated_values == old_values
def test_api_team_accesses_update_administrator_to_owner():
"""
A user who is an administrator in a team, should not be allowed to update
the user access of another user to grant team ownership.
"""
identity = factories.IdentityFactory()
user = identity.user
team = factories.TeamFactory(users=[(user, "administrator")])
other_user = factories.UserFactory()
access = factories.TeamAccessFactory(
team=team,
user=other_user,
role=random.choice(["administrator", "member"]),
)
old_values = serializers.TeamAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": "owner",
}
client = APIClient()
client.force_login(user)
for field, value in new_values.items():
new_data = {**old_values, field: value}
response = client.put(
f"/api/v1.0/teams/{team.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"]:
assert response.status_code == 403
else:
assert response.status_code == 200
access.refresh_from_db()
updated_values = serializers.TeamAccessSerializer(instance=access).data
assert updated_values == old_values
def test_api_team_accesses_update_owner_except_owner():
"""
A user who is an owner in a team should be allowed to update
a user access for this team except for existing "owner" accesses.
"""
identity = factories.IdentityFactory()
user = identity.user
team = factories.TeamFactory(users=[(user, "owner")])
factories.UserFactory()
access = factories.TeamAccessFactory(
team=team,
role=random.choice(["administrator", "member"]),
)
old_values = serializers.TeamAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.choices)[0],
}
client = APIClient()
client.force_login(user)
for field, value in new_values.items():
new_data = {**old_values, field: value}
response = client.put(
f"/api/v1.0/teams/{team.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
assert response.status_code == 403
else:
assert response.status_code == 200
access.refresh_from_db()
updated_values = serializers.TeamAccessSerializer(instance=access).data
if field == "role":
assert updated_values == {**old_values, "role": new_values["role"]}
else:
assert updated_values == old_values
def test_api_team_accesses_update_owner_for_owners():
"""
A user who is "owner" of a team should not be allowed to update
an existing owner access for this team.
"""
identity = factories.IdentityFactory()
user = identity.user
team = factories.TeamFactory(users=[(user, "owner")])
access = factories.TeamAccessFactory(team=team, role="owner")
old_values = serializers.TeamAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.choices)[0],
}
client = APIClient()
client.force_login(user)
for field, value in new_values.items():
response = client.put(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
data={**old_values, field: value},
content_type="application/json",
)
assert response.status_code == 403
access.refresh_from_db()
updated_values = serializers.TeamAccessSerializer(instance=access).data
assert updated_values == old_values
def test_api_team_accesses_update_owner_self():
"""
A user who is owner of a team should be allowed to update
their own user access provided there are other owners in the team.
"""
identity = factories.IdentityFactory()
user = identity.user
team = factories.TeamFactory()
access = factories.TeamAccessFactory(team=team, user=user, role="owner")
old_values = serializers.TeamAccessSerializer(instance=access).data
new_role = random.choice(["administrator", "member"])
client = APIClient()
client.force_login(user)
response = client.put(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
data={**old_values, "role": new_role},
format="json",
)
assert response.status_code == 403
access.refresh_from_db()
assert access.role == "owner"
# Add another owner and it should now work
factories.TeamAccessFactory(team=team, role="owner")
response = client.put(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
data={**old_values, "role": new_role},
format="json",
)
assert response.status_code == 200
access.refresh_from_db()
assert access.role == new_role
@@ -1,6 +1,7 @@
"""
Tests for Teams API endpoint in People's core app: create
"""
import pytest
from rest_framework.status import (
HTTP_201_CREATED,
@@ -1,6 +1,7 @@
"""
Tests for Teams API endpoint in People's core app: delete
"""
import pytest
from rest_framework.status import (
HTTP_204_NO_CONTENT,
@@ -44,7 +45,7 @@ def test_api_teams_delete_authenticated_unrelated():
)
assert response.status_code == HTTP_404_NOT_FOUND
assert response.json() == {"detail": "Not found."}
assert response.json() == {"detail": "No Team matches the given query."}
assert models.Team.objects.count() == 1
@@ -1,6 +1,7 @@
"""
Tests for Teams API endpoint in People's core app: list
"""
from unittest import mock
import pytest
@@ -26,7 +27,10 @@ def test_api_teams_list_anonymous():
def test_api_teams_list_authenticated():
"""Authenticated users should be able to list teams they are an owner/administrator/member of."""
"""
Authenticated users should be able to list teams
they are an owner/administrator/member of.
"""
identity = factories.IdentityFactory()
user = identity.user
@@ -119,3 +123,61 @@ def test_api_teams_list_authenticated_distinct():
content = response.json()
assert len(content["results"]) == 1
assert content["results"][0]["id"] == str(team.id)
def test_api_teams_order():
"""
Test that the endpoint GET teams is sorted in 'created_at' descending order by default.
"""
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
team_ids = [
str(team.id) for team in factories.TeamFactory.create_batch(5, users=[user])
]
response = client.get(
"/api/v1.0/teams/",
)
assert response.status_code == 200
response_data = response.json()
response_team_ids = [team["id"] for team in response_data["results"]]
team_ids.reverse()
assert (
response_team_ids == team_ids
), "created_at values are not sorted from newest to oldest"
def test_api_teams_order_param():
"""
Test that the 'created_at' field is sorted in ascending order
when the 'ordering' query parameter is set.
"""
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
team_ids = [
str(team.id) for team in factories.TeamFactory.create_batch(5, users=[user])
]
response = client.get(
"/api/v1.0/teams/?ordering=created_at",
)
assert response.status_code == 200
response_data = response.json()
response_team_ids = [team["id"] for team in response_data["results"]]
assert (
response_team_ids == team_ids
), "created_at values are not sorted from oldest to newest"
@@ -1,6 +1,7 @@
"""
Tests for Teams API endpoint in People's core app: retrieve
"""
import pytest
from rest_framework.status import HTTP_200_OK, HTTP_401_UNAUTHORIZED, HTTP_404_NOT_FOUND
from rest_framework.test import APIClient
@@ -37,7 +38,7 @@ def test_api_teams_retrieve_authenticated_unrelated():
f"/api/v1.0/teams/{team.id!s}/",
)
assert response.status_code == HTTP_404_NOT_FOUND
assert response.json() == {"detail": "Not found."}
assert response.json() == {"detail": "No Team matches the given query."}
def test_api_teams_retrieve_authenticated_related():
@@ -58,28 +59,19 @@ def test_api_teams_retrieve_authenticated_related():
response = client.get(
f"/api/v1.0/teams/{team.id!s}/",
)
assert response.status_code == HTTP_200_OK
content = response.json()
assert sorted(content.pop("accesses"), key=lambda x: x["user"]) == sorted(
assert sorted(response.json().pop("accesses")) == sorted(
[
{
"id": str(access1.id),
"user": str(user.id),
"role": access1.role,
"abilities": access1.get_abilities(user),
},
{
"id": str(access2.id),
"user": str(access2.user.id),
"role": access2.role,
"abilities": access2.get_abilities(user),
},
],
key=lambda x: x["user"],
str(access1.id),
str(access2.id),
]
)
assert response.json() == {
"id": str(team.id),
"name": team.name,
"slug": team.slug,
"abilities": team.get_abilities(user),
"created_at": team.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": team.updated_at.isoformat().replace("+00:00", "Z"),
}
@@ -1,6 +1,7 @@
"""
Tests for Teams API endpoint in People's core app: update
"""
import random
import pytest
@@ -60,7 +61,7 @@ def test_api_teams_update_authenticated_unrelated():
)
assert response.status_code == HTTP_404_NOT_FOUND
assert response.json() == {"detail": "Not found."}
assert response.json() == {"detail": "No Team matches the given query."}
team.refresh_from_db()
team_values = serializers.TeamSerializer(instance=team).data
@@ -121,8 +122,10 @@ def test_api_teams_update_authenticated_administrators():
team.refresh_from_db()
final_values = serializers.TeamSerializer(instance=team).data
for key, value in final_values.items():
if key in ["id", "accesses"]: # pylint: disable=R1733
if key in ["id", "accesses", "created_at"]:
assert value == initial_values[key]
elif key == "updated_at":
assert value > initial_values[key]
else:
# name, slug and abilities successfully modified
assert value == new_values[key]
@@ -153,8 +156,10 @@ def test_api_teams_update_authenticated_owners():
team.refresh_from_db()
team_values = serializers.TeamSerializer(instance=team).data
for key, value in team_values.items():
if key in ["id", "accesses"]:
if key in ["id", "accesses", "created_at"]:
assert value == old_team_values[key]
elif key == "updated_at":
assert value > old_team_values[key]
else:
# name, slug and abilities successfully modified
assert value == new_team_values[key]
@@ -183,7 +188,7 @@ def test_api_teams_update_administrator_or_owner_of_another():
)
assert response.status_code == HTTP_404_NOT_FOUND
assert response.json() == {"detail": "Not found."}
assert response.json() == {"detail": "No Team matches the given query."}
team.refresh_from_db()
team_values = serializers.TeamSerializer(instance=team).data
@@ -1,6 +1,7 @@
"""
Test contacts API endpoints in People's core app.
"""
from django.test.utils import override_settings
import pytest
@@ -1,846 +0,0 @@
"""
Test team accesses API endpoints for users in People's core app.
"""
import random
from uuid import uuid4
import pytest
from rest_framework.test import APIClient
from core import factories, models
from core.api import serializers
pytestmark = pytest.mark.django_db
def test_api_team_accesses_list_anonymous():
"""Anonymous users should not be allowed to list team accesses."""
team = factories.TeamFactory()
factories.TeamAccessFactory.create_batch(2, team=team)
response = APIClient().get(f"/api/v1.0/teams/{team.id!s}/accesses/")
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_team_accesses_list_authenticated_unrelated():
"""
Authenticated users should not be allowed to list team accesses for a team
to which they are not related.
"""
identity = factories.IdentityFactory()
user = identity.user
team = factories.TeamFactory()
factories.TeamAccessFactory.create_batch(3, team=team)
client = APIClient()
client.force_login(user)
# Accesses for other teams to which the user is related should not be listed either
other_access = factories.TeamAccessFactory(user=user)
factories.TeamAccessFactory(team=other_access.team)
response = client.get(
f"/api/v1.0/teams/{team.id!s}/accesses/",
)
assert response.status_code == 200
assert response.json() == {
"count": 0,
"next": None,
"previous": None,
"results": [],
}
def test_api_team_accesses_list_authenticated_related():
"""
Authenticated users should be able to list team accesses for a team
to which they are related, whatever their role in the team.
"""
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
team = factories.TeamFactory()
user_access = models.TeamAccess.objects.create(team=team, user=user) # random role
access1, access2 = factories.TeamAccessFactory.create_batch(2, team=team)
# Accesses for other teams to which the user is related should not be listed either
other_access = factories.TeamAccessFactory(user=user)
factories.TeamAccessFactory(team=other_access.team)
response = client.get(
f"/api/v1.0/teams/{team.id!s}/accesses/",
)
assert response.status_code == 200
content = response.json()
assert len(content["results"]) == 3
assert sorted(content["results"], key=lambda x: x["id"]) == sorted(
[
{
"id": str(user_access.id),
"user": str(user.id),
"role": user_access.role,
"abilities": user_access.get_abilities(user),
},
{
"id": str(access1.id),
"user": str(access1.user.id),
"role": access1.role,
"abilities": access1.get_abilities(user),
},
{
"id": str(access2.id),
"user": str(access2.user.id),
"role": access2.role,
"abilities": access2.get_abilities(user),
},
],
key=lambda x: x["id"],
)
def test_api_team_accesses_retrieve_anonymous():
"""
Anonymous users should not be allowed to retrieve a team access.
"""
access = factories.TeamAccessFactory()
response = APIClient().get(
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_team_accesses_retrieve_authenticated_unrelated():
"""
Authenticated users should not be allowed to retrieve a team access for
a team to which they are not related.
"""
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
team = factories.TeamFactory()
access = factories.TeamAccessFactory(team=team)
response = client.get(f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/")
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
# Accesses related to another team should be excluded even if the user is related to it
for access in [
factories.TeamAccessFactory(),
factories.TeamAccessFactory(user=user),
]:
response = client.get(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
def test_api_team_accesses_retrieve_authenticated_related():
"""
A user who is related to a team should be allowed to retrieve the
associated team user accesses.
"""
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
team = factories.TeamFactory(users=[user])
access = factories.TeamAccessFactory(team=team)
response = client.get(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 200
assert response.json() == {
"id": str(access.id),
"user": str(access.user.id),
"role": access.role,
"abilities": access.get_abilities(user),
}
def test_api_team_accesses_create_anonymous():
"""Anonymous users should not be allowed to create team accesses."""
user = factories.UserFactory()
team = factories.TeamFactory()
response = APIClient().post(
f"/api/v1.0/teams/{team.id!s}/accesses/",
{
"user": str(user.id),
"team": str(team.id),
"role": random.choice(models.RoleChoices.choices)[0],
},
format="json",
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
assert models.TeamAccess.objects.exists() is False
def test_api_team_accesses_create_authenticated_unrelated():
"""
Authenticated users should not be allowed to create team accesses for a team to
which they are not related.
"""
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
other_user = factories.UserFactory()
team = factories.TeamFactory()
response = client.post(
f"/api/v1.0/teams/{team.id!s}/accesses/",
{
"user": str(other_user.id),
},
format="json",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You are not allowed to manage accesses for this team."
}
assert not models.TeamAccess.objects.filter(user=other_user).exists()
def test_api_team_accesses_create_authenticated_member():
"""Members of a team should not be allowed to create team accesses."""
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
team = factories.TeamFactory(users=[(user, "member")])
other_user = factories.UserFactory()
for role in [role[0] for role in models.RoleChoices.choices]:
response = client.post(
f"/api/v1.0/teams/{team.id!s}/accesses/",
{
"user": str(other_user.id),
"role": role,
},
format="json",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You are not allowed to manage accesses for this team."
}
assert not models.TeamAccess.objects.filter(user=other_user).exists()
def test_api_team_accesses_create_authenticated_administrator():
"""
Administrators of a team should be able to create team accesses except for the "owner" role.
"""
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
team = factories.TeamFactory(users=[(user, "administrator")])
other_user = factories.UserFactory()
# It should not be allowed to create an owner access
response = client.post(
f"/api/v1.0/teams/{team.id!s}/accesses/",
{
"user": str(other_user.id),
"role": "owner",
},
format="json",
)
assert response.status_code == 403
assert response.json() == {
"detail": "Only owners of a team can assign other users as owners."
}
# It should be allowed to create a lower access
role = random.choice(
[role[0] for role in models.RoleChoices.choices if role[0] != "owner"]
)
response = client.post(
f"/api/v1.0/teams/{team.id!s}/accesses/",
{
"user": str(other_user.id),
"role": role,
},
format="json",
)
assert response.status_code == 201
assert models.TeamAccess.objects.filter(user=other_user).count() == 1
new_team_access = models.TeamAccess.objects.filter(user=other_user).get()
assert response.json() == {
"abilities": new_team_access.get_abilities(user),
"id": str(new_team_access.id),
"role": role,
"user": str(other_user.id),
}
def test_api_team_accesses_create_authenticated_owner():
"""
Owners of a team should be able to create team accesses whatever the role.
"""
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
team = factories.TeamFactory(users=[(user, "owner")])
other_user = factories.UserFactory()
role = random.choice([role[0] for role in models.RoleChoices.choices])
response = client.post(
f"/api/v1.0/teams/{team.id!s}/accesses/",
{
"user": str(other_user.id),
"role": role,
},
format="json",
)
assert response.status_code == 201
assert models.TeamAccess.objects.filter(user=other_user).count() == 1
new_team_access = models.TeamAccess.objects.filter(user=other_user).get()
assert response.json() == {
"abilities": new_team_access.get_abilities(user),
"id": str(new_team_access.id),
"role": role,
"user": str(other_user.id),
}
def test_api_team_accesses_update_anonymous():
"""Anonymous users should not be allowed to update a team access."""
access = factories.TeamAccessFactory()
old_values = serializers.TeamAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.choices)[0],
}
api_client = APIClient()
for field, value in new_values.items():
response = api_client.put(
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
{**old_values, field: value},
format="json",
)
assert response.status_code == 401
access.refresh_from_db()
updated_values = serializers.TeamAccessSerializer(instance=access).data
assert updated_values == old_values
def test_api_team_accesses_update_authenticated_unrelated():
"""
Authenticated users should not be allowed to update a team access for a team to which
they are not related.
"""
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
access = factories.TeamAccessFactory()
old_values = serializers.TeamAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.choices)[0],
}
for field, value in new_values.items():
response = client.put(
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
{**old_values, field: value},
format="json",
)
assert response.status_code == 403
access.refresh_from_db()
updated_values = serializers.TeamAccessSerializer(instance=access).data
assert updated_values == old_values
def test_api_team_accesses_update_authenticated_member():
"""Members of a team should not be allowed to update its accesses."""
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
team = factories.TeamFactory(users=[(user, "member")])
access = factories.TeamAccessFactory(team=team)
old_values = serializers.TeamAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.choices)[0],
}
for field, value in new_values.items():
response = client.put(
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
{**old_values, field: value},
format="json",
)
assert response.status_code == 403
access.refresh_from_db()
updated_values = serializers.TeamAccessSerializer(instance=access).data
assert updated_values == old_values
def test_api_team_accesses_update_administrator_except_owner():
"""
A user who is an administrator in a team should be allowed to update a user
access for this team, as long as they don't try to set the role to owner.
"""
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
team = factories.TeamFactory(users=[(user, "administrator")])
access = factories.TeamAccessFactory(
team=team,
role=random.choice(["administrator", "member"]),
)
old_values = serializers.TeamAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": random.choice(["administrator", "member"]),
}
for field, value in new_values.items():
new_data = {**old_values, field: value}
response = client.put(
f"/api/v1.0/teams/{team.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
assert response.status_code == 403
else:
assert response.status_code == 200
access.refresh_from_db()
updated_values = serializers.TeamAccessSerializer(instance=access).data
if field == "role":
assert updated_values == {**old_values, "role": new_values["role"]}
else:
assert updated_values == old_values
def test_api_team_accesses_update_administrator_from_owner():
"""
A user who is an administrator in a team, should not be allowed to update
the user access of an "owner" for this team.
"""
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
team = factories.TeamFactory(users=[(user, "administrator")])
other_user = factories.UserFactory()
access = factories.TeamAccessFactory(team=team, user=other_user, role="owner")
old_values = serializers.TeamAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.choices)[0],
}
for field, value in new_values.items():
response = client.put(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
data={**old_values, field: value},
format="json",
)
assert response.status_code == 403
access.refresh_from_db()
updated_values = serializers.TeamAccessSerializer(instance=access).data
assert updated_values == old_values
def test_api_team_accesses_update_administrator_to_owner():
"""
A user who is an administrator in a team, should not be allowed to update
the user access of another user to grant team ownership.
"""
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
team = factories.TeamFactory(users=[(user, "administrator")])
other_user = factories.UserFactory()
access = factories.TeamAccessFactory(
team=team,
user=other_user,
role=random.choice(["administrator", "member"]),
)
old_values = serializers.TeamAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": "owner",
}
for field, value in new_values.items():
new_data = {**old_values, field: value}
response = client.put(
f"/api/v1.0/teams/{team.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"]:
assert response.status_code == 403
else:
assert response.status_code == 200
access.refresh_from_db()
updated_values = serializers.TeamAccessSerializer(instance=access).data
assert updated_values == old_values
def test_api_team_accesses_update_owner_except_owner():
"""
A user who is an owner in a team should be allowed to update
a user access for this team except for existing "owner" accesses.
"""
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
team = factories.TeamFactory(users=[(user, "owner")])
factories.UserFactory()
access = factories.TeamAccessFactory(
team=team,
role=random.choice(["administrator", "member"]),
)
old_values = serializers.TeamAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.choices)[0],
}
for field, value in new_values.items():
new_data = {**old_values, field: value}
response = client.put(
f"/api/v1.0/teams/{team.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
assert response.status_code == 403
else:
assert response.status_code == 200
access.refresh_from_db()
updated_values = serializers.TeamAccessSerializer(instance=access).data
if field == "role":
assert updated_values == {**old_values, "role": new_values["role"]}
else:
assert updated_values == old_values
def test_api_team_accesses_update_owner_for_owners():
"""
A user who is "owner" of a team should not be allowed to update
an existing owner access for this team.
"""
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
team = factories.TeamFactory(users=[(user, "owner")])
access = factories.TeamAccessFactory(team=team, role="owner")
old_values = serializers.TeamAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.choices)[0],
}
for field, value in new_values.items():
response = client.put(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
data={**old_values, field: value},
content_type="application/json",
)
assert response.status_code == 403
access.refresh_from_db()
updated_values = serializers.TeamAccessSerializer(instance=access).data
assert updated_values == old_values
def test_api_team_accesses_update_owner_self():
"""
A user who is owner of a team should be allowed to update
their own user access provided there are other owners in the team.
"""
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
team = factories.TeamFactory()
access = factories.TeamAccessFactory(team=team, user=user, role="owner")
old_values = serializers.TeamAccessSerializer(instance=access).data
new_role = random.choice(["administrator", "member"])
response = client.put(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
data={**old_values, "role": new_role},
format="json",
)
assert response.status_code == 403
access.refresh_from_db()
assert access.role == "owner"
# Add another owner and it should now work
factories.TeamAccessFactory(team=team, role="owner")
response = client.put(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
data={**old_values, "role": new_role},
format="json",
)
assert response.status_code == 200
access.refresh_from_db()
assert access.role == new_role
# Delete
def test_api_team_accesses_delete_anonymous():
"""Anonymous users should not be allowed to destroy a team access."""
access = factories.TeamAccessFactory()
response = APIClient().delete(
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 401
assert models.TeamAccess.objects.count() == 1
def test_api_team_accesses_delete_authenticated():
"""
Authenticated users should not be allowed to delete a team access for a
team to which they are not related.
"""
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
access = factories.TeamAccessFactory()
response = client.delete(
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 403
assert models.TeamAccess.objects.count() == 1
def test_api_team_accesses_delete_member():
"""
Authenticated users should not be allowed to delete a team access for a
team in which they are a simple member.
"""
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
team = factories.TeamFactory(users=[(user, "member")])
access = factories.TeamAccessFactory(team=team)
assert models.TeamAccess.objects.count() == 2
assert models.TeamAccess.objects.filter(user=access.user).exists()
response = client.delete(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 403
assert models.TeamAccess.objects.count() == 2
def test_api_team_accesses_delete_administrators():
"""
Users who are administrators in a team should be allowed to delete an access
from the team provided it is not ownership.
"""
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
team = factories.TeamFactory(users=[(user, "administrator")])
access = factories.TeamAccessFactory(
team=team, role=random.choice(["member", "administrator"])
)
assert models.TeamAccess.objects.count() == 2
assert models.TeamAccess.objects.filter(user=access.user).exists()
response = client.delete(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 204
assert models.TeamAccess.objects.count() == 1
def test_api_team_accesses_delete_owners_except_owners():
"""
Users should be able to delete the team access of another user
for a team of which they are owner provided it is not an owner access.
"""
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
team = factories.TeamFactory(users=[(user, "owner")])
access = factories.TeamAccessFactory(
team=team, role=random.choice(["member", "administrator"])
)
assert models.TeamAccess.objects.count() == 2
assert models.TeamAccess.objects.filter(user=access.user).exists()
response = client.delete(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 204
assert models.TeamAccess.objects.count() == 1
def test_api_team_accesses_delete_owners_for_owners():
"""
Users should not be allowed to delete the team access of another owner
even for a team in which they are direct owner.
"""
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
team = factories.TeamFactory(users=[(user, "owner")])
access = factories.TeamAccessFactory(team=team, role="owner")
assert models.TeamAccess.objects.count() == 2
assert models.TeamAccess.objects.filter(user=access.user).exists()
response = client.delete(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 403
assert models.TeamAccess.objects.count() == 2
def test_api_team_accesses_delete_owners_last_owner():
"""
It should not be possible to delete the last owner access from a team
"""
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
team = factories.TeamFactory()
access = factories.TeamAccessFactory(team=team, user=user, role="owner")
assert models.TeamAccess.objects.count() == 1
response = client.delete(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 403
assert models.TeamAccess.objects.count() == 1
@@ -0,0 +1,421 @@
"""
Unit tests for the Invitation model
"""
import time
import pytest
from rest_framework import status
from rest_framework.test import APIClient
from core import factories
from core.api import serializers
pytestmark = pytest.mark.django_db
def test_api_team_invitations__create__anonymous():
"""Anonymous users should not be able to create invitations."""
team = factories.TeamFactory()
invitation_values = serializers.InvitationSerializer(
factories.InvitationFactory.build()
).data
response = APIClient().post(
f"/api/v1.0/teams/{team.id}/invitations/",
invitation_values,
format="json",
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_team_invitations__create__authenticated_outsider():
"""Users outside of team should not be permitted to invite to team."""
identity = factories.IdentityFactory()
team = factories.TeamFactory()
invitation_values = serializers.InvitationSerializer(
factories.InvitationFactory.build()
).data
client = APIClient()
client.force_login(identity.user)
response = client.post(
f"/api/v1.0/teams/{team.id}/invitations/",
invitation_values,
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
@pytest.mark.parametrize(
"role",
["owner", "administrator"],
)
def test_api_team_invitations__create__privileged_members(role):
"""Owners and administrators should be able to invite new members."""
identity = factories.IdentityFactory()
team = factories.TeamFactory(users=[(identity.user, role)])
invitation_values = serializers.InvitationSerializer(
factories.InvitationFactory.build()
).data
client = APIClient()
client.force_login(identity.user)
response = client.post(
f"/api/v1.0/teams/{team.id}/invitations/",
invitation_values,
format="json",
)
assert response.status_code == status.HTTP_201_CREATED
def test_api_team_invitations__create__members():
"""
Members should not be able to invite new members.
"""
identity = factories.IdentityFactory()
team = factories.TeamFactory(users=[(identity.user, "member")])
invitation_values = serializers.InvitationSerializer(
factories.InvitationFactory.build()
).data
client = APIClient()
client.force_login(identity.user)
response = client.post(
f"/api/v1.0/teams/{team.id}/invitations/",
invitation_values,
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.json() == {
"detail": "You are not allowed to manage invitation for this team."
}
def test_api_team_invitations__create__issuer_and_team_automatically_added():
"""Team and issuer fields should auto-complete."""
identity = factories.IdentityFactory()
team = factories.TeamFactory(users=[(identity.user, "owner")])
# Generate a random invitation
invitation = factories.InvitationFactory.build()
invitation_data = {"email": invitation.email, "role": invitation.role}
client = APIClient()
client.force_login(identity.user)
response = client.post(
f"/api/v1.0/teams/{team.id}/invitations/",
invitation_data,
format="json",
)
assert response.status_code == status.HTTP_201_CREATED
# team and issuer automatically set
assert response.json()["team"] == str(team.id)
assert response.json()["issuer"] == str(identity.user.id)
def test_api_team_invitations__create__cannot_duplicate_invitation():
"""An email should not be invited multiple times to the same team."""
existing_invitation = factories.InvitationFactory()
team = existing_invitation.team
# Grant privileged role on the Team to the user
identity = factories.IdentityFactory()
factories.TeamAccessFactory(team=team, user=identity.user, role="administrator")
# Create a new invitation to the same team with the exact same email address
duplicated_invitation = serializers.InvitationSerializer(
factories.InvitationFactory.build(email=existing_invitation.email)
).data
client = APIClient()
client.force_login(identity.user)
response = client.post(
f"/api/v1.0/teams/{team.id}/invitations/",
duplicated_invitation,
format="json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json()["__all__"] == [
"Team invitation with this Email address and Team already exists."
]
def test_api_team_invitations__create__cannot_invite_existing_users():
"""
Should not be able to invite already existing users.
"""
user = factories.UserFactory()
team = factories.TeamFactory(users=[(user, "administrator")])
existing_user = factories.IdentityFactory(is_main=True)
# Build an invitation to the email of an exising identity in the db
invitation_values = serializers.InvitationSerializer(
factories.InvitationFactory.build(email=existing_user.email, team=team)
).data
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/teams/{team.id}/invitations/",
invitation_values,
format="json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json()["email"] == [
"This email is already associated to a registered user."
]
def test_api_team_invitations__list__anonymous_user():
"""Anonymous users should not be able to list invitations."""
team = factories.TeamFactory()
response = APIClient().get(f"/api/v1.0/teams/{team.id}/invitations/")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_api_team_invitations__list__authenticated():
"""
Authenticated user should be able to list invitations
in teams they belong to, including from other issuers.
"""
identity = factories.IdentityFactory()
other_user = factories.UserFactory()
team = factories.TeamFactory(
users=[(identity.user, "administrator"), (other_user, "owner")]
)
invitation = factories.InvitationFactory(
team=team, role="administrator", issuer=identity.user
)
other_invitations = factories.InvitationFactory.create_batch(
2, team=team, role="member", issuer=other_user
)
# invitations from other teams should not be listed
other_team = factories.TeamFactory()
factories.InvitationFactory.create_batch(2, team=other_team, role="member")
client = APIClient()
client.force_login(identity.user)
response = client.get(
f"/api/v1.0/teams/{team.id}/invitations/",
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["count"] == 3
assert sorted(response.json()["results"], key=lambda x: x["created_at"]) == sorted(
[
{
"id": str(i.id),
"created_at": i.created_at.isoformat().replace("+00:00", "Z"),
"email": str(i.email),
"team": str(team.id),
"role": i.role,
"issuer": str(i.issuer.id),
"is_expired": False,
}
for i in [invitation, *other_invitations]
],
key=lambda x: x["created_at"],
)
def test_api_team_invitations__list__expired_invitations_still_listed(settings):
"""
Expired invitations are still listed.
"""
identity = factories.IdentityFactory()
other_user = factories.UserFactory()
team = factories.TeamFactory(
users=[(identity.user, "administrator"), (other_user, "owner")]
)
# override settings to accelerate validation expiration
settings.INVITATION_VALIDITY_DURATION = 1 # second
expired_invitation = factories.InvitationFactory(
team=team,
role="member",
issuer=identity.user,
)
time.sleep(1)
client = APIClient()
client.force_login(identity.user)
response = client.get(
f"/api/v1.0/teams/{team.id}/invitations/",
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["count"] == 1
assert sorted(response.json()["results"], key=lambda x: x["created_at"]) == sorted(
[
{
"id": str(expired_invitation.id),
"created_at": expired_invitation.created_at.isoformat().replace(
"+00:00", "Z"
),
"email": str(expired_invitation.email),
"team": str(team.id),
"role": expired_invitation.role,
"issuer": str(expired_invitation.issuer.id),
"is_expired": True,
},
],
key=lambda x: x["created_at"],
)
def test_api_team_invitations__retrieve__anonymous_user():
"""
Anonymous user should not be able to retrieve invitations.
"""
invitation = factories.InvitationFactory()
response = APIClient().get(
f"/api/v1.0/teams/{invitation.team.id}/invitations/{invitation.id}/",
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_api_team_invitations__retrieve__unrelated_user():
"""
Authenticated unrelated users should not be able to retrieve invitations.
"""
user = factories.IdentityFactory(user=factories.UserFactory()).user
invitation = factories.InvitationFactory()
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/teams/{invitation.team.id}/invitations/{invitation.id}/",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_api_team_invitations__retrieve__team_member():
"""
Authenticated team members should be able to retrieve invitations
whatever their role in the team.
"""
user = factories.IdentityFactory(user=factories.UserFactory()).user
invitation = factories.InvitationFactory()
factories.TeamAccessFactory(team=invitation.team, user=user, role="member")
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/teams/{invitation.team.id}/invitations/{invitation.id}/",
)
assert response.status_code == status.HTTP_200_OK
assert response.json() == {
"id": str(invitation.id),
"created_at": invitation.created_at.isoformat().replace("+00:00", "Z"),
"email": invitation.email,
"team": str(invitation.team.id),
"role": str(invitation.role),
"issuer": str(invitation.issuer.id),
"is_expired": False,
}
@pytest.mark.parametrize(
"method",
["put", "patch"],
)
def test_api_team_invitations__update__forbidden(method):
"""
Update of invitations is currently forbidden.
"""
user = factories.IdentityFactory(user=factories.UserFactory()).user
invitation = factories.InvitationFactory()
factories.TeamAccessFactory(team=invitation.team, user=user, role="owner")
client = APIClient()
client.force_login(user)
if method == "put":
response = client.put(
f"/api/v1.0/teams/{invitation.team.id}/invitations/{invitation.id}/",
)
if method == "patch":
response = client.patch(
f"/api/v1.0/teams/{invitation.team.id}/invitations/{invitation.id}/",
)
assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED
assert response.json()["detail"] == f'Method "{method.upper()}" not allowed.'
def test_api_team_invitations__delete__anonymous():
"""Anonymous user should not be able to delete invitations."""
invitation = factories.InvitationFactory()
response = APIClient().delete(
f"/api/v1.0/teams/{invitation.team.id}/invitations/{invitation.id}/",
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_api_team_invitations__delete__authenticated_outsider():
"""Members outside of team should not cancel invitations."""
identity = factories.IdentityFactory()
team = factories.TeamFactory()
invitation = factories.InvitationFactory(team=team)
client = APIClient()
client.force_login(identity.user)
response = client.delete(
f"/api/v1.0/teams/{team.id}/invitations/{invitation.id}/",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
@pytest.mark.parametrize("role", ["owner", "administrator"])
def test_api_team_invitations__delete__privileged_members(role):
"""Privileged member should be able to cancel invitation."""
identity = factories.IdentityFactory()
team = factories.TeamFactory(users=[(identity.user, role)])
invitation = factories.InvitationFactory(team=team)
client = APIClient()
client.force_login(identity.user)
response = client.delete(
f"/api/v1.0/teams/{team.id}/invitations/{invitation.id}/",
)
assert response.status_code == status.HTTP_204_NO_CONTENT
def test_api_team_invitations__delete__members():
"""Member should not be able to cancel invitation."""
identity = factories.IdentityFactory()
team = factories.TeamFactory(users=[(identity.user, "member")])
invitation = factories.InvitationFactory(team=team)
client = APIClient()
client.force_login(identity.user)
response = client.delete(
f"/api/v1.0/teams/{team.id}/invitations/{invitation.id}/",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert (
response.json()["detail"]
== "You do not have permission to perform this action."
)
+298 -26
View File
@@ -1,6 +1,7 @@
"""
Test users API endpoints in the People core app.
"""
from unittest import mock
import pytest
@@ -53,15 +54,21 @@ def test_api_users_authenticated_list_by_email():
partial query on the email.
"""
user = factories.UserFactory(email="tester@ministry.fr")
factories.IdentityFactory(user=user, email=user.email)
factories.IdentityFactory(user=user, email=user.email, name="john doe")
client = APIClient()
client.force_login(user)
dave = factories.IdentityFactory(email="david.bowman@work.com")
nicole = factories.IdentityFactory(email="nicole_foole@work.com")
frank = factories.IdentityFactory(email="frank_poole@work.com")
factories.IdentityFactory(email="heywood_floyd@work.com")
dave = factories.IdentityFactory(
email="david.bowman@work.com", name=None, is_main=True
)
nicole = factories.IdentityFactory(
email="nicole_foole@work.com", name=None, is_main=True
)
frank = factories.IdentityFactory(
email="frank_poole@work.com", name=None, is_main=True
)
factories.IdentityFactory(email="heywood_floyd@work.com", name=None, is_main=True)
# Full query should work
response = client.get(
@@ -90,24 +97,200 @@ def test_api_users_authenticated_list_by_email():
# Even with a low similarity threshold, query should match expected emails
response = client.get("/api/v1.0/users/?q=ool")
assert response.status_code == HTTP_200_OK
assert response.json()["results"] == [
{
"id": str(nicole.user.id),
"email": nicole.email,
"name": nicole.name,
"is_device": nicole.user.is_device,
"is_staff": nicole.user.is_staff,
"language": nicole.user.language,
"timezone": str(nicole.user.timezone),
},
{
"id": str(frank.user.id),
"email": frank.email,
"name": frank.name,
"is_device": frank.user.is_device,
"is_staff": frank.user.is_staff,
"language": frank.user.language,
"timezone": str(frank.user.timezone),
},
]
def test_api_users_authenticated_list_by_name():
"""
Authenticated users should be able to search users with a case-insensitive and
partial query on the name.
"""
user = factories.UserFactory(email="tester@ministry.fr")
factories.IdentityFactory(user=user, email=user.email, name="john doe")
client = APIClient()
client.force_login(user)
dave = factories.IdentityFactory(name="dave bowman", email=None, is_main=True)
nicole = factories.IdentityFactory(name="nicole foole", email=None, is_main=True)
frank = factories.IdentityFactory(name="frank poole", email=None, is_main=True)
factories.IdentityFactory(name="heywood floyd", email=None, is_main=True)
# Full query should work
response = client.get(
"/api/v1.0/users/?q=david.bowman@work.com",
)
assert response.status_code == HTTP_200_OK
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids[0] == str(dave.user.id)
# Partial query should work
response = client.get("/api/v1.0/users/?q=fran")
assert response.status_code == HTTP_200_OK
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids[0] == str(frank.user.id)
# Result that matches a trigram twice ranks better than result that matches once
response = client.get("/api/v1.0/users/?q=ole")
assert response.status_code == HTTP_200_OK
user_ids = [user["id"] for user in response.json()["results"]]
# "Nicole Foole" matches twice on "ole"
assert user_ids == [str(nicole.user.id), str(frank.user.id)]
# Even with a low similarity threshold, query should match expected user
response = client.get("/api/v1.0/users/?q=ool")
assert response.status_code == HTTP_200_OK
assert response.json()["results"] == [
{
"id": str(nicole.user.id),
"email": nicole.email,
"name": nicole.name,
"is_device": nicole.user.is_device,
"is_staff": nicole.user.is_staff,
"language": nicole.user.language,
"timezone": str(nicole.user.timezone),
},
{
"id": str(frank.user.id),
"email": frank.email,
"name": frank.name,
"is_device": frank.user.is_device,
"is_staff": frank.user.is_staff,
"language": frank.user.language,
"timezone": str(frank.user.timezone),
},
]
def test_api_users_authenticated_list_by_name_and_email():
"""
Authenticated users should be able to search users with a case-insensitive and
partial query on the name and email.
"""
user = factories.UserFactory(email="tester@ministry.fr")
factories.IdentityFactory(user=user, email=user.email, name="john doe")
client = APIClient()
client.force_login(user)
nicole = factories.IdentityFactory(
email="nicole_foole@work.com", name="nicole foole", is_main=True
)
frank = factories.IdentityFactory(
email="oleg_poole@work.com", name=None, is_main=True
)
david = factories.IdentityFactory(email=None, name="david role", is_main=True)
# Result that matches a trigram in name and email ranks better than result that matches once
response = client.get("/api/v1.0/users/?q=ole")
assert response.status_code == HTTP_200_OK
user_ids = [user["id"] for user in response.json()["results"]]
# "Nicole Foole" matches twice on "ole" in her name and twice on "ole" in her email
# "Oleg poole" matches twice on "ole" in her email
# "David role" matches once on "ole" in his name
assert user_ids == [str(nicole.user.id), str(frank.user.id), str(david.user.id)]
def test_api_users_authenticated_list_exclude_users_already_in_team(
django_assert_num_queries,
):
"""
Authenticated users should be able to search users
but the result should exclude all users already in the given team.
"""
user = factories.UserFactory(email="tester@ministry.fr")
factories.IdentityFactory(user=user, email=user.email, name="john doe")
client = APIClient()
client.force_login(user)
dave = factories.IdentityFactory(name="dave bowman", email=None, is_main=True)
nicole = factories.IdentityFactory(name="nicole foole", email=None, is_main=True)
frank = factories.IdentityFactory(name="frank poole", email=None, is_main=True)
mary = factories.IdentityFactory(name="mary poole", email=None, is_main=True)
factories.IdentityFactory(name="heywood floyd", email=None, is_main=True)
factories.IdentityFactory(name="Andrei Smyslov", email=None, is_main=True)
factories.TeamFactory.create_batch(10)
# Add Dave and Frank in the same team
team = factories.TeamFactory(
users=[
(dave.user, models.RoleChoices.MEMBER),
(frank.user, models.RoleChoices.MEMBER),
]
)
factories.TeamFactory(users=[(nicole.user, models.RoleChoices.MEMBER)])
# Search user to add him/her to a team, we give a team id to the request
# to exclude all users already in the team
# We can't find David Bowman because he is already a member of the given team
# 2 queries are needed here:
# - user authenticated
# - search user query
with django_assert_num_queries(2):
response = client.get(
f"/api/v1.0/users/?q=bowman&team_id={team.id}",
)
assert response.status_code == HTTP_200_OK
assert response.json()["results"] == []
# We can only find Nicole and Mary because Frank is already a member of the given team
# 4 queries are needed here:
# - user authenticated
# - search user query
# - User
# - Identity
with django_assert_num_queries(4):
response = client.get(
f"/api/v1.0/users/?q=ool&team_id={team.id}",
)
assert response.status_code == HTTP_200_OK
user_ids = sorted([user["id"] for user in response.json()["results"]])
assert user_ids == sorted([str(mary.user.id), str(nicole.user.id)])
def test_api_users_authenticated_list_multiple_identities_single_user():
"""
User with multiple identities should appear only once in results.
"""
user = factories.UserFactory(email="tester@ministry.fr")
factories.IdentityFactory(user=user, email=user.email)
factories.IdentityFactory(user=user, email=user.email, name="eva karl")
client = APIClient()
client.force_login(user)
dave = factories.UserFactory()
factories.IdentityFactory(user=dave, email="david.bowman@work.com")
factories.IdentityFactory(user=dave, email="david.bowman@fun.fr")
factories.IdentityFactory(
user=dave, email="dave.bowman@work.com", name="dave bowman"
)
factories.IdentityFactory(user=dave, email="dave.bowman@fun.fr", name="dave bowman")
# Full query should work
response = client.get(
@@ -126,18 +309,24 @@ def test_api_users_authenticated_list_multiple_identities_multiple_users():
on their best matching identity.
"""
user = factories.UserFactory(email="tester@ministry.fr")
factories.IdentityFactory(user=user, email=user.email)
factories.IdentityFactory(user=user, email=user.email, name="john doe")
client = APIClient()
client.force_login(user)
dave = factories.UserFactory()
davina = factories.UserFactory()
prudence = factories.UserFactory()
factories.IdentityFactory(user=dave, email="david.bowman@work.com")
factories.IdentityFactory(user=dave, email="babibou@ehehe.com")
factories.IdentityFactory(user=davina, email="davina.bowan@work.com")
factories.IdentityFactory(user=prudence, email="prudence.crandall@work.com")
dave_identity = factories.IdentityFactory(
user=dave, email="dave.bowman@work.com", is_main=True, name="dave bowman"
)
factories.IdentityFactory(user=dave, email="babibou@ehehe.com", name="babihou")
davina_identity = factories.IdentityFactory(
user=factories.UserFactory(), email="davina.bowan@work.com", name="davina"
)
prue_identity = factories.IdentityFactory(
user=factories.UserFactory(),
email="prudence.crandall@work.com",
name="prudence",
)
# Full query should work
response = client.get(
@@ -146,19 +335,48 @@ def test_api_users_authenticated_list_multiple_identities_multiple_users():
assert response.status_code == HTTP_200_OK
assert response.json()["count"] == 3
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(dave.id), str(davina.id), str(prudence.id)]
assert response.json()["results"] == [
{
"id": str(dave.id),
"email": dave_identity.email,
"name": dave_identity.name,
"is_device": dave_identity.user.is_device,
"is_staff": dave_identity.user.is_staff,
"language": dave_identity.user.language,
"timezone": str(dave_identity.user.timezone),
},
{
"id": str(davina_identity.user.id),
"email": davina_identity.email,
"name": davina_identity.name,
"is_device": davina_identity.user.is_device,
"is_staff": davina_identity.user.is_staff,
"language": davina_identity.user.language,
"timezone": str(davina_identity.user.timezone),
},
{
"id": str(prue_identity.user.id),
"email": prue_identity.email,
"name": prue_identity.name,
"is_device": prue_identity.user.is_device,
"is_staff": prue_identity.user.is_staff,
"language": prue_identity.user.language,
"timezone": str(prue_identity.user.timezone),
},
]
def test_api_users_authenticated_list_uppercase_content():
"""Upper case content should be found by lower case query."""
user = factories.UserFactory(email="tester@ministry.fr")
factories.IdentityFactory(user=user, email=user.email)
factories.IdentityFactory(user=user, email=user.email, name="eva karl")
client = APIClient()
client.force_login(user)
dave = factories.IdentityFactory(email="DAVID.BOWMAN@INTENSEWORK.COM")
dave = factories.IdentityFactory(
email="DAVID.BOWMAN@INTENSEWORK.COM", name="DAVID BOWMAN"
)
# Unaccented full address
response = client.get(
@@ -182,12 +400,12 @@ def test_api_users_authenticated_list_uppercase_content():
def test_api_users_list_authenticated_capital_query():
"""Upper case query should find lower case content."""
user = factories.UserFactory(email="tester@ministry.fr")
factories.IdentityFactory(user=user, email=user.email)
factories.IdentityFactory(user=user, email=user.email, name="eva karl")
client = APIClient()
client.force_login(user)
dave = factories.IdentityFactory(email="david.bowman@work.com")
dave = factories.IdentityFactory(email="david.bowman@work.com", name="david bowman")
# Full uppercase query
response = client.get(
@@ -211,12 +429,14 @@ def test_api_users_list_authenticated_capital_query():
def test_api_contacts_list_authenticated_accented_query():
"""Accented content should be found by unaccented query."""
user = factories.UserFactory(email="tester@ministry.fr")
factories.IdentityFactory(user=user, email=user.email)
factories.IdentityFactory(user=user, email=user.email, name="john doe")
client = APIClient()
client.force_login(user)
helene = factories.IdentityFactory(email="helene.bowman@work.com")
helene = factories.IdentityFactory(
email="helene.bowman@work.com", name="helene bowman"
)
# Accented full query
response = client.get(
@@ -278,6 +498,58 @@ def test_api_users_list_pagination(
assert len(content["results"]) == 2
@pytest.mark.parametrize("page_size", [1, 10, 99, 100])
def test_api_users_list_pagination_page_size(
page_size,
):
"""Page's size on pagination should work as expected."""
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
for i in range(page_size):
factories.UserFactory.create(email=f"user-{i}@people.com")
response = client.get(
f"/api/v1.0/users/?page_size={page_size}",
)
assert response.status_code == HTTP_200_OK
content = response.json()
assert content["count"] == page_size + 1
assert len(content["results"]) == page_size
@pytest.mark.parametrize("page_size", [101, 200])
def test_api_users_list_pagination_wrong_page_size(
page_size,
):
"""Page's size on pagination should be limited to "max_page_size"."""
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
for i in range(page_size):
factories.UserFactory.create(email=f"user-{i}@people.com")
response = client.get(
f"/api/v1.0/users/?page_size={page_size}",
)
assert response.status_code == HTTP_200_OK
content = response.json()
assert content["count"] == page_size + 1
# Length should not exceed "max_page_size" default value
assert len(content["results"]) == 100
def test_api_users_retrieve_me_anonymous():
"""Anonymous users should not be allowed to list users."""
factories.UserFactory.create_batch(2)
@@ -291,7 +563,7 @@ def test_api_users_retrieve_me_anonymous():
def test_api_users_retrieve_me_authenticated():
"""Authenticated users should be able to retrieve their own user via the "/users/me" path."""
identity = factories.IdentityFactory()
identity = factories.IdentityFactory(is_main=True)
user = identity.user
client = APIClient()
@@ -310,12 +582,12 @@ def test_api_users_retrieve_me_authenticated():
assert response.status_code == HTTP_200_OK
assert response.json() == {
"id": str(user.id),
"email": str(user.email),
"name": str(identity.name),
"email": str(identity.email),
"language": user.language,
"timezone": str(user.timezone),
"is_device": False,
"is_staff": False,
"data": user.profile_contact.data,
}
@@ -1,11 +1,10 @@
"""Unit tests for the `get_or_create_user` function."""
from unittest import mock
from django.core.exceptions import SuspiciousOperation
import pytest
from rest_framework_simplejwt.exceptions import InvalidToken
from rest_framework_simplejwt.tokens import AccessToken
from core import factories, models
from core import models
from core.authentication import OIDCAuthenticationBackend
from core.factories import IdentityFactory
@@ -22,7 +21,7 @@ def test_authentication_getter_existing_user_no_email(
klass = OIDCAuthenticationBackend()
# Create a user and its identity
identity = IdentityFactory()
identity = IdentityFactory(name=None)
# Create multiple identities for a user
for _ in range(5):
@@ -35,7 +34,7 @@ def test_authentication_getter_existing_user_no_email(
with django_assert_num_queries(1):
user = klass.get_or_create_user(
access_token=AccessToken(), id_token=None, payload=None
access_token="test-token", id_token=None, payload=None
)
identity.refresh_from_db()
@@ -47,45 +46,82 @@ def test_authentication_getter_existing_user_with_email(
):
"""
When the user's info contains an email and targets an existing user,
it should update the email on the identity but not on the user.
"""
klass = OIDCAuthenticationBackend()
identity = IdentityFactory()
identity = IdentityFactory(name="John Doe")
# Create multiple identities for a user
for _ in range(5):
IdentityFactory(user=identity.user)
user_email = identity.user.email
assert models.User.objects.count() == 1
def get_userinfo_mocked(*args):
return {"sub": identity.sub, "email": identity.email}
return {
"sub": identity.sub,
"email": identity.email,
"first_name": "John",
"last_name": "Doe",
}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
# Only 1 query if the email has not changed
# Only 1 query because email and names have not changed
with django_assert_num_queries(1):
user = klass.get_or_create_user(
access_token=AccessToken(), id_token=None, payload=None
access_token="test-token", id_token=None, payload=None
)
new_email = "test@fooo.com"
assert models.User.objects.get() == user
@pytest.mark.parametrize(
"first_name, last_name, email",
[
("Jack", "Doe", "john.doe@example.com"),
("John", "Duy", "john.doe@example.com"),
("John", "Doe", "jack.duy@example.com"),
("Jack", "Duy", "jack.duy@example.com"),
],
)
def test_authentication_getter_existing_user_change_fields(
first_name, last_name, email, django_assert_num_queries, monkeypatch
):
"""
It should update the email or name fields on the identity when they change.
The email on the user should not be changed.
"""
klass = OIDCAuthenticationBackend()
identity = IdentityFactory(name="John Doe", email="john.doe@example.com")
user_email = identity.user.email
# Create multiple identities for a user
for _ in range(5):
IdentityFactory(user=identity.user)
assert models.User.objects.count() == 1
def get_userinfo_mocked(*args):
return {"sub": identity.sub, "email": new_email}
return {
"sub": identity.sub,
"email": email,
"first_name": first_name,
"last_name": last_name,
}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
# Additional update query if the email has changed
# One and only one additional update query when a field has changed
with django_assert_num_queries(2):
user = klass.get_or_create_user(
access_token=AccessToken(), id_token=None, payload=None
access_token="test-token", id_token=None, payload=None
)
identity.refresh_from_db()
assert identity.email == new_email
assert identity.email == email
assert identity.name == f"{first_name:s} {last_name:s}"
assert models.User.objects.count() == 1
assert user == identity.user
@@ -105,7 +141,7 @@ def test_authentication_getter_new_user_no_email(monkeypatch):
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
user = klass.get_or_create_user(
access_token=AccessToken(), id_token=None, payload=None
access_token="test-token", id_token=None, payload=None
)
identity = user.identities.get()
@@ -120,26 +156,28 @@ def test_authentication_getter_new_user_no_email(monkeypatch):
def test_authentication_getter_new_user_with_email(monkeypatch):
"""
If no user matches the user's info sub, a user should be created.
User's info contains an email, created user's email should be set.
User's email and name should be set on the identity.
The "email" field on the User model should not be set as it is reserved for staff users.
"""
klass = OIDCAuthenticationBackend()
email = "people@example.com"
def get_userinfo_mocked(*args):
return {"sub": "123", "email": email}
return {"sub": "123", "email": email, "first_name": "John", "last_name": "Doe"}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
user = klass.get_or_create_user(
access_token=AccessToken(), id_token=None, payload=None
access_token="test-token", id_token=None, payload=None
)
identity = user.identities.get()
assert identity.sub == "123"
assert identity.email == email
assert identity.name == "John Doe"
assert user.email == email
assert user.email is None
assert models.User.objects.count() == 1
@@ -154,11 +192,13 @@ def test_models_oidc_user_getter_invalid_token(django_assert_num_queries, monkey
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with django_assert_num_queries(0), pytest.raises(
InvalidToken, match="User info contained no recognizable user identification"
with (
django_assert_num_queries(0),
pytest.raises(
SuspiciousOperation,
match="User info contained no recognizable user identification",
),
):
user = klass.get_or_create_user(
access_token=AccessToken(), id_token=None, payload=None
)
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
assert models.User.objects.exists() is False
@@ -1,6 +1,7 @@
"""
Unit tests for the Contact model
"""
from django.core.exceptions import ValidationError
import pytest
@@ -1,6 +1,7 @@
"""
Unit tests for the Identity model
"""
from django.core.exceptions import ValidationError
import pytest
@@ -70,19 +71,19 @@ def test_models_identities_is_main_switch():
def test_models_identities_email_not_required():
"""The "email" field can be blank."""
"""The 'email' field can be blank."""
user = factories.UserFactory()
models.Identity.objects.create(user=user, sub="123", email=None)
def test_models_identities_user_required():
"""The "user" field is required."""
"""The 'user' field is required."""
with pytest.raises(models.User.DoesNotExist, match="Identity has no user."):
models.Identity.objects.create(user=None, email="david@example.com")
def test_models_identities_email_unique_same_user():
"""The "email" field should be unique for a given user."""
"""The 'email' field should be unique for a given user."""
email = factories.IdentityFactory()
with pytest.raises(
@@ -93,13 +94,13 @@ def test_models_identities_email_unique_same_user():
def test_models_identities_email_unique_different_users():
"""The "email" field should not be unique among users."""
"""The 'email' field should not be unique among users."""
email = factories.IdentityFactory()
factories.IdentityFactory(email=email.email)
def test_models_identities_email_normalization():
"""The email field should be automatically normalized upon saving."""
"""The 'email' field should be automatically normalized upon saving."""
email = factories.IdentityFactory()
email.email = "Thomas.Jefferson@Example.com"
email.save()
@@ -120,21 +121,21 @@ def test_models_identities_ordering():
def test_models_identities_sub_null():
"""The "sub" field should not be null."""
"""The 'sub' field should not be null."""
user = factories.UserFactory()
with pytest.raises(ValidationError, match="This field cannot be null."):
models.Identity.objects.create(user=user, sub=None)
def test_models_identities_sub_blank():
"""The "sub" field should not be blank."""
"""The 'sub' field should not be blank."""
user = factories.UserFactory()
with pytest.raises(ValidationError, match="This field cannot be blank."):
models.Identity.objects.create(user=user, email="david@example.com", sub="")
def test_models_identities_sub_unique():
"""The "sub" field should be unique."""
"""The 'sub' field should be unique."""
user = factories.UserFactory()
identity = factories.IdentityFactory()
with pytest.raises(ValidationError, match="Identity with this Sub already exists."):
@@ -142,7 +143,7 @@ def test_models_identities_sub_unique():
def test_models_identities_sub_max_length():
"""The subfield should be 255 characters maximum."""
"""The 'sub' field should be 255 characters maximum."""
factories.IdentityFactory(sub="a" * 255)
with pytest.raises(ValidationError) as excinfo:
factories.IdentityFactory(sub="a" * 256)
@@ -154,13 +155,13 @@ def test_models_identities_sub_max_length():
def test_models_identities_sub_special_characters():
"""The subfield should accept periods, dashes, +, @ and underscores."""
"""The 'sub' field should accept periods, dashes, +, @ and underscores."""
identity = factories.IdentityFactory(sub="dave.bowman-1+2@hal_9000")
assert identity.sub == "dave.bowman-1+2@hal_9000"
def test_models_identities_sub_spaces():
"""The subfield should not accept spaces."""
"""The 'sub' field should not accept spaces."""
with pytest.raises(ValidationError) as excinfo:
factories.IdentityFactory(sub="a b")
@@ -171,12 +172,12 @@ def test_models_identities_sub_spaces():
def test_models_identities_sub_upper_case():
"""The subfield should accept upper case characters."""
"""The 'sub' field should accept upper case characters."""
identity = factories.IdentityFactory(sub="John")
assert identity.sub == "John"
def test_models_identities_sub_ascii():
"""The subfield should accept non ASCII letters."""
"""The 'sub' field should accept non ASCII letters."""
identity = factories.IdentityFactory(sub="rené")
assert identity.sub == "rené"
@@ -0,0 +1,302 @@
"""
Unit tests for the Invitation model
"""
import smtplib
import time
import uuid
from logging import Logger
from unittest import mock
from django.contrib.auth.models import AnonymousUser
from django.core import exceptions, mail
import pytest
from faker import Faker
from freezegun import freeze_time
from core import factories, models
pytestmark = pytest.mark.django_db
fake = Faker()
def test_models_invitations_readonly_after_create():
"""Existing invitations should be readonly."""
invitation = factories.InvitationFactory()
with pytest.raises(exceptions.PermissionDenied):
invitation.save()
def test_models_invitations_email_no_empty_mail():
"""The "email" field should not be empty."""
with pytest.raises(exceptions.ValidationError, match="This field cannot be blank"):
factories.InvitationFactory(email="")
def test_models_invitations_email_no_null_mail():
"""The "email" field is required."""
with pytest.raises(exceptions.ValidationError, match="This field cannot be null"):
factories.InvitationFactory(email=None)
def test_models_invitations_team_required():
"""The "team" field is required."""
with pytest.raises(exceptions.ValidationError, match="This field cannot be null"):
factories.InvitationFactory(team=None)
def test_models_invitations_team_should_be_team_instance():
"""The "team" field should be a team instance."""
with pytest.raises(ValueError, match='Invitation.team" must be a "Team" instance'):
factories.InvitationFactory(team="ee")
def test_models_invitations_role_required():
"""The "role" field is required."""
with pytest.raises(exceptions.ValidationError, match="This field cannot be blank"):
factories.InvitationFactory(role="")
def test_models_invitations_role_among_choices():
"""The "role" field should be a valid choice."""
with pytest.raises(
exceptions.ValidationError, match="Value 'boss' is not a valid choice"
):
factories.InvitationFactory(role="boss")
def test_models_invitations__is_expired(settings):
"""
The 'is_expired' property should return False until validity duration
is exceeded and True afterwards.
"""
expired_invitation = factories.InvitationFactory()
assert expired_invitation.is_expired is False
settings.INVITATION_VALIDITY_DURATION = 1
time.sleep(1)
assert expired_invitation.is_expired is True
def test_models_invitation__new_user__convert_invitations_to_accesses():
"""
Upon creating a new identity, invitations linked to that email
should be converted to accesses and then deleted.
"""
# Two invitations to the same mail but to different teams
invitation_to_team1 = factories.InvitationFactory()
invitation_to_team2 = factories.InvitationFactory(email=invitation_to_team1.email)
other_invitation = factories.InvitationFactory(
team=invitation_to_team2.team
) # another person invited to team2
new_identity = factories.IdentityFactory(
is_main=True, email=invitation_to_team1.email
)
# The invitation regarding
assert models.TeamAccess.objects.filter(
team=invitation_to_team1.team, user=new_identity.user
).exists()
assert models.TeamAccess.objects.filter(
team=invitation_to_team2.team, user=new_identity.user
).exists()
assert not models.Invitation.objects.filter(
team=invitation_to_team1.team, email=invitation_to_team1.email
).exists() # invitation "consumed"
assert not models.Invitation.objects.filter(
team=invitation_to_team2.team, email=invitation_to_team2.email
).exists() # invitation "consumed"
assert models.Invitation.objects.filter(
team=invitation_to_team2.team, email=other_invitation.email
).exists() # the other invitation remains
def test_models_invitation__new_user__filter_expired_invitations():
"""
Upon creating a new identity, valid invitations should be converted into accesses
and expired invitations should remain unchanged.
"""
with freeze_time("2020-01-01"):
expired_invitation = factories.InvitationFactory()
user_email = expired_invitation.email
valid_invitation = factories.InvitationFactory(email=user_email)
new_identity = factories.IdentityFactory(is_main=True, email=user_email)
# valid invitation should have granted access to the related team
assert models.TeamAccess.objects.filter(
team=valid_invitation.team, user=new_identity.user
).exists()
assert not models.Invitation.objects.filter(
team=valid_invitation.team, email=user_email
).exists()
# expired invitation should not have been consumed
assert not models.TeamAccess.objects.filter(
team=expired_invitation.team, user=new_identity.user
).exists()
assert models.Invitation.objects.filter(
team=expired_invitation.team, email=user_email
).exists()
@pytest.mark.parametrize("num_invitations, num_queries", [(0, 8), (1, 11), (20, 11)])
def test_models_invitation__new_user__user_creation_constant_num_queries(
django_assert_num_queries, num_invitations, num_queries
):
"""
The number of queries executed during user creation should not be proportional
to the number of invitations being processed.
"""
user_email = fake.email()
if num_invitations != 0:
for _ in range(0, num_invitations):
factories.InvitationFactory(email=user_email, team=factories.TeamFactory())
user = factories.UserFactory()
# with no invitation, we skip an "if", resulting in 8 requests
# otherwise, we should have 11 queries with any number of invitations
with django_assert_num_queries(num_queries):
models.Identity.objects.create(
is_main=True,
email=user_email,
user=user,
name="Prudence C.",
sub=uuid.uuid4(),
)
# get_abilities
def test_models_team_invitations_get_abilities_anonymous():
"""Check abilities returned for an anonymous user."""
access = factories.InvitationFactory()
abilities = access.get_abilities(AnonymousUser())
assert abilities == {
"delete": False,
"get": False,
"patch": False,
"put": False,
}
def test_models_team_invitations_get_abilities_authenticated():
"""Check abilities returned for an authenticated user."""
access = factories.InvitationFactory()
user = factories.UserFactory()
abilities = access.get_abilities(user)
assert abilities == {
"delete": False,
"get": False,
"patch": False,
"put": False,
}
@pytest.mark.parametrize("role", ["administrator", "owner"])
def test_models_team_invitations_get_abilities_privileged_member(role):
"""Check abilities for a team member with a privileged role."""
pivileged_access = factories.TeamAccessFactory(role=role)
team = pivileged_access.team
factories.TeamAccessFactory(team=team) # another one
invitation = factories.InvitationFactory(team=team)
abilities = invitation.get_abilities(pivileged_access.user)
assert abilities == {
"delete": True,
"get": True,
"patch": False,
"put": False,
}
def test_models_team_invitations_get_abilities_member():
"""Check abilities for a team member with 'member' role."""
member_access = factories.TeamAccessFactory(role="member")
team = member_access.team
factories.TeamAccessFactory(team=team) # another one
invitation = factories.InvitationFactory(team=team)
abilities = invitation.get_abilities(member_access.user)
assert abilities == {
"delete": False,
"get": True,
"patch": False,
"put": False,
}
def test_models_team_invitations_email():
"""Check email invitation during invitation creation."""
member_access = factories.TeamAccessFactory(role="member")
team = member_access.team
# pylint: disable-next=no-member
assert len(mail.outbox) == 0
factories.TeamAccessFactory(team=team)
invitation = factories.InvitationFactory(team=team, email="john@people.com")
# pylint: disable-next=no-member
assert len(mail.outbox) == 1
# pylint: disable-next=no-member
(email,) = mail.outbox
assert email.to == [invitation.email]
assert email.subject == "Invitation to join Desk!"
email_content = " ".join(email.body.split())
assert "Invitation to join Desk!" in email_content
@mock.patch(
"django.core.mail.send_mail",
side_effect=smtplib.SMTPException("Error SMTPException"),
)
@mock.patch.object(Logger, "error")
def test_models_team_invitations_email_failed(mock_logger, _mock_send_mail):
"""Check invitation behavior when an SMTP error occurs during invitation creation."""
member_access = factories.TeamAccessFactory(role="member")
team = member_access.team
# pylint: disable-next=no-member
assert len(mail.outbox) == 0
factories.TeamAccessFactory(team=team)
# No error should be raised
invitation = factories.InvitationFactory(team=team, email="john@people.com")
# No email has been sent
# pylint: disable-next=no-member
assert len(mail.outbox) == 0
# Logger should be called
mock_logger.assert_called_once()
(
_,
email,
exception,
) = mock_logger.call_args.args
assert email == invitation.email
assert isinstance(exception, smtplib.SMTPException)
@@ -1,6 +1,7 @@
"""
Unit tests for the TeamAccess model
"""
from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import ValidationError
+3 -2
View File
@@ -1,6 +1,7 @@
"""
Unit tests for the Team model
"""
from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import ValidationError
@@ -61,7 +62,7 @@ def test_models_teams_get_abilities_anonymous():
abilities = team.get_abilities(AnonymousUser())
assert abilities == {
"delete": False,
"get": True,
"get": False,
"patch": False,
"put": False,
"manage_accesses": False,
@@ -74,7 +75,7 @@ def test_models_teams_get_abilities_authenticated():
abilities = team.get_abilities(factories.UserFactory())
assert abilities == {
"delete": False,
"get": True,
"get": False,
"patch": False,
"put": False,
"manage_accesses": False,
+3 -2
View File
@@ -1,6 +1,7 @@
"""
Unit tests for the User model
"""
from unittest import mock
from django.core.exceptions import ValidationError
@@ -35,13 +36,13 @@ def test_models_users_email_unique():
with pytest.raises(
ValidationError, match="User with this Email address already exists."
):
factories.UserFactory(email=user.email)
models.User.objects.create(email=user.email)
def test_models_users_email_several_null():
"""Several users with a null value for the "email" field can co-exist."""
factories.UserFactory(email=None)
factories.UserFactory(email=None)
models.User.objects.create(email=None, password="foo.")
assert models.User.objects.filter(email__isnull=True).count() == 2
+1
View File
@@ -1,6 +1,7 @@
"""
Test Throttle in People's app.
"""
import pytest
from rest_framework.test import APIClient
-25
View File
@@ -1,25 +0,0 @@
"""Utils for tests in the People core application"""
from rest_framework_simplejwt.tokens import AccessToken
class OIDCToken(AccessToken):
"""Set payload on token from user/contact/email"""
@classmethod
def for_user(cls, user):
"""Returns an authorization token for the given user for testing."""
identity = user.identities.filter(is_main=True).first()
token = cls()
token["first_name"] = (
user.profile_contact.short_name if user.profile_contact else "David"
)
token["last_name"] = (
" ".join(user.profile_contact.full_name.split()[1:])
if user.profile_contact
else "Bowman"
)
token["sub"] = str(identity.sub)
token["email"] = user.email
return token
-10
View File
@@ -1,10 +0,0 @@
"""Tokens for People's core app."""
from rest_framework_simplejwt.settings import api_settings
from rest_framework_simplejwt.tokens import Token
class BearerToken(Token):
"""Bearer token as emitted by Keycloak OIDC for example."""
token_type = "Bearer" # noqa: S105
lifetime = api_settings.ACCESS_TOKEN_LIFETIME
+21
View File
@@ -0,0 +1,21 @@
"""Debug Urls to check the layout of emails"""
from django.urls import path
from .views import (
DebugViewHtml,
DebugViewTxt,
)
urlpatterns = [
path(
"__debug__/mail/invitation_html",
DebugViewHtml.as_view(),
name="debug.mail.invitation_html",
),
path(
"__debug__/mail/invitation_txt",
DebugViewTxt.as_view(),
name="debug.mail.invitation_txt",
),
]
+27
View File
@@ -0,0 +1,27 @@
"""Debug Views to check the layout of emails"""
from django.views.generic.base import TemplateView
class DebugBaseView(TemplateView):
"""Debug View to check the layout of emails"""
def get_context_data(self, **kwargs):
"""Generates sample datas to have a valid debug email"""
context = super().get_context_data(**kwargs)
context["title"] = "Development email preview"
return context
class DebugViewHtml(DebugBaseView):
"""Debug View for HTML Email Layout"""
template_name = "mail/html/invitation.html"
class DebugViewTxt(DebugBaseView):
"""Debug View for Text Email Layout"""
template_name = "mail/text/invitation.txt"
-1
View File
@@ -3,6 +3,5 @@
NB_OBJECTS = {
"users": 1000,
"teams": 100,
"max_identities_per_user": 3,
"max_users_per_team": 100,
}
@@ -11,10 +11,14 @@ from django import db
from django.conf import settings
from django.core.management.base import BaseCommand, CommandError
from faker import Faker
from core import models
from demo import defaults
fake = Faker()
logger = logging.getLogger("people.commands.demo.create_demo")
@@ -125,15 +129,19 @@ def create_demo(stdout):
users_values = list(models.User.objects.values("id", "email"))
for user_dict in users_values:
for i in range(
random.randint(0, defaults.NB_OBJECTS["max_identities_per_user"])
random.choices(range(5), weights=[5, 50, 30, 10, 5], k=1)[0]
):
user_email = user_dict["email"]
queue.push(
models.Identity(
user_id=user_dict["id"],
sub=uuid4(),
email=f"identity{i:d}{user_email:s}",
is_main=(i == 0),
# Leave 3% of emails and names empty
email=f"identity{i:d}{user_email:s}"
if random.random() < 0.97
else None,
name=fake.name() if random.random() < 0.97 else None,
)
)
queue.flush()
@@ -2,6 +2,7 @@
Management command overriding the "createsuperuser" command to allow creating users
with their email and no username.
"""
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand
@@ -1,4 +1,5 @@
"""Test the `create_demo` management command"""
from unittest import mock
from django.core.management import call_command
+235 -88
View File
@@ -2,7 +2,7 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-people\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-01-03 23:15+0000\n"
"POT-Creation-Date: 2024-03-21 19:26+0000\n"
"PO-Revision-Date: 2024-01-03 23:15\n"
"Last-Translator: \n"
"Language-Team: French\n"
@@ -17,175 +17,322 @@ msgstr ""
"X-Crowdin-File: backend.pot\n"
"X-Crowdin-File-ID: 2\n"
#: core/models.py:30
#: core/admin.py:70
msgid "Personal info"
msgstr ""
#: core/admin.py:72
msgid "Permissions"
msgstr ""
#: core/admin.py:84
msgid "Important dates"
msgstr ""
#: core/authentication.py:81
msgid "User info contained no recognizable user identification"
msgstr ""
#: core/authentication.py:114
msgid "Claims contained no recognizable user identification"
msgstr ""
#: core/models.py:38
msgid "Member"
msgstr ""
#: core/models.py:31
#: core/models.py:39
msgid "Administrator"
msgstr ""
#: core/models.py:32
#: core/models.py:40
msgid "Owner"
msgstr ""
#: core/models.py:44
#: core/models.py:52
msgid "id"
msgstr ""
#: core/models.py:45
#: core/models.py:53
msgid "primary key for the record as UUID"
msgstr ""
#: core/models.py:51
#: core/models.py:59
msgid "created at"
msgstr ""
#: core/models.py:52
#: core/models.py:60
msgid "date and time at which a record was created"
msgstr ""
#: core/models.py:57
#: core/models.py:65
msgid "updated at"
msgstr ""
#: core/models.py:58
#: core/models.py:66
msgid "date and time at which a record was last updated"
msgstr ""
#: core/models.py:89
#: core/models.py:97
msgid "full name"
msgstr ""
#: core/models.py:90
#: core/models.py:98
msgid "short name"
msgstr ""
#: core/models.py:95
#: core/models.py:103
msgid "contact information"
msgstr ""
#: core/models.py:96
#: core/models.py:104
msgid "A JSON object containing the contact information"
msgstr ""
#: core/models.py:110
#: core/models.py:118
msgid "contact"
msgstr ""
#: core/models.py:111
#: core/models.py:119
msgid "contacts"
msgstr ""
#: core/models.py:173
msgid "language"
msgstr ""
#: core/models.py:174
msgid "The language in which the user wants to see the interface."
msgstr ""
#: core/models.py:180
msgid "The timezone in which the user wants to see times."
msgstr ""
#: core/models.py:183
msgid "device"
msgstr ""
#: core/models.py:185
msgid "Whether the user is a device or a real user."
msgstr ""
#: core/models.py:188
msgid "staff status"
msgstr ""
#: core/models.py:190
msgid "Whether the user can log into this admin site."
msgstr ""
#: core/models.py:193
msgid "active"
msgstr ""
#: core/models.py:196
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr ""
#: core/models.py:208
msgid "user"
msgstr ""
#: core/models.py:209
msgid "users"
msgstr ""
#: core/models.py:245
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters."
msgstr ""
#: core/models.py:252
msgid "sub"
msgstr ""
#: core/models.py:254
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
msgstr ""
#: core/models.py:260
#: core/models.py:160 core/models.py:263 core/models.py:483
msgid "email address"
msgstr ""
#: core/models.py:262
msgid "main"
#: core/models.py:172
msgid "language"
msgstr ""
#: core/models.py:173
msgid "The language in which the user wants to see the interface."
msgstr ""
#: core/models.py:179
msgid "The timezone in which the user wants to see times."
msgstr ""
#: core/models.py:182
msgid "device"
msgstr ""
#: core/models.py:184
msgid "Whether the user is a device or a real user."
msgstr ""
#: core/models.py:187
msgid "staff status"
msgstr ""
#: core/models.py:189
msgid "Whether the user can log into this admin site."
msgstr ""
#: core/models.py:192
msgid "active"
msgstr ""
#: core/models.py:195
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
msgstr ""
#: core/models.py:207
msgid "user"
msgstr ""
#: core/models.py:208
msgid "users"
msgstr ""
#: core/models.py:248
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
msgstr ""
#: core/models.py:255
msgid "sub"
msgstr ""
#: core/models.py:257
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
msgstr ""
#: core/models.py:264
msgid "name"
msgstr ""
#: core/models.py:266
msgid "main"
msgstr ""
#: core/models.py:268
msgid "Designates whether the email is the main one."
msgstr ""
#: core/models.py:270
#: core/models.py:274
msgid "identity"
msgstr ""
#: core/models.py:271
#: core/models.py:275
msgid "identities"
msgstr ""
#: core/models.py:278
#: core/models.py:282
msgid "This email address is already declared for this user."
msgstr ""
#: core/models.py:323
#: core/models.py:356
msgid "Team"
msgstr ""
#: core/models.py:324
#: core/models.py:357
msgid "Teams"
msgstr ""
#: core/models.py:375
#: core/models.py:417
msgid "Team/user relation"
msgstr ""
#: core/models.py:376
#: core/models.py:418
msgid "Team/user relations"
msgstr ""
#: core/models.py:381
#: core/models.py:423
msgid "This user is already in this team."
msgstr ""
#: core/models.py:451
msgid "Token contained no recognizable user identification"
#: core/models.py:500
msgid "Team invitation"
msgstr ""
#: people/settings.py:132
msgid "English"
#: core/models.py:501
msgid "Team invitations"
msgstr ""
#: core/models.py:526
msgid "This email is already associated to a registered user."
msgstr ""
#: core/models.py:568 core/models.py:573
msgid "Invitation to join Desk!"
msgstr ""
#: core/templates/mail/html/invitation.html:160
#: core/templates/mail/text/invitation.txt:3
msgid "La Suite Numérique"
msgstr ""
#: core/templates/mail/html/invitation.html:190
#: core/templates/mail/text/invitation.txt:5
msgid "Invitation to join a team"
msgstr ""
#: core/templates/mail/html/invitation.html:198
#: core/templates/mail/text/invitation.txt:8
msgid "Welcome to"
msgstr ""
#: core/templates/mail/html/invitation.html:216
#: core/templates/mail/text/invitation.txt:12
msgid "Logo"
msgstr ""
#: core/templates/mail/html/invitation.html:226
#: core/templates/mail/text/invitation.txt:14
msgid ""
"We are delighted to welcome you to our community on Equipes, your new "
"companion to simplify the management of your groups efficiently, "
"intuitively, and securely."
msgstr ""
#: core/templates/mail/html/invitation.html:231
#: core/templates/mail/text/invitation.txt:15
msgid ""
"Our application is designed to help you organize, collaborate, and manage "
"permissions."
msgstr ""
#: core/templates/mail/html/invitation.html:236
#: core/templates/mail/text/invitation.txt:16
msgid "With Equipes, you will be able to:"
msgstr ""
#: core/templates/mail/html/invitation.html:237
#: core/templates/mail/text/invitation.txt:17
msgid "Create customized groups according to your specific needs."
msgstr ""
#: core/templates/mail/html/invitation.html:238
#: core/templates/mail/text/invitation.txt:18
msgid "Invite members of your team or community in just a few clicks."
msgstr ""
#: core/templates/mail/html/invitation.html:239
#: core/templates/mail/text/invitation.txt:19
msgid ""
"Plan events, meetings, or activities effortlessly with our integrated "
"calendar."
msgstr ""
#: core/templates/mail/html/invitation.html:240
#: core/templates/mail/text/invitation.txt:20
msgid "Share documents, photos, and important information securely."
msgstr ""
#: core/templates/mail/html/invitation.html:241
#: core/templates/mail/text/invitation.txt:21
msgid ""
"Facilitate exchanges and communication with our messaging and group "
"discussion tools."
msgstr ""
#: core/templates/mail/html/invitation.html:252
#: core/templates/mail/text/invitation.txt:23
msgid "Visit Equipes"
msgstr ""
#: core/templates/mail/html/invitation.html:261
#: core/templates/mail/text/invitation.txt:25
msgid ""
"We are confident that Equipes will help you increase efficiency and "
"productivity while strengthening the bond among members."
msgstr ""
#: core/templates/mail/html/invitation.html:266
#: core/templates/mail/text/invitation.txt:26
msgid ""
"Feel free to explore all the features of the application and share your "
"feedback and suggestions with us. Your feedback is valuable to us and will "
"enable us to continually improve our service."
msgstr ""
#: core/templates/mail/html/invitation.html:271
#: core/templates/mail/text/invitation.txt:27
msgid ""
"Once again, welcome aboard! We are eager to accompany you on this group "
"management adventure."
msgstr ""
#: core/templates/mail/html/invitation.html:278
#: core/templates/mail/text/invitation.txt:29
msgid "Sincerely,"
msgstr ""
#: core/templates/mail/html/invitation.html:279
#: core/templates/mail/text/invitation.txt:31
msgid "The La Suite Numérique Team"
msgstr ""
#: people/settings.py:133
msgid "French"
msgid "English"
msgstr ""
#: people/settings.py:134
msgid "French"
msgstr ""
+1
View File
@@ -2,6 +2,7 @@
"""
People's sandbox management script.
"""
import os
import sys
+7
View File
@@ -1,4 +1,5 @@
"""API URL Configuration"""
from django.conf import settings
from django.urls import include, path, re_path
@@ -21,6 +22,12 @@ team_related_router.register(
basename="team_accesses",
)
team_related_router.register(
"invitations",
viewsets.InvitationViewset,
basename="invitations",
)
urlpatterns = [
path(
f"api/{settings.API_VERSION}/",
+1
View File
@@ -1,4 +1,5 @@
"""People celery configuration file."""
import os
from celery import Celery
+42 -5
View File
@@ -9,6 +9,7 @@ https://docs.djangoproject.com/en/3.1/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/3.1/ref/settings/
"""
import json
import os
@@ -70,6 +71,7 @@ class Base(Configuration):
# Security
ALLOWED_HOSTS = values.ListValue([])
SECRET_KEY = values.Value(None)
SILENCED_SYSTEM_CHECKS = values.ListValue([])
# Application definition
ROOT_URLCONF = "people.urls"
@@ -228,7 +230,18 @@ class Base(Configuration):
"PAGE_SIZE": 20,
"DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.URLPathVersioning",
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
"DEFAULT_THROTTLE_RATES": {"sustained": "150/hour", "burst": "20/minute"},
"DEFAULT_THROTTLE_RATES": {
"sustained": values.Value(
default="150/hour",
environ_name="SUSTAINED_THROTTLE_RATES",
environ_prefix=None,
),
"burst": values.Value(
default="20/minute",
environ_name="BURST_THROTTLE_RATES",
environ_prefix=None,
),
},
}
SPECTACULAR_SETTINGS = {
@@ -254,14 +267,16 @@ class Base(Configuration):
EMAIL_HOST_PASSWORD = values.Value(None)
EMAIL_PORT = values.PositiveIntegerValue(None)
EMAIL_USE_TLS = values.BooleanValue(False)
EMAIL_FROM = values.Value("from@example.com")
EMAIL_USE_SSL = values.BooleanValue(False)
EMAIL_FROM = values.Value("do-not-reply@desk.beta.numerique.gouv.fr")
AUTH_USER_MODEL = "core.User"
INVITATION_VALIDITY_DURATION = 604800 # 7 days, in seconds
# CORS
CORS_ALLOW_CREDENTIALS = True
CORS_ALLOW_ALL_ORIGINS = values.BooleanValue(False)
CORS_ALLOWED_ORIGINS = values.ListValue([], environ_name="CORS_ALLOWED_ORIGINS")
CORS_ALLOWED_ORIGINS = values.ListValue([])
CORS_ALLOWED_ORIGIN_REGEXES = values.ListValue([])
# Sentry
@@ -278,6 +293,7 @@ class Base(Configuration):
# Session
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
SESSION_CACHE_ALIAS = "default"
SESSION_COOKIE_AGE = 60 * 60 * 12 # 12 hours to match Agent Connect
# OIDC - Authorization Code Flow
@@ -292,7 +308,8 @@ class Base(Configuration):
"people", environ_name="OIDC_RP_CLIENT_ID", environ_prefix=None
)
OIDC_RP_CLIENT_SECRET = values.Value(
environ_name="OIDC_RP_CLIENT_SECRET", environ_prefix=None
environ_name="OIDC_RP_CLIENT_SECRET",
environ_prefix=None,
)
OIDC_OP_JWKS_ENDPOINT = values.Value(
environ_name="OIDC_OP_JWKS_ENDPOINT", environ_prefix=None
@@ -331,6 +348,12 @@ class Base(Configuration):
default=[], environ_name="OIDC_REDIRECT_ALLOWED_HOSTS", environ_prefix=None
)
USER_OIDC_FIELDS_TO_NAME = values.ListValue(
default=["first_name", "last_name"],
environ_name="USER_OIDC_FIELDS_TO_NAME",
environ_prefix=None,
)
# pylint: disable=invalid-name
@property
def ENVIRONMENT(self):
@@ -411,7 +434,7 @@ class Development(Base):
ALLOWED_HOSTS = ["*"]
CORS_ALLOW_ALL_ORIGINS = True
CSRF_TRUSTED_ORIGINS = ["http://localhost:8072"]
CSRF_TRUSTED_ORIGINS = ["http://localhost:8072", "http://localhost:3000"]
DEBUG = True
SESSION_COOKIE_NAME = "people_sessionid"
@@ -526,6 +549,20 @@ class Production(Base):
AWS_STORAGE_BUCKET_NAME = values.Value("tf-default-people-media-storage")
AWS_S3_REGION_NAME = values.Value()
CACHES = {
"default": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": values.Value(
"redis://redis:6379/1",
environ_name="REDIS_URL",
environ_prefix=None,
),
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
},
},
}
class Feature(Production):
"""
+3
View File
@@ -12,6 +12,8 @@ from drf_spectacular.views import (
SpectacularSwaggerView,
)
from debug import urls as debug_urls
from . import api_urls
API_VERSION = settings.API_VERSION
@@ -25,6 +27,7 @@ if settings.DEBUG:
urlpatterns
+ staticfiles_urlpatterns()
+ static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
+ debug_urls.urlpatterns
)
if settings.USE_SWAGGER or settings.DEBUG:
+22 -20
View File
@@ -25,20 +25,21 @@ license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"boto3==1.34.39",
"boto3==1.34.75",
"Brotli==1.1.0",
"celery[redis]==5.3.6",
"django-configurations==2.5",
"django-configurations==2.5.1",
"django-cors-headers==4.3.1",
"django-countries==7.5.1",
"django-countries==7.6.1",
"django-parler==2.3",
"redis==5.0.3",
"django-redis==5.4.0",
"django-storages==1.14.2",
"django-timezone-field>=5.1",
"django==5.0.2",
"djangorestframework-simplejwt[crypto]==5.3.1",
"djangorestframework==3.14.0",
"drf_spectacular==0.27.1",
"dockerflow==2024.1.0",
"django==5.0.3",
"djangorestframework==3.15.1",
"drf_spectacular==0.27.2",
"dockerflow==2024.3.0",
"easy_thumbnails==2.8.5",
"factory_boy==3.3.0",
"gunicorn==21.2.0",
@@ -47,10 +48,10 @@ dependencies = [
"psycopg[binary]==3.1.18",
"PyJWT==2.8.0",
"requests==2.31.0",
"sentry-sdk==1.40.3",
"sentry-sdk==1.44.0",
"url-normalize==1.4.3",
"whitenoise==6.6.0",
"mozilla-django-oidc==4.0.0",
"mozilla-django-oidc==4.0.1",
]
[project.urls]
@@ -62,20 +63,21 @@ dependencies = [
[project.optional-dependencies]
dev = [
"django-extensions==3.2.3",
"drf-spectacular-sidecar==2024.2.1",
"drf-spectacular-sidecar==2024.4.1",
"ipdb==0.13.13",
"ipython==8.21.0",
"ipython==8.23.0",
"pyfakefs==5.3.5",
"pylint-django==2.5.5",
"pylint==3.0.3",
"pytest-cov==4.1.0",
"pylint==3.1.0",
"pytest-cov==5.0.0",
"pytest-django==4.8.0",
"pytest==8.0.0",
"pytest==8.1.1",
"pytest-icdiff==0.9",
"pytest-xdist==3.5.0",
"responses==0.24.1",
"ruff==0.2.1",
"types-requests==2.31.0.20240125",
"responses==0.25.0",
"ruff==0.3.5",
"types-requests==2.31.0.20240402",
"freezegun==1.4.0",
]
[tool.setuptools]
@@ -94,7 +96,6 @@ exclude = [
"__pycache__",
"*/migrations/*",
]
ignore= ["DJ001", "PLR2004"]
line-length = 88
@@ -115,12 +116,13 @@ select = [
"SLF", # flake8-self
"T20", # flake8-print
]
ignore= ["DJ001", "PLR2004"]
[tool.ruff.lint.isort]
section-order = ["future","standard-library","django","third-party","people","first-party","local-folder"]
sections = { people=["core"], django=["django"] }
[tool.ruff.per-file-ignores]
[tool.ruff.lint.per-file-ignores]
"**/tests/*" = ["S", "SLF"]
[tool.pytest.ini_options]
+1 -1
View File
@@ -1 +1 @@
NEXT_PUBLIC_API_URL=http://kubernetes-service-name-wip:8071/api/v1.0/
NEXT_PUBLIC_API_URL=https://desk-staging.beta.numerique.gouv.fr/api/v1.0/
+15
View File
@@ -0,0 +1,15 @@
server {
listen 8080;
server_name localhost;
root /usr/share/nginx/html;
location / {
try_files $uri index.html $uri/ =404;
}
error_page 404 /404.html;
location = /404.html {
internal;
}
}
+100 -17
View File
@@ -3,6 +3,8 @@ const config = {
default: {
theme: {
colors: {
'card-border': '#DDDDDD',
'primary-bg': '#FAFAFA',
'primary-100': '#EDF5FA',
'primary-150': '#E5EEFA',
'info-150': '#E5EEFA',
@@ -72,10 +74,16 @@ const config = {
'forms-input': {
'value-color': 'var(--c--theme--colors--primary-500)',
'border-color': 'var(--c--theme--colors--primary-500)',
color: {
error: 'var(--c--theme--colors--danger-500)',
'error-hover': 'var(--c--theme--colors--danger-500)',
'box-shadow-error-hover': 'var(--c--theme--colors--danger-500)',
},
},
'forms-labelledbox': {
'label-color': {
small: 'var(--c--theme--colors--primary-500)',
'small-disabled': 'var(--c--theme--colors--greyscale-400)',
big: {
disabled: 'var(--c--theme--colors--greyscale-400)',
},
@@ -83,6 +91,8 @@ const config = {
},
'forms-select': {
'border-color': 'var(--c--theme--colors--primary-500)',
'border-color-disabled-hover':
'var(--c--theme--colors--greyscale-200)',
'border-radius': {
hover: 'var(--c--components--forms-select--border-radius)',
focus: 'var(--c--components--forms-select--border-radius)',
@@ -109,6 +119,9 @@ const config = {
'border-color-hover': 'var(--c--theme--colors--greyscale-200)',
},
},
modal: {
'background-color': '#ffffff',
},
button: {
'border-radius': {
active: 'var(--c--components--button--border-radius)',
@@ -175,8 +188,10 @@ const config = {
dsfr: {
theme: {
colors: {
'card-border': '#DDDDDD',
'primary-text': '#000091',
'primary-100': '#f5f5fe',
'primary-150': '#F4F4FD',
'primary-200': '#ececfe',
'primary-300': '#e3e3fd',
'primary-400': '#cacafb',
@@ -195,10 +210,11 @@ const config = {
'secondary-700': '#3b2424',
'secondary-800': '#341f1f',
'secondary-900': '#2b1919',
'greyscale-000': '#cecece',
'greyscale-100': '#f6f6f6',
'greyscale-200': '#eeeeee',
'greyscale-300': '#e5e5e5',
'greyscale-text': '#303C4B',
'greyscale-000': '#f6f6f6',
'greyscale-100': '#eeeeee',
'greyscale-200': '#e5e5e5',
'greyscale-300': '#e1e1e1',
'greyscale-400': '#dddddd',
'greyscale-500': '#cecece',
'greyscale-600': '#7b7b7b',
@@ -258,30 +274,97 @@ const config = {
'border-radius': '0',
},
button: {
'border-radius': '2px',
'medium-height': '48px',
'border-radius': '4px',
primary: {
background: {
color: 'var(--c--theme--colors--primary-text)',
'color-hover': '#1212ff',
'color-active': '#2323ff',
},
color: '#ffffff',
'color-hover': '#ffffff',
'color-active': '#ffffff',
},
'primary-text': {
background: {
'color-hover': 'var(--c--theme--colors--primary-100)',
'color-active': 'var(--c--theme--colors--primary-100)',
},
'color-hover': 'var(--c--theme--colors--primary-text)',
},
secondary: {
background: {
'color-hover': '#F6F6F6',
'color-active': '#EDEDED',
},
border: {
color: 'var(--c--theme--colors--primary-600)',
'color-hover': 'var(--c--theme--colors--primary-600)',
},
color: 'var(--c--theme--colors--primary-text)',
},
'tertiary-text': {
background: {
'color-hover': 'var(--c--theme--colors--primary-100)',
},
'color-hover': 'var(--c--theme--colors--primary-text)',
},
},
datagrid: {
header: {
color: 'var(--c--theme--colors--primary-text)',
size: 'var(--c--theme--font--sizes--s)',
},
body: {
'background-color': 'transparent',
'background-color-hover': '#F4F4FD',
},
pagination: {
'background-color': 'transparent',
'background-color-active': 'var(--c--theme--colors--primary-300)',
},
},
'forms-checkbox': {
'border-radius': '0',
color: 'var(--c--theme--colors--primary-text)',
},
'forms-datepicker': {
'border-radius': '0',
},
'forms-fileuploader': {
'border-radius': '0',
},
'forms-field': {
color: 'var(--c--theme--colors--primary-text)',
},
'forms-input': {
'border-radius': '4px',
'background-color': '#ffffff',
'border-color': 'var(--c--theme--colors--primary-text)',
'box-shadow-color': 'var(--c--theme--colors--primary-text)',
'value-color': 'var(--c--theme--colors--primary-text)',
},
'forms-labelledbox': {
'label-color': {
big: 'var(--c--theme--colors--primary-text)',
},
},
'forms-select': {
'border-radius': '4px',
'border-radius-hover': '4px',
'background-color': '#ffffff',
'border-color': 'var(--c--theme--colors--primary-text)',
'border-color-hover': 'var(--c--theme--colors--primary-text)',
'box-shadow-color': 'var(--c--theme--colors--primary-text)',
},
'forms-switch': {
'handle-border-radius': '2px',
'rail-border-radius': '4px',
},
'forms-input': {
'border-radius': '0',
},
'forms-select': {
'border-radius': '0',
},
'forms-datepicker': {
'border-radius': '0',
},
'forms-textarea': {
'border-radius': '0',
},
'forms-fileuploader': {
'border-radius': '0',
},
},
},
},
+7
View File
@@ -1,6 +1,13 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'export',
images: {
unoptimized: true,
},
compiler: {
// Enables the styled-components SWC transform
styledComponents: true,
},
webpack(config) {
// Grab the existing rule that handles SVG imports
const fileLoaderRule = config.module.rules.find((rule) =>
+20 -13
View File
@@ -15,33 +15,40 @@
"test:watch": "jest --watch"
},
"dependencies": {
"@openfun/cunningham-react": "2.4.0",
"@tanstack/react-query": "5.18.1",
"i18next": "23.7.16",
"next": "14.1.0",
"@openfun/cunningham-react": "2.7.0",
"@tanstack/react-query": "5.28.9",
"i18next": "23.10.1",
"lodash": "4.17.21",
"luxon": "3.4.4",
"next": "14.1.4",
"react": "18.2.0",
"react-aria-components": "1.1.1",
"react-dom": "18.2.0",
"react-i18next": "14.0.0",
"react-i18next": "14.1.0",
"react-select": "5.8.0",
"styled-components": "6.1.8",
"zustand": "4.5.0"
"zustand": "4.5.2"
},
"devDependencies": {
"@svgr/webpack": "8.1.0",
"@tanstack/react-query-devtools": "5.18.1",
"@testing-library/jest-dom": "6.4.1",
"@testing-library/react": "14.2.1",
"@tanstack/react-query-devtools": "5.28.10",
"@testing-library/jest-dom": "6.4.2",
"@testing-library/react": "14.2.2",
"@testing-library/user-event": "14.5.2",
"@types/jest": "29.5.12",
"@types/lodash": "4.17.0",
"@types/luxon": "3.4.2",
"@types/node": "*",
"@types/react": "18.2.53",
"@types/react-dom": "18.2.18",
"dotenv": "16.4.1",
"@types/react": "18.2.73",
"@types/react-dom": "*",
"dotenv": "16.4.5",
"eslint-config-people": "*",
"fetch-mock": "9.11.0",
"jest": "29.7.0",
"jest-environment-jsdom": "29.7.0",
"node-fetch": "2.7.0",
"prettier": "3.2.5",
"stylelint": "16.2.1",
"stylelint": "16.3.1",
"stylelint-config-standard": "36.0.0",
"stylelint-prettier": "5.0.0",
"typescript": "*"

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

@@ -3,16 +3,16 @@ import { render, screen } from '@testing-library/react';
import { AppWrapper } from '@/tests/utils';
import Page from '../page';
import Page from '../pages';
describe('Page', () => {
it('checks Page rendering', () => {
render(<Page />, { wrapper: AppWrapper });
expect(screen.getByRole('status')).toBeInTheDocument();
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent(
'Hello Desk !',
);
expect(
screen.getByRole('button', {
name: /Create a new team/i,
}),
).toBeInTheDocument();
});
});
@@ -0,0 +1,20 @@
interface IAPIError<T = unknown> {
status: number;
cause?: string[];
data?: T;
}
export class APIError<T = unknown> extends Error implements IAPIError<T> {
public status: IAPIError['status'];
public cause?: IAPIError['cause'];
public data?: IAPIError<T>['data'];
constructor(message: string, { status, cause, data }: IAPIError<T>) {
super(message);
this.name = 'APIError';
this.status = status;
this.cause = cause;
this.data = data;
}
}
@@ -1,7 +1,7 @@
import fetchMock from 'fetch-mock';
import { fetchAPI } from '@/api';
import { useAuthStore } from '@/features/';
import { useAuthStore } from '@/core/auth';
describe('fetchAPI', () => {
beforeEach(() => {
@@ -34,10 +34,25 @@ describe('fetchAPI', () => {
});
it('logout if 401 response', async () => {
const mockReplace = jest.fn();
Object.defineProperty(window, 'location', {
configurable: true,
enumerable: true,
value: {
replace: mockReplace,
},
});
useAuthStore.setState({ userData: { email: 'test@test.com' } });
fetchMock.mock('http://some.api.url/api/v1.0/some/url', 401);
await fetchAPI('some/url');
expect(useAuthStore.getState().userData).toBeUndefined();
expect(mockReplace).toHaveBeenCalledWith(
'http://some.api.url/api/v1.0/authenticate/',
);
});
});
+22 -2
View File
@@ -1,21 +1,41 @@
import { useAuthStore } from '@/features/';
import { login, useAuthStore } from '@/core/auth';
/**
* Retrieves the CSRF token from the document's cookies.
*
* @returns {string|null} The CSRF token if found in the cookies, or null if not present.
*/
function getCSRFToken() {
return document.cookie
.split(';')
.filter((cookie) => cookie.trim().startsWith('csrftoken='))
.map((cookie) => cookie.split('=')[1])
.pop();
}
export const fetchAPI = async (input: string, init?: RequestInit) => {
const apiUrl = `${process.env.NEXT_PUBLIC_API_URL}${input}`;
const { logout } = useAuthStore.getState();
const csrfToken = getCSRFToken();
const response = await fetch(apiUrl, {
...init,
credentials: 'include',
headers: {
...init?.headers,
'Content-Type': 'application/json',
...(csrfToken && { 'X-CSRFToken': csrfToken }),
},
});
// todo - handle 401, redirect to login screen
// todo - please have a look to this documentation page https://mozilla-django-oidc.readthedocs.io/en/stable/xhr.html
response.status === 401 && logout();
if (response.status === 401) {
logout();
// Fix - force re-logging the user, will be refactored
login();
}
return response;
};
+2
View File
@@ -1,2 +1,4 @@
export * from './APIError';
export * from './fetchApi';
export * from './types';
export * from './utils';
+18
View File
@@ -0,0 +1,18 @@
export const errorCauses = async (response: Response, data?: unknown) => {
const errorsBody = (await response.json()) as Record<
string,
string | string[]
> | null;
const causes = errorsBody
? Object.entries(errorsBody)
.map(([, value]) => value)
.flat()
: undefined;
return {
status: response.status,
cause: causes,
data,
};
};
@@ -1,25 +0,0 @@
import { Box } from '@/components';
import { HEADER_HEIGHT, Header, Menu } from '@/features/';
export default function InnerLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<Box as="main" $direction="column" $height="100vh" $css="overflow:hidden;">
<Header />
<Box $css="flex: 1;">
<Menu />
<Box
$direction="column"
$height={`calc(100vh - ${HEADER_HEIGHT})`}
$width="100%"
$css="overflow: auto;"
>
{children}
</Box>
</Box>
</Box>
);
}
@@ -1,7 +0,0 @@
'use client';
import { Box } from '@/components';
export default function Contacts() {
return <Box>Contacts</Box>;
}
@@ -1,7 +0,0 @@
'use client';
import { Box } from '@/components';
export default function Favorite() {
return <Box>Favorite</Box>;
}
@@ -1,6 +0,0 @@
@import url('../cunningham/cunningham-style.css');
body {
margin: 0;
padding: 0;
}
@@ -1,7 +0,0 @@
'use client';
import { Box } from '@/components';
export default function Groups() {
return <Box>Groups</Box>;
}
@@ -1,7 +0,0 @@
'use client';
import { Box } from '@/components';
export default function Help() {
return <Box>Help</Box>;
}
-37
View File
@@ -1,37 +0,0 @@
'use client';
import { CunninghamProvider } from '@openfun/cunningham-react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { useCunninghamTheme } from '@/cunningham';
import { Auth } from '@/features/auth/Auth';
import '@/i18n/initI18n';
import InnerLayout from './InnerLayout';
import './globals.css';
const queryClient = new QueryClient();
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
const { theme } = useCunninghamTheme();
return (
<html lang="en">
<body suppressHydrationWarning={process.env.NODE_ENV === 'development'}>
<QueryClientProvider client={queryClient}>
<ReactQueryDevtools />
<CunninghamProvider theme={theme}>
<Auth>
<InnerLayout>{children}</InnerLayout>
</Auth>
</CunninghamProvider>
</QueryClientProvider>
</body>
</html>
);
}
-17
View File
@@ -1,17 +0,0 @@
'use client';
import { useTranslation } from 'react-i18next';
import { Box } from '@/components';
import { Teams } from '@/features';
export default function Home() {
const { t } = useTranslation();
return (
<Box $direction="column" className="p-b">
<h1>{t('Hello Desk !')}</h1>
<Teams />
</Box>
);
}
@@ -1,7 +0,0 @@
'use client';
import { Box } from '@/components';
export default function Recent() {
return <Box>Recent</Box>;
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

Before

Width:  |  Height:  |  Size: 1020 B

After

Width:  |  Height:  |  Size: 1020 B

@@ -0,0 +1,29 @@
<svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_179_19260)">
<path
d="M12.6406 26.02C14.5606 26.06 16.3406 27.02 17.5406 28.7C19.0006 30.76 21.4206 32 24.0006 32C26.5806 32 29.0006 30.76 30.4606 28.68C31.6606 27 33.4406 26.04 35.3606 26C33.9206 23.56 28.1606 22 24.0006 22C19.8606 22 14.0806 23.56 12.6406 26.02Z"
fill="currentColor"
/>
<path
d="M8 26C11.32 26 14 23.32 14 20C14 16.68 11.32 14 8 14C4.68 14 2 16.68 2 20C2 23.32 4.68 26 8 26Z"
fill="currentColor"
/>
<path
d="M40 26C43.32 26 46 23.32 46 20C46 16.68 43.32 14 40 14C36.68 14 34 16.68 34 20C34 23.32 36.68 26 40 26Z"
fill="currentColor"
/>
<path
d="M24 20C27.32 20 30 17.32 30 14C30 10.68 27.32 8 24 8C20.68 8 18 10.68 18 14C18 17.32 20.68 20 24 20Z"
fill="currentColor"
/>
<path
d="M42 28H35.46C33.92 28 32.76 28.9 32.1 29.84C32.02 29.96 29.38 34 24 34C21.14 34 17.94 32.72 15.9 29.84C15.12 28.74 13.9 28 12.54 28H6C3.8 28 2 29.8 2 32V40H16V35.48C18.3 37.08 21.08 38 24 38C26.92 38 29.7 37.08 32 35.48V40H46V32C46 29.8 44.2 28 42 28Z"
fill="currentColor"
/>
</g>
<defs>
<clipPath id="clip0_179_19260">
<rect width="48" height="48" fill="white" />
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,6 @@
<svg viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M20 0C8.96 0 0 8.96 0 20C0 31.04 8.96 40 20 40C31.04 40 40 31.04 40 20C40 8.96 31.04 0 20 0ZM20 6C23.32 6 26 8.68 26 12C26 15.32 23.32 18 20 18C16.68 18 14 15.32 14 12C14 8.68 16.68 6 20 6ZM20 34.4C15 34.4 10.58 31.84 8 27.96C8.06 23.98 16 21.8 20 21.8C23.98 21.8 31.94 23.98 32 27.96C29.42 31.84 25 34.4 20 34.4Z"
fill="currentColor"
/>
</svg>

After

Width:  |  Height:  |  Size: 439 B

+10 -1
View File
@@ -1,4 +1,4 @@
import { ReactHTML } from 'react';
import { ComponentPropsWithRef, ReactHTML } from 'react';
import styled from 'styled-components';
import { CSSProperties } from 'styled-components/dist/types';
@@ -14,13 +14,19 @@ export interface BoxProps {
$gap?: CSSProperties['gap'];
$height?: CSSProperties['height'];
$justify?: CSSProperties['justifyContent'];
$overflow?: CSSProperties['overflow'];
$position?: CSSProperties['position'];
$radius?: CSSProperties['borderRadius'];
$width?: CSSProperties['width'];
$maxWidth?: CSSProperties['maxWidth'];
$minWidth?: CSSProperties['minWidth'];
}
export type BoxType = ComponentPropsWithRef<typeof Box>;
export const Box = styled('div')<BoxProps>`
display: flex;
flex-direction: column;
${({ $align }) => $align && `align-items: ${$align};`}
${({ $background }) => $background && `background: ${$background};`}
${({ $color }) => $color && `color: ${$color};`}
@@ -30,8 +36,11 @@ export const Box = styled('div')<BoxProps>`
${({ $gap }) => $gap && `gap: ${$gap};`}
${({ $height }) => $height && `height: ${$height};`}
${({ $justify }) => $justify && `justify-content: ${$justify};`}
${({ $overflow }) => $overflow && `overflow: ${$overflow};`}
${({ $position }) => $position && `position: ${$position};`}
${({ $radius }) => $radius && `border-radius: ${$radius};`}
${({ $width }) => $width && `width: ${$width};`}
${({ $maxWidth }) => $maxWidth && `max-width: ${$maxWidth};`}
${({ $minWidth }) => $minWidth && `min-width: ${$minWidth};`}
${({ $css }) => $css && `${$css};`}
`;
@@ -0,0 +1,41 @@
import { ComponentPropsWithRef, forwardRef } from 'react';
import { Box, BoxType } from './Box';
export type BoxButtonType = ComponentPropsWithRef<typeof BoxButton>;
/**
* Styleless button that extends the Box component.
* Good to wrap around SVGs or other elements that need to be clickable.
* @param props - @see BoxType props
* @param ref
* @see Box
* @example
* ```tsx
* <BoxButton $radius="100%" aria-label="My button" onClick={() => console.log('clicked')}>
* Click me
* </BoxButton>
* ```
*/
const BoxButton = forwardRef<HTMLDivElement, BoxType>(
({ $css, ...props }, ref) => {
return (
<Box
ref={ref}
as="button"
$background="none"
$css={`
cursor: pointer;
border: none;
outline: none;
transition: all 0.2s ease-in-out;
${$css || ''}
`}
{...props}
/>
);
},
);
BoxButton.displayName = 'BoxButton';
export { BoxButton };
@@ -0,0 +1,30 @@
import { PropsWithChildren } from 'react';
import styled from 'styled-components';
import { useCunninghamTheme } from '@/cunningham';
import { BoxType } from '.';
const Wrapper = styled.div`
position: relative;
background-color: white;
border-radius: 0.25rem;
box-shadow: 2px 2px 5px var(--shadow-color) 88;
border: 1px solid var(--border-color);
`;
export const Card = ({ children, ...props }: PropsWithChildren<BoxType>) => {
const { colorsTokens } = useCunninghamTheme();
return (
<Wrapper
style={{
'--border-color': colorsTokens()['card-border'],
'--shadow-color': colorsTokens()['primary-300'],
}}
{...props}
>
{children}
</Wrapper>
);
};
@@ -0,0 +1,67 @@
import React, {
PropsWithChildren,
ReactNode,
useEffect,
useState,
} from 'react';
import { Button, DialogTrigger, Popover } from 'react-aria-components';
import styled from 'styled-components';
const StyledPopover = styled(Popover)`
background-color: white;
border-radius: 4px;
box-shadow: 1px 1px 5px rgba(0, 0, 0, 0.1);
padding: 0.5rem;
border: 1px solid #dddddd;
opacity: 0;
transition: opacity 0.2s ease-in-out;
`;
const StyledButton = styled(Button)`
cursor: pointer;
border: none;
background: none;
outline: none;
transition: all 0.2s ease-in-out;
`;
interface DropButtonProps {
button: ReactNode;
isOpen?: boolean;
onOpenChange?: (isOpen: boolean) => void;
}
export const DropButton = ({
button,
isOpen = false,
onOpenChange,
children,
}: PropsWithChildren<DropButtonProps>) => {
const [opacity, setOpacity] = useState(false);
const [isLocalOpen, setIsLocalOpen] = useState(isOpen);
useEffect(() => {
setIsLocalOpen(isOpen);
}, [isOpen]);
const onOpenChangeHandler = (isOpen: boolean) => {
setIsLocalOpen(isOpen);
onOpenChange?.(isOpen);
setTimeout(() => {
setOpacity(isOpen);
}, 10);
};
return (
<DialogTrigger onOpenChange={onOpenChangeHandler} isOpen={isLocalOpen}>
<StyledButton>{button}</StyledButton>
<StyledPopover
style={{ opacity: opacity ? 1 : 0 }}
isOpen={isLocalOpen}
onOpenChange={onOpenChangeHandler}
>
{children}
</StyledPopover>
</DialogTrigger>
);
};
@@ -0,0 +1,22 @@
import { Text } from '@/components';
interface IconOptionsProps {
isOpen: boolean;
'aria-label': string;
}
export const IconOptions = ({ isOpen, ...props }: IconOptionsProps) => {
return (
<Text
aria-label={props['aria-label']}
className="material-icons"
$theme="primary"
$css={`
transition: all 0.3s ease-in-out;
transform: rotate(${isOpen ? '90' : '0'}deg);
`}
>
more_vert
</Text>
);
};

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