Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c14c774f1 | |||
| 258fd7663c | |||
| 5ee9f13c4a |
@@ -4,7 +4,6 @@ on:
|
||||
push:
|
||||
tags:
|
||||
- 'preprod'
|
||||
- 'production'
|
||||
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -5,6 +5,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
- 'fix/warning-backend'
|
||||
tags:
|
||||
- 'v*'
|
||||
pull_request:
|
||||
|
||||
@@ -181,7 +181,7 @@ jobs:
|
||||
DOCKER_BUILDKIT: 1
|
||||
COMPOSE_DOCKER_CLI_BUILD: 1
|
||||
run: |
|
||||
docker compose build --pull --build-arg BUILDKIT_INLINE_CACHE=1
|
||||
docker-compose build --pull --build-arg BUILDKIT_INLINE_CACHE=1
|
||||
make run
|
||||
|
||||
- name: Apply DRF migrations
|
||||
|
||||
@@ -66,7 +66,6 @@ src/frontend/tsclient
|
||||
|
||||
# Test & lint
|
||||
.coverage
|
||||
coverage.json
|
||||
.pylint.d
|
||||
.pytest_cache
|
||||
db.sqlite3
|
||||
|
||||
@@ -166,10 +166,6 @@ test-back-parallel: ## run all back-end tests in parallel
|
||||
bin/pytest -n auto $${args:-${1}}
|
||||
.PHONY: test-back-parallel
|
||||
|
||||
test-coverage: ## compute, display and save test coverage
|
||||
bin/pytest --cov=. --cov-report json .
|
||||
.PHONY: test-coverage
|
||||
|
||||
makemigrations: ## run django makemigrations for the people project.
|
||||
@echo "$(BOLD)Running makemigrations$(RESET)"
|
||||
@$(COMPOSE) up -d postgresql
|
||||
@@ -193,8 +189,7 @@ showmigrations: ## run django showmigrations for the people project.
|
||||
|
||||
superuser: ## Create an admin superuser with password "admin"
|
||||
@echo "$(BOLD)Creating a Django superuser$(RESET)"
|
||||
$(MANAGE) createsuperuser --username admin --password admin
|
||||
|
||||
@$(MANAGE) createsuperuser --admin_email admin@example.com --password admin
|
||||
.PHONY: superuser
|
||||
|
||||
back-i18n-compile: ## compile the gettext files
|
||||
|
||||
@@ -69,7 +69,7 @@ You first need to create a superuser account:
|
||||
$ make superuser
|
||||
```
|
||||
|
||||
You can then login with sub `admin` and password `admin`.
|
||||
You can then login with credentials `admin@example` / `admin`.
|
||||
|
||||
### Run frontend
|
||||
|
||||
|
||||
@@ -13,13 +13,7 @@
|
||||
"enabled": false,
|
||||
"groupName": "ignored js dependencies",
|
||||
"matchManagers": ["npm"],
|
||||
"matchPackageNames": [
|
||||
"fetch-mock",
|
||||
"node",
|
||||
"node-fetch",
|
||||
"i18next-parser",
|
||||
"eslint"
|
||||
]
|
||||
"matchPackageNames": ["node", "node-fetch", "i18next-parser", "eslint"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""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
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@@ -7,6 +8,42 @@ from django.utils.translation import gettext_lazy as _
|
||||
from . import models
|
||||
|
||||
|
||||
class IdentityFormSet(forms.BaseInlineFormSet):
|
||||
"""
|
||||
Make the "is_main" field readonly when it is True so that declaring another identity
|
||||
works in the admin.
|
||||
"""
|
||||
|
||||
def add_fields(self, form, index):
|
||||
"""Disable the "is_main" field when it is set to True"""
|
||||
super().add_fields(form, index)
|
||||
is_main_value = form.instance.is_main if form.instance else False
|
||||
form.fields["is_main"].disabled = is_main_value
|
||||
|
||||
|
||||
class IdentityInline(admin.TabularInline):
|
||||
"""Inline admin class for user identities."""
|
||||
|
||||
fields = (
|
||||
"sub",
|
||||
"email",
|
||||
"is_main",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
formset = IdentityFormSet
|
||||
model = models.Identity
|
||||
extra = 0
|
||||
readonly_fields = ("email", "created_at", "sub", "updated_at")
|
||||
|
||||
def has_add_permission(self, request, obj):
|
||||
"""
|
||||
Identities are automatically created on successful OIDC logins.
|
||||
Disable creating identities via the admin.
|
||||
"""
|
||||
return False
|
||||
|
||||
|
||||
class TeamAccessInline(admin.TabularInline):
|
||||
"""Inline admin class for team accesses."""
|
||||
|
||||
@@ -35,12 +72,11 @@ class UserAdmin(auth_admin.UserAdmin):
|
||||
{
|
||||
"fields": (
|
||||
"id",
|
||||
"sub",
|
||||
"password",
|
||||
)
|
||||
},
|
||||
),
|
||||
(_("Personal info"), {"fields": ("email", "language", "timezone")}),
|
||||
(_("Personal info"), {"fields": ("admin_email", "language", "timezone")}),
|
||||
(
|
||||
_("Permissions"),
|
||||
{
|
||||
@@ -61,14 +97,13 @@ class UserAdmin(auth_admin.UserAdmin):
|
||||
None,
|
||||
{
|
||||
"classes": ("wide",),
|
||||
"fields": ("sub", "email", "password1", "password2"),
|
||||
"fields": ("admin_email", "password1", "password2"),
|
||||
},
|
||||
),
|
||||
)
|
||||
inlines = (TeamAccessInline,)
|
||||
inlines = (IdentityInline, TeamAccessInline)
|
||||
list_display = (
|
||||
"sub",
|
||||
"email",
|
||||
"admin_email",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"is_active",
|
||||
@@ -78,14 +113,8 @@ class UserAdmin(auth_admin.UserAdmin):
|
||||
)
|
||||
list_filter = ("is_staff", "is_superuser", "is_device", "is_active")
|
||||
ordering = ("is_active", "-is_superuser", "-is_staff", "-is_device", "-updated_at")
|
||||
readonly_fields = ["id", "created_at", "updated_at"]
|
||||
search_fields = ("id", "email", "sub")
|
||||
|
||||
def get_readonly_fields(self, request, obj=None):
|
||||
"""The sub should only be editable for a create, not for updates."""
|
||||
if obj:
|
||||
return self.readonly_fields + ["sub"]
|
||||
return self.readonly_fields
|
||||
readonly_fields = ("id", "created_at", "updated_at")
|
||||
search_fields = ("id", "admin_email", "identities__sub", "identities__email")
|
||||
|
||||
|
||||
@admin.register(models.Team)
|
||||
@@ -102,19 +131,6 @@ class TeamAdmin(admin.ModelAdmin):
|
||||
search_fields = ("name",)
|
||||
|
||||
|
||||
@admin.register(models.TeamAccess)
|
||||
class TeamAccessAdmin(admin.ModelAdmin):
|
||||
"""Team access admin interface declaration."""
|
||||
|
||||
list_display = (
|
||||
"user",
|
||||
"team",
|
||||
"role",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
|
||||
|
||||
@admin.register(models.Invitation)
|
||||
class InvitationAdmin(admin.ModelAdmin):
|
||||
"""Admin interface to handle invitations."""
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""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 (
|
||||
@@ -198,6 +198,12 @@ 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="_identities_main",
|
||||
)
|
||||
)
|
||||
|
||||
# Exclude all users already in the given team
|
||||
@@ -208,11 +214,15 @@ class UserViewSet(
|
||||
if query := self.request.GET.get("q", ""):
|
||||
similarity = Max(
|
||||
TrigramSimilarity(
|
||||
Coalesce(Func("email", function="unaccent"), Value("")),
|
||||
Coalesce(
|
||||
Func("identities__email", function="unaccent"), Value("")
|
||||
),
|
||||
Func(Value(query), function="unaccent"),
|
||||
)
|
||||
+ TrigramSimilarity(
|
||||
Coalesce(Func("name", function="unaccent"), Value("")),
|
||||
Coalesce(
|
||||
Func("identities__name", function="unaccent"), Value("")
|
||||
),
|
||||
Func(Value(query), function="unaccent"),
|
||||
)
|
||||
)
|
||||
@@ -319,7 +329,7 @@ class TeamAccessViewSet(
|
||||
|
||||
filter_backends = [filters.OrderingFilter]
|
||||
ordering = ["role"]
|
||||
ordering_fields = ["role", "user__email", "user__name"]
|
||||
ordering_fields = ["role", "email", "name"]
|
||||
|
||||
def get_permissions(self):
|
||||
"""User only needs to be authenticated to list team accesses"""
|
||||
@@ -347,39 +357,34 @@ class TeamAccessViewSet(
|
||||
queryset = queryset.filter(team=self.kwargs["team_id"])
|
||||
|
||||
if self.action in {"list", "retrieve"}:
|
||||
if query := self.request.GET.get("q", ""):
|
||||
similarity = Max(
|
||||
TrigramSimilarity(
|
||||
Coalesce(Func("user__email", function="unaccent"), Value("")),
|
||||
Func(Value(query), function="unaccent"),
|
||||
)
|
||||
+ TrigramSimilarity(
|
||||
Coalesce(Func("user__name", function="unaccent"), Value("")),
|
||||
Func(Value(query), function="unaccent"),
|
||||
)
|
||||
)
|
||||
queryset = (
|
||||
queryset.annotate(similarity=similarity)
|
||||
.filter(similarity__gte=SIMILARITY_THRESHOLD)
|
||||
.order_by("-similarity")
|
||||
)
|
||||
|
||||
# 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]
|
||||
|
||||
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,
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"user__identities",
|
||||
queryset=models.Identity.objects.filter(is_main=True),
|
||||
to_attr="_identities_main",
|
||||
)
|
||||
)
|
||||
# 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]),
|
||||
)
|
||||
.select_related("user")
|
||||
.distinct()
|
||||
)
|
||||
return queryset
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import requests
|
||||
@@ -9,12 +10,14 @@ from mozilla_django_oidc.auth import (
|
||||
OIDCAuthenticationBackend as MozillaOIDCAuthenticationBackend,
|
||||
)
|
||||
|
||||
from core.models import Identity
|
||||
|
||||
|
||||
class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
"""Custom OpenID Connect (OIDC) Authentication Backend.
|
||||
|
||||
This class overrides the default OIDC Authentication Backend to accommodate differences
|
||||
in the User model, and handles signed and/or encrypted UserInfo response.
|
||||
in the User and Identity models, and handles signed and/or encrypted UserInfo response.
|
||||
"""
|
||||
|
||||
def get_userinfo(self, access_token, id_token, payload):
|
||||
@@ -78,16 +81,28 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
_("User info contained no recognizable user identification")
|
||||
)
|
||||
|
||||
try:
|
||||
user = self.UserModel.objects.get(sub=sub)
|
||||
except self.UserModel.DoesNotExist:
|
||||
if self.get_settings("OIDC_CREATE_USER", True):
|
||||
user = self.create_user(user_info)
|
||||
else:
|
||||
user = (
|
||||
self.UserModel.objects.filter(identities__sub=sub)
|
||||
.annotate(
|
||||
identity_email=models.F("identities__email"),
|
||||
identity_name=models.F("identities__name"),
|
||||
)
|
||||
.distinct()
|
||||
.first()
|
||||
)
|
||||
if user:
|
||||
email = user_info.get("email")
|
||||
name = user_info.get("name")
|
||||
if email and email != user.email or name and name != user.name:
|
||||
self.UserModel.objects.filter(sub=sub).update(email=email, name=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)
|
||||
|
||||
return user
|
||||
|
||||
@@ -99,9 +114,9 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
_("Claims contained no recognizable user identification")
|
||||
)
|
||||
|
||||
return self.UserModel.objects.create(
|
||||
password="!", # noqa: S106
|
||||
sub=sub,
|
||||
email=claims.get("email"),
|
||||
name=claims.get("name"),
|
||||
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
|
||||
|
||||
@@ -124,13 +124,24 @@ class UserFactory(factory.django.DjangoModelFactory):
|
||||
|
||||
class Meta:
|
||||
model = models.User
|
||||
django_get_or_create = ("admin_email",)
|
||||
|
||||
admin_email = factory.Faker("email")
|
||||
language = factory.fuzzy.FuzzyChoice([lang[0] for lang in settings.LANGUAGES])
|
||||
password = make_password("password")
|
||||
|
||||
|
||||
class IdentityFactory(factory.django.DjangoModelFactory):
|
||||
"""A factory to create identities for a user"""
|
||||
|
||||
class Meta:
|
||||
model = models.Identity
|
||||
django_get_or_create = ("sub",)
|
||||
|
||||
user = factory.SubFactory(UserFactory)
|
||||
sub = factory.Sequence(lambda n: f"user{n!s}")
|
||||
email = factory.Faker("email")
|
||||
name = factory.Faker("name")
|
||||
language = factory.fuzzy.FuzzyChoice([lang[0] for lang in settings.LANGUAGES])
|
||||
password = make_password("password")
|
||||
|
||||
|
||||
class TeamFactory(factory.django.DjangoModelFactory):
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.0.3 on 2024-06-08 09:25
|
||||
# Generated by Django 5.0.3 on 2024-03-25 22:58
|
||||
|
||||
import django.contrib.auth.models
|
||||
import django.core.validators
|
||||
@@ -45,9 +45,7 @@ class Migration(migrations.Migration):
|
||||
('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')),
|
||||
('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')),
|
||||
('admin_email', models.EmailField(blank=True, max_length=254, null=True, unique=True, verbose_name='admin email address')),
|
||||
('language', models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language')),
|
||||
('timezone', timezone_field.fields.TimeZoneField(choices_display='WITH_GMT_OFFSET', default='UTC', help_text='The timezone in which the user wants to see times.', use_pytz=False)),
|
||||
('is_device', models.BooleanField(default=False, help_text='Whether the user is a device or a real user.', verbose_name='device')),
|
||||
@@ -89,6 +87,25 @@ class Migration(migrations.Migration):
|
||||
name='profile_contact',
|
||||
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='user', to='core.contact'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Identity',
|
||||
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')),
|
||||
('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)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'identity',
|
||||
'verbose_name_plural': 'identities',
|
||||
'db_table': 'people_identity',
|
||||
'ordering': ('-is_main', 'email'),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Invitation',
|
||||
fields=[
|
||||
@@ -156,6 +173,10 @@ class Migration(migrations.Migration):
|
||||
name='contact',
|
||||
unique_together={('owner', 'base')},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
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'),
|
||||
|
||||
@@ -161,25 +161,9 @@ class Contact(BaseModel):
|
||||
class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
"""User model to work with OIDC only authentication."""
|
||||
|
||||
sub_validator = validators.RegexValidator(
|
||||
regex=r"^[\w.@+-]+\Z",
|
||||
message=_(
|
||||
"Enter a valid sub. This value may contain only letters, "
|
||||
"numbers, and @/./+/-/_ characters."
|
||||
),
|
||||
admin_email = models.EmailField(
|
||||
_("admin email address"), unique=True, null=True, blank=True
|
||||
)
|
||||
|
||||
sub = models.CharField(
|
||||
_("sub"),
|
||||
help_text=_(
|
||||
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
||||
),
|
||||
max_length=255,
|
||||
unique=True,
|
||||
validators=[sub_validator],
|
||||
)
|
||||
email = models.EmailField(_("email address"), null=True, blank=True)
|
||||
name = models.CharField(_("name"), max_length=100, null=True, blank=True)
|
||||
profile_contact = models.OneToOneField(
|
||||
Contact,
|
||||
on_delete=models.SET_NULL,
|
||||
@@ -221,7 +205,7 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
|
||||
objects = auth_models.UserManager()
|
||||
|
||||
USERNAME_FIELD = "sub"
|
||||
USERNAME_FIELD = "admin_email"
|
||||
REQUIRED_FIELDS = []
|
||||
|
||||
class Meta:
|
||||
@@ -230,11 +214,116 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
verbose_name_plural = _("users")
|
||||
|
||||
def __str__(self):
|
||||
return self.name if self.name else self.email or f"User {self.sub}"
|
||||
return (
|
||||
str(self.profile_contact)
|
||||
if self.profile_contact
|
||||
else self.admin_email or str(self.id)
|
||||
)
|
||||
|
||||
def _get_identities_main(self):
|
||||
"""Return a list with the main identity or an empty list."""
|
||||
try:
|
||||
return self._identities_main
|
||||
except AttributeError:
|
||||
return self.identities.filter(is_main=True)
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return main identity's name."""
|
||||
try:
|
||||
return self._get_identities_main()[0].name
|
||||
except IndexError:
|
||||
return None
|
||||
|
||||
@property
|
||||
def email(self):
|
||||
"""Return main identity's email."""
|
||||
try:
|
||||
return self._get_identities_main()[0].email
|
||||
except IndexError:
|
||||
return None
|
||||
|
||||
def clean(self):
|
||||
"""Validate fields."""
|
||||
super().clean()
|
||||
|
||||
if self.profile_contact_id and not self.profile_contact.owner == self:
|
||||
raise exceptions.ValidationError(
|
||||
"Users can only declare as profile a contact they own."
|
||||
)
|
||||
|
||||
def email_user(self, subject, message, from_email=None, **kwargs):
|
||||
"""Email this user."""
|
||||
email = self.email or self.admin_email
|
||||
if not email:
|
||||
raise ValueError("You must first set an email for the user.")
|
||||
mail.send_mail(subject, message, from_email, [email], **kwargs)
|
||||
|
||||
@classmethod
|
||||
def get_email_field_name(cls):
|
||||
"""
|
||||
Raise error when trying to get email field name from the user as we are using
|
||||
a separate Email model to allow several emails per user.
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
"This feature is deactivated to allow several emails per user."
|
||||
)
|
||||
|
||||
|
||||
class Identity(BaseModel):
|
||||
"""User identity"""
|
||||
|
||||
sub_validator = validators.RegexValidator(
|
||||
regex=r"^[\w.@+-]+\Z",
|
||||
message=_(
|
||||
"Enter a valid sub. This value may contain only letters, "
|
||||
"numbers, and @/./+/-/_ characters."
|
||||
),
|
||||
)
|
||||
|
||||
user = models.ForeignKey(User, related_name="identities", on_delete=models.CASCADE)
|
||||
sub = models.CharField(
|
||||
_("sub"),
|
||||
help_text=_(
|
||||
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
||||
),
|
||||
max_length=255,
|
||||
unique=True,
|
||||
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,
|
||||
help_text=_("Designates whether the email is the main one."),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "people_identity"
|
||||
ordering = ("-is_main", "email")
|
||||
verbose_name = _("identity")
|
||||
verbose_name_plural = _("identities")
|
||||
constraints = [
|
||||
# Uniqueness
|
||||
models.UniqueConstraint(
|
||||
fields=["user", "email"],
|
||||
name="unique_user_email",
|
||||
violation_error_message=_(
|
||||
"This email address is already declared for this user."
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
main_str = "[main]" if self.is_main else ""
|
||||
id_str = self.email or self.sub
|
||||
return f"{id_str:s}{main_str:s}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""
|
||||
If it's a new user, give her access to the relevant teams.
|
||||
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:
|
||||
@@ -242,16 +331,9 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def clean(self):
|
||||
"""Validate fields."""
|
||||
super().clean()
|
||||
if self.email:
|
||||
self.email = User.objects.normalize_email(self.email)
|
||||
|
||||
if self.profile_contact_id and not self.profile_contact.owner == self:
|
||||
raise exceptions.ValidationError(
|
||||
"Users can only declare as profile a contact they own."
|
||||
)
|
||||
# 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):
|
||||
"""
|
||||
@@ -272,17 +354,24 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
|
||||
TeamAccess.objects.bulk_create(
|
||||
[
|
||||
TeamAccess(user=self, team=invitation.team, role=invitation.role)
|
||||
TeamAccess(user=self.user, team=invitation.team, role=invitation.role)
|
||||
for invitation in valid_invitations
|
||||
]
|
||||
)
|
||||
valid_invitations.delete()
|
||||
|
||||
def email_user(self, subject, message, from_email=None, **kwargs):
|
||||
"""Email this user."""
|
||||
if not self.email:
|
||||
raise ValueError("You must first set an email for the user.")
|
||||
mail.send_mail(subject, message, from_email, [self.email], **kwargs)
|
||||
def clean(self):
|
||||
"""Normalize the email field and clean the 'is_main' field."""
|
||||
if self.email:
|
||||
self.email = User.objects.normalize_email(self.email)
|
||||
if not self.user.identities.exclude(pk=self.pk).filter(is_main=True).exists():
|
||||
if not self.created_at:
|
||||
self.is_main = True
|
||||
elif not self.is_main:
|
||||
raise exceptions.ValidationError(
|
||||
{"is_main": "A user should have one and only one main identity."}
|
||||
)
|
||||
super().clean()
|
||||
|
||||
|
||||
class Team(BaseModel):
|
||||
@@ -526,8 +615,8 @@ class Invitation(BaseModel):
|
||||
"""Validate fields."""
|
||||
super().clean()
|
||||
|
||||
# Check if a user already exists for the provided email
|
||||
if User.objects.filter(email=self.email).exists():
|
||||
# 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.")}
|
||||
)
|
||||
|
||||
@@ -4,8 +4,9 @@ from django.core.exceptions import SuspiciousOperation
|
||||
|
||||
import pytest
|
||||
|
||||
from core import factories, models
|
||||
from core import models
|
||||
from core.authentication.backends import OIDCAuthenticationBackend
|
||||
from core.factories import IdentityFactory
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -18,19 +19,26 @@ def test_authentication_getter_existing_user_no_email(
|
||||
"""
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
user = factories.UserFactory()
|
||||
|
||||
# Create a user and its identity
|
||||
identity = IdentityFactory(name=None)
|
||||
|
||||
# Create multiple identities for a user
|
||||
for _ in range(5):
|
||||
IdentityFactory(user=identity.user)
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": user.sub}
|
||||
return {"sub": identity.sub}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
with django_assert_num_queries(1):
|
||||
authenticated_user = klass.get_or_create_user(
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
assert user == authenticated_user
|
||||
identity.refresh_from_db()
|
||||
assert user == identity.user
|
||||
|
||||
|
||||
def test_authentication_getter_existing_user_with_email(
|
||||
@@ -40,12 +48,19 @@ def test_authentication_getter_existing_user_with_email(
|
||||
When the user's info contains an email and targets an existing user,
|
||||
"""
|
||||
klass = OIDCAuthenticationBackend()
|
||||
user = factories.UserFactory(name="John Doe")
|
||||
|
||||
identity = IdentityFactory(name="John Doe")
|
||||
|
||||
# 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": user.sub,
|
||||
"email": user.email,
|
||||
"sub": identity.sub,
|
||||
"email": identity.email,
|
||||
"first_name": "John",
|
||||
"last_name": "Doe",
|
||||
}
|
||||
@@ -54,11 +69,11 @@ def test_authentication_getter_existing_user_with_email(
|
||||
|
||||
# Only 1 query because email and names have not changed
|
||||
with django_assert_num_queries(1):
|
||||
authenticated_user = klass.get_or_create_user(
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
assert user == authenticated_user
|
||||
assert models.User.objects.get() == user
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -74,14 +89,23 @@ 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 user when they change.
|
||||
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()
|
||||
user = factories.UserFactory(name="John Doe", email="john.doe@example.com")
|
||||
|
||||
identity = IdentityFactory(name="John Doe", email="john.doe@example.com")
|
||||
user_email = identity.user.admin_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": user.sub,
|
||||
"sub": identity.sub,
|
||||
"email": email,
|
||||
"first_name": first_name,
|
||||
"last_name": last_name,
|
||||
@@ -91,20 +115,23 @@ def test_authentication_getter_existing_user_change_fields(
|
||||
|
||||
# One and only one additional update query when a field has changed
|
||||
with django_assert_num_queries(2):
|
||||
authenticated_user = klass.get_or_create_user(
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
assert user == authenticated_user
|
||||
user.refresh_from_db()
|
||||
assert user.email == email
|
||||
assert user.name == f"{first_name:s} {last_name:s}"
|
||||
identity.refresh_from_db()
|
||||
assert identity.email == email
|
||||
assert identity.name == f"{first_name:s} {last_name:s}"
|
||||
|
||||
assert models.User.objects.count() == 1
|
||||
assert user == identity.user
|
||||
assert user.admin_email == user_email
|
||||
|
||||
|
||||
def test_authentication_getter_new_user_no_email(monkeypatch):
|
||||
"""
|
||||
If no user matches the user's info sub, a user should be created.
|
||||
User's info doesn't contain an email/name, created user's email/name should be empty.
|
||||
User's info doesn't contain an email, created user's email should be empty.
|
||||
"""
|
||||
klass = OIDCAuthenticationBackend()
|
||||
|
||||
@@ -117,9 +144,11 @@ def test_authentication_getter_new_user_no_email(monkeypatch):
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
assert user.sub == "123"
|
||||
assert user.email is None
|
||||
assert user.name is None
|
||||
identity = user.identities.get()
|
||||
assert identity.sub == "123"
|
||||
assert identity.email is None
|
||||
|
||||
assert user.admin_email is None
|
||||
assert user.password == "!"
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
@@ -127,9 +156,11 @@ 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 email and name should be set on the user.
|
||||
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):
|
||||
@@ -141,10 +172,12 @@ def test_authentication_getter_new_user_with_email(monkeypatch):
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
assert user.sub == "123"
|
||||
assert user.email == email
|
||||
assert user.name == "John Doe"
|
||||
assert user.password == "!"
|
||||
identity = user.identities.get()
|
||||
assert identity.sub == "123"
|
||||
assert identity.email == email
|
||||
assert identity.name == "John Doe"
|
||||
|
||||
assert user.admin_email is None
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
|
||||
|
||||
@@ -38,7 +38,8 @@ def test_view_logout_anonymous():
|
||||
def test_view_logout(mocked_oidc_logout_url):
|
||||
"""Authenticated users should be redirected to OIDC provider for logout."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -59,7 +60,8 @@ def test_view_logout(mocked_oidc_logout_url):
|
||||
def test_view_logout_no_oidc_provider(mocked_oidc_logout_url):
|
||||
"""Authenticated users should be logged out when no OIDC provider is available."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -94,7 +96,8 @@ def test_view_logout_callback_anonymous():
|
||||
def test_view_logout_persist_state(initial_oidc_states):
|
||||
"""State value should be persisted in session's data."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
@@ -125,7 +128,8 @@ def test_view_logout_construct_oidc_logout_url(
|
||||
):
|
||||
"""Should construct the logout URL to initiate the logout flow with the OIDC provider."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
@@ -155,7 +159,8 @@ def test_view_logout_construct_oidc_logout_url_none_id_token():
|
||||
"""If no ID token is available in the session,
|
||||
the user should be redirected to the final URL."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
@@ -175,7 +180,8 @@ def test_view_logout_construct_oidc_logout_url_none_id_token():
|
||||
def test_view_logout_callback_wrong_state(initial_state):
|
||||
"""Should raise an error if OIDC state doesn't match session data."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
@@ -201,7 +207,8 @@ def test_view_logout_callback_wrong_state(initial_state):
|
||||
def test_view_logout_callback():
|
||||
"""If state matches, callback should clear OIDC state and redirects."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
request = RequestFactory().get("/logout-callback/", data={"state": "mocked_state"})
|
||||
request.user = user
|
||||
|
||||
@@ -33,7 +33,7 @@ def test_openapi_client_schema():
|
||||
)
|
||||
assert output.getvalue() == ""
|
||||
|
||||
response = Client().get("/api/v1.0/swagger.json")
|
||||
response = Client().get("/v1.0/swagger.json")
|
||||
|
||||
assert response.status_code == 200
|
||||
with open(
|
||||
|
||||
@@ -41,7 +41,9 @@ 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.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
other_user = factories.UserFactory()
|
||||
team = factories.TeamFactory()
|
||||
|
||||
@@ -64,7 +66,9 @@ def test_api_team_accesses_create_authenticated_unrelated():
|
||||
|
||||
def test_api_team_accesses_create_authenticated_member():
|
||||
"""Members of a team should not be allowed to create team accesses."""
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "member")])
|
||||
other_user = factories.UserFactory()
|
||||
|
||||
@@ -92,7 +96,9 @@ def test_api_team_accesses_create_authenticated_administrator():
|
||||
"""
|
||||
Administrators of a team should be able to create team accesses except for the "owner" role.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "administrator")])
|
||||
other_user = factories.UserFactory()
|
||||
|
||||
@@ -143,7 +149,9 @@ def test_api_team_accesses_create_authenticated_owner():
|
||||
"""
|
||||
Owners of a team should be able to create team accesses whatever the role.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "owner")])
|
||||
other_user = factories.UserFactory()
|
||||
|
||||
@@ -219,7 +227,7 @@ def test_api_team_accesses_create_webhook():
|
||||
"value": [
|
||||
{
|
||||
"value": str(other_user.id),
|
||||
"email": other_user.email,
|
||||
"email": None,
|
||||
"type": "User",
|
||||
}
|
||||
],
|
||||
|
||||
@@ -32,7 +32,9 @@ 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.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
access = factories.TeamAccessFactory()
|
||||
|
||||
client = APIClient()
|
||||
@@ -50,7 +52,9 @@ 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.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "member")])
|
||||
access = factories.TeamAccessFactory(team=team)
|
||||
|
||||
@@ -72,7 +76,9 @@ 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.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "administrator")])
|
||||
access = factories.TeamAccessFactory(
|
||||
team=team, role=random.choice(["member", "administrator"])
|
||||
@@ -96,7 +102,9 @@ 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.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "owner")])
|
||||
access = factories.TeamAccessFactory(
|
||||
team=team, role=random.choice(["member", "administrator"])
|
||||
@@ -120,7 +128,9 @@ 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.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "owner")])
|
||||
access = factories.TeamAccessFactory(team=team, role="owner")
|
||||
|
||||
@@ -142,6 +152,7 @@ def test_api_team_accesses_delete_owners_last_owner():
|
||||
It should not be possible to delete the last owner access from a team
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
team = factories.TeamFactory()
|
||||
access = factories.TeamAccessFactory(team=team, user=user, role="owner")
|
||||
assert models.TeamAccess.objects.count() == 1
|
||||
@@ -162,6 +173,7 @@ def test_api_team_accesses_delete_webhook():
|
||||
When the team has a webhook, deleting a team access should fire a call.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "administrator")])
|
||||
webhook = factories.TeamWebhookFactory(team=team)
|
||||
access = factories.TeamAccessFactory(
|
||||
@@ -203,7 +215,7 @@ def test_api_team_accesses_delete_webhook():
|
||||
"value": [
|
||||
{
|
||||
"value": str(access.user.id),
|
||||
"email": access.user.email,
|
||||
"email": None,
|
||||
"type": "User",
|
||||
}
|
||||
],
|
||||
|
||||
@@ -28,7 +28,9 @@ 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.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
team = factories.TeamFactory()
|
||||
factories.TeamAccessFactory.create_batch(3, team=team)
|
||||
|
||||
@@ -55,12 +57,19 @@ 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.
|
||||
"""
|
||||
user, administrator, owner = factories.UserFactory.create_batch(3)
|
||||
identity = factories.IdentityFactory(is_main=True)
|
||||
user = identity.user
|
||||
|
||||
team = factories.TeamFactory()
|
||||
|
||||
access1 = factories.TeamAccessFactory.create(team=team, user=owner, role="owner")
|
||||
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, role="administrator"
|
||||
team=team, user=administrator.user, role="administrator"
|
||||
)
|
||||
|
||||
# Ensure this user's role is different from other team members to test abilities' computation
|
||||
@@ -84,8 +93,8 @@ def test_api_team_accesses_list_authenticated_related():
|
||||
"id": str(user_access.id),
|
||||
"user": {
|
||||
"id": str(user_access.user.id),
|
||||
"email": str(user.email),
|
||||
"name": str(user.name),
|
||||
"email": str(identity.email),
|
||||
"name": str(identity.name),
|
||||
},
|
||||
"role": str(user_access.role),
|
||||
"abilities": user_access.get_abilities(user),
|
||||
@@ -115,152 +124,46 @@ def test_api_team_accesses_list_authenticated_related():
|
||||
)
|
||||
|
||||
|
||||
def test_api_team_accesses_list_find_members(django_assert_num_queries):
|
||||
def test_api_team_accesses_list_authenticated_main_identity():
|
||||
"""
|
||||
Authenticated users should be able to search users access with a case-insensitive and
|
||||
partial query on the name.
|
||||
Name and email should be returned from main identity only
|
||||
"""
|
||||
dave = factories.UserFactory(name="dave bowman", email=None)
|
||||
frank = factories.UserFactory(name="frank poole", email=None)
|
||||
mary = factories.UserFactory(name="mary poole", email=None)
|
||||
nicole = factories.UserFactory(name="nicole foole", email=None)
|
||||
factories.UserFactory(email="tester@ministry.fr", name="john doe")
|
||||
factories.UserFactory(name="heywood floyd", email=None)
|
||||
factories.UserFactory(name="Andrei Smyslov", email=None)
|
||||
factories.TeamFactory.create_batch(10)
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory(user=user, is_main=True)
|
||||
factories.IdentityFactory(user=user) # additional non-main identity
|
||||
|
||||
# Add Mary, Nicole and Dave in the same team
|
||||
team = factories.TeamFactory(
|
||||
name="Odyssey",
|
||||
users=[
|
||||
(mary, models.RoleChoices.OWNER),
|
||||
(nicole, models.RoleChoices.ADMIN),
|
||||
(dave, models.RoleChoices.MEMBER),
|
||||
],
|
||||
)
|
||||
factories.TeamFactory(users=[(frank, models.RoleChoices.MEMBER)])
|
||||
team = factories.TeamFactory()
|
||||
models.TeamAccess.objects.create(team=team, user=user) # random role
|
||||
|
||||
# Search users in the team Odyssey
|
||||
client = APIClient()
|
||||
client.force_login(mary)
|
||||
# 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)
|
||||
|
||||
# 5 queries are needed here:
|
||||
# - 1 query: select on user authenticated
|
||||
# - 4 queries: get all users, owner included (2 more queries)
|
||||
with django_assert_num_queries(5):
|
||||
response = client.get(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/",
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json()["count"] == 3
|
||||
# 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)
|
||||
|
||||
# We can find David Bowman
|
||||
# 3 queries are needed here:
|
||||
# - 1 query: user authenticated
|
||||
# - 2 queries: search user query with match
|
||||
with django_assert_num_queries(3):
|
||||
response = client.get(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/?q=bowman",
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json()["count"] == 1
|
||||
dave_access = dave.accesses.get(team=team)
|
||||
assert response.json()["results"][0]["id"] == str(dave_access.id)
|
||||
|
||||
# We can find Nicole
|
||||
# 3 queries are needed here:
|
||||
# - 1 query: user authenticated
|
||||
# - 2 queries: search user query with match
|
||||
with django_assert_num_queries(3):
|
||||
response = client.get(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/?q=nicol",
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json()["count"] == 1
|
||||
nicole_access = nicole.accesses.get(team=team)
|
||||
assert response.json()["results"][0]["id"] == str(nicole_access.id)
|
||||
|
||||
# We can find Nicole and Mary
|
||||
# 5 queries are needed here:
|
||||
# - 1 query: select on user authenticated
|
||||
# - 4 queries: search user query with match and the owner found (2 more queries)
|
||||
with django_assert_num_queries(5):
|
||||
response = client.get(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/?q=ool",
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json()["count"] == 2
|
||||
user_access_ids = sorted([user["id"] for user in response.json()["results"]])
|
||||
mary_access = mary.accesses.get(team=team)
|
||||
assert sorted([str(mary_access.id), str(nicole_access.id)]) == user_access_ids
|
||||
|
||||
# We cannot find Andrei, because he isn't a member of the current team
|
||||
# 2 queries are needed here:
|
||||
# - 1 query: select on user authenticated
|
||||
# - 1 query: search user query with no match
|
||||
with django_assert_num_queries(2):
|
||||
response = client.get(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/?q=andrei",
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json()["count"] == 0
|
||||
|
||||
# We can find Mary
|
||||
# 5 queries are needed here:
|
||||
# - 1 query: select on user authenticated
|
||||
# - 4 queries: search user query with match and an owner found (2 more queries)
|
||||
with django_assert_num_queries(5):
|
||||
response = client.get(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/?q=mary",
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json()["count"] == 1
|
||||
assert response.json()["results"][0]["id"] == str(mary_access.id)
|
||||
|
||||
|
||||
def test_api_team_accesses__list_find_members_by_email():
|
||||
"""
|
||||
Authenticated users should be able to search users access with a case-insensitive and
|
||||
partial query on the email.
|
||||
"""
|
||||
user = factories.UserFactory(name=None)
|
||||
|
||||
# set all names to None to match only on emails
|
||||
colleague1 = factories.UserFactory(name=None, email="prudence_crandall@edu.us")
|
||||
colleague2 = factories.UserFactory(name=None, email="reinebrunehaut@gouv.fr")
|
||||
colleague3 = factories.UserFactory(name=None, email="artemisia.gentileschi@arte.it")
|
||||
|
||||
# Add all colleague in the same team
|
||||
team = factories.TeamFactory(
|
||||
users=[
|
||||
(user, models.RoleChoices.ADMIN),
|
||||
(colleague1, models.RoleChoices.OWNER),
|
||||
(colleague2, models.RoleChoices.ADMIN),
|
||||
(colleague3, models.RoleChoices.MEMBER),
|
||||
],
|
||||
)
|
||||
factories.TeamAccessFactory.create_batch(4)
|
||||
|
||||
# Create another team with similar email
|
||||
factories.TeamFactory(
|
||||
users=[
|
||||
(
|
||||
factories.UserFactory(name=None, email="bruneau@gouv.fr"),
|
||||
models.RoleChoices.ADMIN,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
# Search users in the team Odyssey
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/?q=BRUNE",
|
||||
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)),
|
||||
]
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json()["count"] == 1
|
||||
assert response.json()["results"][0]["user"]["email"] == "reinebrunehaut@gouv.fr"
|
||||
|
||||
|
||||
def test_api_team_accesses_list_authenticated_constant_numqueries(
|
||||
@@ -270,28 +173,31 @@ def test_api_team_accesses_list_authenticated_constant_numqueries(
|
||||
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 3 queries are needed to efficiently fetch team accesses,
|
||||
# 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(3):
|
||||
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.UserFactory()
|
||||
extra_user = factories.IdentityFactory(is_main=True).user
|
||||
factories.TeamAccessFactory(team=team, user=extra_user)
|
||||
|
||||
# num queries should still be the same
|
||||
with django_assert_num_queries(3):
|
||||
# num queries should still be 4
|
||||
with django_assert_num_queries(4):
|
||||
response = client.get(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/",
|
||||
)
|
||||
@@ -304,12 +210,14 @@ 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.UserFactory()
|
||||
extra_user = factories.IdentityFactory(is_main=True).user
|
||||
factories.TeamAccessFactory(team=team, user=extra_user)
|
||||
|
||||
client = APIClient()
|
||||
@@ -334,24 +242,26 @@ def test_api_team_accesses_list_authenticated_ordering():
|
||||
assert sorted(results, reverse=True) == results
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ordering_field", ["email", "name"])
|
||||
def test_api_team_accesses_list_authenticated_ordering_user(ordering_field):
|
||||
"""Team accesses can be ordered by user's fields."""
|
||||
@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.UserFactory()
|
||||
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=user__{ordering_field}",
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/?ordering={ordering_fields}",
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json()["count"] == 21
|
||||
@@ -361,7 +271,19 @@ def test_api_team_accesses_list_authenticated_ordering_user(ordering_field):
|
||||
return x.casefold().replace(" ", "")
|
||||
|
||||
results = [
|
||||
team_access["user"][ordering_field]
|
||||
team_access["user"][ordering_fields]
|
||||
for team_access in response.json()["results"]
|
||||
]
|
||||
assert sorted(results, key=normalize) == 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, key=normalize) == results
|
||||
|
||||
@@ -31,7 +31,9 @@ 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.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
access = factories.TeamAccessFactory(team=factories.TeamFactory())
|
||||
|
||||
client = APIClient()
|
||||
@@ -60,7 +62,9 @@ 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.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory(is_main=True)
|
||||
user = identity.user
|
||||
|
||||
team = factories.TeamFactory()
|
||||
access = factories.TeamAccessFactory(team=team, user=user)
|
||||
|
||||
@@ -75,8 +79,8 @@ def test_api_team_accesses_retrieve_authenticated_related():
|
||||
"id": str(access.id),
|
||||
"user": {
|
||||
"id": str(access.user.id),
|
||||
"email": str(user.email),
|
||||
"name": str(user.name),
|
||||
"email": str(identity.email),
|
||||
"name": str(identity.name),
|
||||
},
|
||||
"role": str(access.role),
|
||||
"abilities": access.get_abilities(user),
|
||||
|
||||
@@ -43,7 +43,9 @@ 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.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
access = factories.TeamAccessFactory()
|
||||
old_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
|
||||
@@ -70,7 +72,9 @@ def test_api_team_accesses_update_authenticated_unrelated():
|
||||
|
||||
def test_api_team_accesses_update_authenticated_member():
|
||||
"""Members of a team should not be allowed to update its accesses."""
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "member")])
|
||||
access = factories.TeamAccessFactory(team=team)
|
||||
old_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
@@ -101,7 +105,9 @@ 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.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "administrator")])
|
||||
access = factories.TeamAccessFactory(
|
||||
team=team,
|
||||
@@ -145,7 +151,9 @@ 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.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
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")
|
||||
@@ -176,7 +184,9 @@ 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.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "administrator")])
|
||||
other_user = factories.UserFactory()
|
||||
access = factories.TeamAccessFactory(
|
||||
@@ -217,7 +227,9 @@ 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.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "owner")])
|
||||
factories.UserFactory()
|
||||
access = factories.TeamAccessFactory(
|
||||
@@ -263,7 +275,9 @@ 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.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
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
|
||||
@@ -293,7 +307,9 @@ 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.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
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
|
||||
|
||||
@@ -10,7 +10,7 @@ from rest_framework.status import (
|
||||
)
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core.factories import TeamFactory, UserFactory
|
||||
from core.factories import IdentityFactory, TeamFactory
|
||||
from core.models import Team
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
@@ -34,10 +34,11 @@ def test_api_teams_create_authenticated():
|
||||
Authenticated users should be able to create teams and should automatically be declared
|
||||
as the owner of the newly created team.
|
||||
"""
|
||||
user = UserFactory()
|
||||
identity = IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
client.force_login(identity.user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/teams/",
|
||||
@@ -57,9 +58,9 @@ def test_api_teams_create_authenticated_slugify_name():
|
||||
"""
|
||||
Creating teams should automatically generate a slug.
|
||||
"""
|
||||
user = UserFactory()
|
||||
identity = IdentityFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
client.force_login(identity.user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/teams/",
|
||||
@@ -86,10 +87,10 @@ def test_api_teams_create_authenticated_expected_slug(param):
|
||||
"""
|
||||
Creating teams should automatically create unaccented, no unicode, lower-case slug.
|
||||
"""
|
||||
user = UserFactory()
|
||||
identity = IdentityFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
client.force_login(identity.user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/teams/",
|
||||
@@ -109,10 +110,10 @@ def test_api_teams_create_authenticated_unique_slugs():
|
||||
Creating teams should raise an error if already existing slug.
|
||||
"""
|
||||
TeamFactory(name="existing team")
|
||||
user = UserFactory()
|
||||
identity = IdentityFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
client.force_login(identity.user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/teams/",
|
||||
|
||||
@@ -33,10 +33,10 @@ def test_api_teams_delete_authenticated_unrelated():
|
||||
Authenticated users should not be allowed to delete a team to which they are not
|
||||
related.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
client.force_login(identity.user)
|
||||
|
||||
team = factories.TeamFactory()
|
||||
|
||||
@@ -54,7 +54,8 @@ def test_api_teams_delete_authenticated_member():
|
||||
Authenticated users should not be allowed to delete a team for which they are
|
||||
only a member.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -77,7 +78,8 @@ def test_api_teams_delete_authenticated_administrator():
|
||||
Authenticated users should not be allowed to delete a team for which they are
|
||||
administrator.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -100,7 +102,8 @@ def test_api_teams_delete_authenticated_owner():
|
||||
Authenticated users should be able to delete a team for which they are directly
|
||||
owner.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
@@ -31,7 +31,8 @@ def test_api_teams_list_authenticated():
|
||||
Authenticated users should be able to list teams
|
||||
they are an owner/administrator/member of.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -58,7 +59,8 @@ def test_api_teams_list_pagination(
|
||||
_mock_page_size,
|
||||
):
|
||||
"""Pagination should work as expected."""
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -103,7 +105,8 @@ def test_api_teams_list_pagination(
|
||||
|
||||
def test_api_teams_list_authenticated_distinct():
|
||||
"""A team with several related users should only be listed once."""
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -126,7 +129,8 @@ def test_api_teams_order():
|
||||
"""
|
||||
Test that the endpoint GET teams is sorted in 'created_at' descending order by default.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -155,7 +159,8 @@ def test_api_teams_order_param():
|
||||
Test that the 'created_at' field is sorted in ascending order
|
||||
when the 'ordering' query parameter is set.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
@@ -27,10 +27,10 @@ def test_api_teams_retrieve_authenticated_unrelated():
|
||||
Authenticated users should not be allowed to retrieve a team to which they are
|
||||
not related.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
client.force_login(identity.user)
|
||||
|
||||
team = factories.TeamFactory()
|
||||
|
||||
@@ -46,7 +46,8 @@ def test_api_teams_retrieve_authenticated_related():
|
||||
Authenticated users should be allowed to retrieve a team to which they
|
||||
are related whatever the role.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
@@ -45,10 +45,10 @@ def test_api_teams_update_authenticated_unrelated():
|
||||
"""
|
||||
Authenticated users should not be allowed to update a team to which they are not related.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
client.force_login(identity.user)
|
||||
|
||||
team = factories.TeamFactory()
|
||||
old_team_values = serializers.TeamSerializer(instance=team).data
|
||||
@@ -73,7 +73,8 @@ def test_api_teams_update_authenticated_members():
|
||||
Users who are members of a team but not administrators should
|
||||
not be allowed to update it.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -100,7 +101,8 @@ def test_api_teams_update_authenticated_members():
|
||||
|
||||
def test_api_teams_update_authenticated_administrators():
|
||||
"""Administrators of a team should be allowed to update it."""
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -132,7 +134,8 @@ def test_api_teams_update_authenticated_administrators():
|
||||
def test_api_teams_update_authenticated_owners():
|
||||
"""Administrators of a team should be allowed to update it,
|
||||
apart from read-only fields."""
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -167,7 +170,8 @@ def test_api_teams_update_administrator_or_owner_of_another():
|
||||
Being administrator or owner of a team should not grant authorization to update
|
||||
another team.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -196,7 +200,8 @@ def test_api_teams_update_existing_slug_should_return_error():
|
||||
Updating a team's name to an existing slug should return a bad request,
|
||||
instead of creating a duplicate.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
@@ -69,7 +69,8 @@ def test_api_contacts_list_authenticated_no_query():
|
||||
Authenticated users should be able to list contacts without applying a query.
|
||||
Profile and base contacts should be excluded.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
contact = factories.ContactFactory(owner=user)
|
||||
user.profile_contact = contact
|
||||
user.save()
|
||||
@@ -107,7 +108,8 @@ def test_api_contacts_list_authenticated_by_full_name():
|
||||
Authenticated users should be able to search users with a case insensitive and
|
||||
partial query on the full name.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
dave = factories.BaseContactFactory(full_name="David Bowman")
|
||||
nicole = factories.BaseContactFactory(full_name="Nicole Foole")
|
||||
@@ -148,7 +150,8 @@ def test_api_contacts_list_authenticated_by_full_name():
|
||||
|
||||
def test_api_contacts_list_authenticated_uppercase_content():
|
||||
"""Upper case content should be found by lower case query."""
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
dave = factories.BaseContactFactory(full_name="EEE", short_name="AAA")
|
||||
|
||||
@@ -172,7 +175,8 @@ def test_api_contacts_list_authenticated_uppercase_content():
|
||||
|
||||
def test_api_contacts_list_authenticated_capital_query():
|
||||
"""Upper case query should find lower case content."""
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
dave = factories.BaseContactFactory(full_name="eee", short_name="aaa")
|
||||
|
||||
@@ -196,7 +200,8 @@ def test_api_contacts_list_authenticated_capital_query():
|
||||
|
||||
def test_api_contacts_list_authenticated_accented_content():
|
||||
"""Accented content should be found by unaccented query."""
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
dave = factories.BaseContactFactory(full_name="ééé", short_name="ààà")
|
||||
|
||||
@@ -220,7 +225,8 @@ def test_api_contacts_list_authenticated_accented_content():
|
||||
|
||||
def test_api_contacts_list_authenticated_accented_query():
|
||||
"""Accented query should find unaccented content."""
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
dave = factories.BaseContactFactory(full_name="eee", short_name="aaa")
|
||||
|
||||
@@ -258,7 +264,9 @@ def test_api_contacts_retrieve_authenticated_owned():
|
||||
"""
|
||||
Authenticated users should be allowed to retrieve a contact they own.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
contact = factories.ContactFactory(owner=user)
|
||||
|
||||
client = APIClient()
|
||||
@@ -281,11 +289,11 @@ def test_api_contacts_retrieve_authenticated_public():
|
||||
"""
|
||||
Authenticated users should be able to retrieve public contacts.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
contact = factories.BaseContactFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
client.force_login(identity.user)
|
||||
|
||||
response = client.get(f"/api/v1.0/contacts/{contact.id!s}/")
|
||||
assert response.status_code == 200
|
||||
@@ -303,11 +311,11 @@ def test_api_contacts_retrieve_authenticated_other():
|
||||
"""
|
||||
Authenticated users should not be allowed to retrieve another user's contacts.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
contact = factories.ContactFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
client.force_login(identity.user)
|
||||
|
||||
response = client.get(f"/api/v1.0/contacts/{contact.id!s}/")
|
||||
assert response.status_code == 403
|
||||
@@ -331,10 +339,10 @@ def test_api_contacts_create_anonymous_forbidden():
|
||||
|
||||
def test_api_contacts_create_authenticated_missing_base():
|
||||
"""Anonymous users should be able to create users."""
|
||||
user = factories.UserFactory(profile_contact=None)
|
||||
identity = factories.IdentityFactory(user__profile_contact=None)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
client.force_login(identity.user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/contacts/",
|
||||
@@ -352,7 +360,8 @@ def test_api_contacts_create_authenticated_missing_base():
|
||||
|
||||
def test_api_contacts_create_authenticated_successful():
|
||||
"""Authenticated users should be able to create contacts."""
|
||||
user = factories.UserFactory(profile_contact=None)
|
||||
identity = factories.IdentityFactory(user__profile_contact=None)
|
||||
user = identity.user
|
||||
base_contact = factories.BaseContactFactory()
|
||||
|
||||
client = APIClient()
|
||||
@@ -398,7 +407,9 @@ def test_api_contacts_create_authenticated_existing_override():
|
||||
Trying to create a contact for base contact that is already overriden by the user
|
||||
should receive a 400 error.
|
||||
"""
|
||||
user = factories.UserFactory(profile_contact=None)
|
||||
identity = factories.IdentityFactory(user__profile_contact=None)
|
||||
user = identity.user
|
||||
|
||||
base_contact = factories.BaseContactFactory()
|
||||
factories.ContactFactory(base=base_contact, owner=user)
|
||||
|
||||
@@ -452,7 +463,8 @@ def test_api_contacts_update_authenticated_owned():
|
||||
"""
|
||||
Authenticated users should be allowed to update their own contacts.
|
||||
"""
|
||||
user = factories.UserFactory(profile_contact=None)
|
||||
identity = factories.IdentityFactory(user__profile_contact=None)
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -486,7 +498,8 @@ def test_api_contacts_update_authenticated_profile():
|
||||
"""
|
||||
Authenticated users should be allowed to update their profile contact.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -521,7 +534,8 @@ def test_api_contacts_update_authenticated_other():
|
||||
"""
|
||||
Authenticated users should not be allowed to update contacts owned by other users.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -559,7 +573,8 @@ def test_api_contacts_delete_list_anonymous():
|
||||
|
||||
def test_api_contacts_delete_list_authenticated():
|
||||
"""Authenticated users should not be allowed to delete a list of contacts."""
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -587,7 +602,8 @@ def test_api_contacts_delete_authenticated_public():
|
||||
"""
|
||||
Authenticated users should not be allowed to delete a public contact.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -606,7 +622,8 @@ def test_api_contacts_delete_authenticated_owner():
|
||||
"""
|
||||
Authenticated users should be allowed to delete a contact they own.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
contact = factories.ContactFactory(owner=user)
|
||||
|
||||
client = APIClient()
|
||||
@@ -625,7 +642,8 @@ def test_api_contacts_delete_authenticated_profile():
|
||||
"""
|
||||
Authenticated users should be allowed to delete their profile contact.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
contact = factories.ContactFactory(owner=user, base=None)
|
||||
user.profile_contact = contact
|
||||
user.save()
|
||||
@@ -645,7 +663,8 @@ def test_api_contacts_delete_authenticated_other():
|
||||
"""
|
||||
Authenticated users should not be allowed to delete a contact they don't own.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
contact = factories.ContactFactory()
|
||||
|
||||
client = APIClient()
|
||||
|
||||
@@ -34,15 +34,15 @@ def test_api_team_invitations__create__anonymous():
|
||||
|
||||
def test_api_team_invitations__create__authenticated_outsider():
|
||||
"""Users outside of team should not be permitted to invite to team."""
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
|
||||
team = factories.TeamFactory()
|
||||
invitation_values = serializers.InvitationSerializer(
|
||||
factories.InvitationFactory.build()
|
||||
).data
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
client.force_login(identity.user)
|
||||
response = client.post(
|
||||
f"/api/v1.0/teams/{team.id}/invitations/",
|
||||
invitation_values,
|
||||
@@ -57,16 +57,16 @@ def test_api_team_invitations__create__authenticated_outsider():
|
||||
)
|
||||
def test_api_team_invitations__create__privileged_members(role):
|
||||
"""Owners and administrators should be able to invite new members."""
|
||||
user = factories.UserFactory()
|
||||
team = factories.TeamFactory(users=[(user, role)])
|
||||
identity = factories.IdentityFactory()
|
||||
|
||||
team = factories.TeamFactory(users=[(identity.user, role)])
|
||||
|
||||
invitation_values = serializers.InvitationSerializer(
|
||||
factories.InvitationFactory.build()
|
||||
).data
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
client.force_login(identity.user)
|
||||
response = client.post(
|
||||
f"/api/v1.0/teams/{team.id}/invitations/",
|
||||
invitation_values,
|
||||
@@ -79,16 +79,16 @@ def test_api_team_invitations__create__members():
|
||||
"""
|
||||
Members should not be able to invite new members.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
team = factories.TeamFactory(users=[(user, "member")])
|
||||
identity = factories.IdentityFactory()
|
||||
|
||||
team = factories.TeamFactory(users=[(identity.user, "member")])
|
||||
|
||||
invitation_values = serializers.InvitationSerializer(
|
||||
factories.InvitationFactory.build()
|
||||
).data
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
client.force_login(identity.user)
|
||||
response = client.post(
|
||||
f"/api/v1.0/teams/{team.id}/invitations/",
|
||||
invitation_values,
|
||||
@@ -102,16 +102,16 @@ def test_api_team_invitations__create__members():
|
||||
|
||||
def test_api_team_invitations__create__issuer_and_team_automatically_added():
|
||||
"""Team and issuer fields should auto-complete."""
|
||||
user = factories.UserFactory()
|
||||
team = factories.TeamFactory(users=[(user, "owner")])
|
||||
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(user)
|
||||
|
||||
client.force_login(identity.user)
|
||||
response = client.post(
|
||||
f"/api/v1.0/teams/{team.id}/invitations/",
|
||||
invitation_data,
|
||||
@@ -120,7 +120,7 @@ def test_api_team_invitations__create__issuer_and_team_automatically_added():
|
||||
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(user.id)
|
||||
assert response.json()["issuer"] == str(identity.user.id)
|
||||
|
||||
|
||||
def test_api_team_invitations__create__cannot_duplicate_invitation():
|
||||
@@ -129,8 +129,8 @@ def test_api_team_invitations__create__cannot_duplicate_invitation():
|
||||
team = existing_invitation.team
|
||||
|
||||
# Grant privileged role on the Team to the user
|
||||
user = factories.UserFactory()
|
||||
factories.TeamAccessFactory(team=team, user=user, role="administrator")
|
||||
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(
|
||||
@@ -138,7 +138,7 @@ def test_api_team_invitations__create__cannot_duplicate_invitation():
|
||||
).data
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
client.force_login(identity.user)
|
||||
response = client.post(
|
||||
f"/api/v1.0/teams/{team.id}/invitations/",
|
||||
duplicated_invitation,
|
||||
@@ -154,9 +154,11 @@ def test_api_team_invitations__create__cannot_invite_existing_users():
|
||||
"""
|
||||
Should not be able to invite already existing users.
|
||||
"""
|
||||
user, existing_user = factories.UserFactory.create_batch(2)
|
||||
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)
|
||||
@@ -164,7 +166,6 @@ def test_api_team_invitations__create__cannot_invite_existing_users():
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/teams/{team.id}/invitations/",
|
||||
invitation_values,
|
||||
@@ -189,10 +190,14 @@ def test_api_team_invitations__list__authenticated():
|
||||
Authenticated user should be able to list invitations
|
||||
in teams they belong to, including from other issuers.
|
||||
"""
|
||||
user, other_user = factories.UserFactory.create_batch(2)
|
||||
team = factories.TeamFactory(users=[(user, "administrator"), (other_user, "owner")])
|
||||
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=user
|
||||
team=team, role="administrator", issuer=identity.user
|
||||
)
|
||||
other_invitations = factories.InvitationFactory.create_batch(
|
||||
2, team=team, role="member", issuer=other_user
|
||||
@@ -203,7 +208,7 @@ def test_api_team_invitations__list__authenticated():
|
||||
factories.InvitationFactory.create_batch(2, team=other_team, role="member")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
client.force_login(identity.user)
|
||||
response = client.get(
|
||||
f"/api/v1.0/teams/{team.id}/invitations/",
|
||||
)
|
||||
@@ -230,22 +235,24 @@ def test_api_team_invitations__list__expired_invitations_still_listed(settings):
|
||||
"""
|
||||
Expired invitations are still listed.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
other_user = factories.UserFactory()
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "administrator"), (other_user, "owner")])
|
||||
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=user,
|
||||
issuer=identity.user,
|
||||
)
|
||||
time.sleep(1)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
client.force_login(identity.user)
|
||||
response = client.get(
|
||||
f"/api/v1.0/teams/{team.id}/invitations/",
|
||||
)
|
||||
@@ -286,12 +293,12 @@ def test_api_team_invitations__retrieve__unrelated_user():
|
||||
"""
|
||||
Authenticated unrelated users should not be able to retrieve invitations.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
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}/",
|
||||
)
|
||||
@@ -304,7 +311,8 @@ 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.UserFactory()
|
||||
user = factories.IdentityFactory(user=factories.UserFactory()).user
|
||||
|
||||
invitation = factories.InvitationFactory()
|
||||
factories.TeamAccessFactory(team=invitation.team, user=user, role="member")
|
||||
|
||||
@@ -334,7 +342,8 @@ def test_api_team_invitations__update__forbidden(method):
|
||||
"""
|
||||
Update of invitations is currently forbidden.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
user = factories.IdentityFactory(user=factories.UserFactory()).user
|
||||
|
||||
invitation = factories.InvitationFactory()
|
||||
factories.TeamAccessFactory(team=invitation.team, user=user, role="owner")
|
||||
|
||||
@@ -367,12 +376,13 @@ def test_api_team_invitations__delete__anonymous():
|
||||
|
||||
def test_api_team_invitations__delete__authenticated_outsider():
|
||||
"""Members outside of team should not cancel invitations."""
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
|
||||
team = factories.TeamFactory()
|
||||
invitation = factories.InvitationFactory(team=team)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
client.force_login(identity.user)
|
||||
response = client.delete(
|
||||
f"/api/v1.0/teams/{team.id}/invitations/{invitation.id}/",
|
||||
)
|
||||
@@ -382,12 +392,13 @@ def test_api_team_invitations__delete__authenticated_outsider():
|
||||
@pytest.mark.parametrize("role", ["owner", "administrator"])
|
||||
def test_api_team_invitations__delete__privileged_members(role):
|
||||
"""Privileged member should be able to cancel invitation."""
|
||||
user = factories.UserFactory()
|
||||
team = factories.TeamFactory(users=[(user, role)])
|
||||
identity = factories.IdentityFactory()
|
||||
|
||||
team = factories.TeamFactory(users=[(identity.user, role)])
|
||||
invitation = factories.InvitationFactory(team=team)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
client.force_login(identity.user)
|
||||
response = client.delete(
|
||||
f"/api/v1.0/teams/{team.id}/invitations/{invitation.id}/",
|
||||
)
|
||||
@@ -396,12 +407,13 @@ def test_api_team_invitations__delete__privileged_members(role):
|
||||
|
||||
def test_api_team_invitations__delete__members():
|
||||
"""Member should not be able to cancel invitation."""
|
||||
user = factories.UserFactory()
|
||||
team = factories.TeamFactory(users=[(user, "member")])
|
||||
identity = factories.IdentityFactory()
|
||||
|
||||
team = factories.TeamFactory(users=[(identity.user, "member")])
|
||||
invitation = factories.InvitationFactory(team=team)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
client.force_login(identity.user)
|
||||
response = client.delete(
|
||||
f"/api/v1.0/teams/{team.id}/invitations/{invitation.id}/",
|
||||
)
|
||||
|
||||
@@ -35,10 +35,10 @@ def test_api_users_list_authenticated():
|
||||
"""
|
||||
Authenticated users should be able to list all users.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
client.force_login(identity.user)
|
||||
|
||||
factories.UserFactory.create_batch(2)
|
||||
response = client.get(
|
||||
@@ -53,15 +53,23 @@ def test_api_users_authenticated_list_by_email():
|
||||
Authenticated users should be able to search users with a case-insensitive and
|
||||
partial query on the email.
|
||||
"""
|
||||
user = factories.UserFactory(email="tester@ministry.fr", name="john doe")
|
||||
dave = factories.UserFactory(email="david.bowman@work.com", name=None)
|
||||
nicole = factories.UserFactory(email="nicole_foole@work.com", name=None)
|
||||
frank = factories.UserFactory(email="frank_poole@work.com", name=None)
|
||||
factories.UserFactory(email="heywood_floyd@work.com", name=None)
|
||||
user = factories.UserFactory(admin_email="tester@ministry.fr")
|
||||
factories.IdentityFactory(user=user, email=user.admin_email, name="john doe")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
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(
|
||||
"/api/v1.0/users/?q=david.bowman@work.com",
|
||||
@@ -69,14 +77,14 @@ def test_api_users_authenticated_list_by_email():
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
assert user_ids[0] == str(dave.id)
|
||||
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.id)
|
||||
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")
|
||||
@@ -84,7 +92,7 @@ def test_api_users_authenticated_list_by_email():
|
||||
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.id), str(frank.id)]
|
||||
assert user_ids == [str(nicole.user.id), str(frank.user.id)]
|
||||
|
||||
# Even with a low similarity threshold, query should match expected emails
|
||||
response = client.get("/api/v1.0/users/?q=ool")
|
||||
@@ -92,22 +100,22 @@ def test_api_users_authenticated_list_by_email():
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json()["results"] == [
|
||||
{
|
||||
"id": str(nicole.id),
|
||||
"id": str(nicole.user.id),
|
||||
"email": nicole.email,
|
||||
"name": nicole.name,
|
||||
"is_device": nicole.is_device,
|
||||
"is_staff": nicole.is_staff,
|
||||
"language": nicole.language,
|
||||
"timezone": str(nicole.timezone),
|
||||
"is_device": nicole.user.is_device,
|
||||
"is_staff": nicole.user.is_staff,
|
||||
"language": nicole.user.language,
|
||||
"timezone": str(nicole.user.timezone),
|
||||
},
|
||||
{
|
||||
"id": str(frank.id),
|
||||
"id": str(frank.user.id),
|
||||
"email": frank.email,
|
||||
"name": frank.name,
|
||||
"is_device": frank.is_device,
|
||||
"is_staff": frank.is_staff,
|
||||
"language": frank.language,
|
||||
"timezone": str(frank.timezone),
|
||||
"is_device": frank.user.is_device,
|
||||
"is_staff": frank.user.is_staff,
|
||||
"language": frank.user.language,
|
||||
"timezone": str(frank.user.timezone),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -117,15 +125,17 @@ 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", name="john doe")
|
||||
dave = factories.UserFactory(name="dave bowman", email=None)
|
||||
nicole = factories.UserFactory(name="nicole foole", email=None)
|
||||
frank = factories.UserFactory(name="frank poole", email=None)
|
||||
factories.UserFactory(name="heywood floyd", email=None)
|
||||
user = factories.UserFactory(admin_email="tester@ministry.fr")
|
||||
factories.IdentityFactory(user=user, email=user.admin_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",
|
||||
@@ -133,14 +143,14 @@ def test_api_users_authenticated_list_by_name():
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
assert user_ids[0] == str(dave.id)
|
||||
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.id)
|
||||
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")
|
||||
@@ -148,7 +158,7 @@ def test_api_users_authenticated_list_by_name():
|
||||
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.id), str(frank.id)]
|
||||
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")
|
||||
@@ -156,22 +166,22 @@ def test_api_users_authenticated_list_by_name():
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json()["results"] == [
|
||||
{
|
||||
"id": str(nicole.id),
|
||||
"id": str(nicole.user.id),
|
||||
"email": nicole.email,
|
||||
"name": nicole.name,
|
||||
"is_device": nicole.is_device,
|
||||
"is_staff": nicole.is_staff,
|
||||
"language": nicole.language,
|
||||
"timezone": str(nicole.timezone),
|
||||
"is_device": nicole.user.is_device,
|
||||
"is_staff": nicole.user.is_staff,
|
||||
"language": nicole.user.language,
|
||||
"timezone": str(nicole.user.timezone),
|
||||
},
|
||||
{
|
||||
"id": str(frank.id),
|
||||
"id": str(frank.user.id),
|
||||
"email": frank.email,
|
||||
"name": frank.name,
|
||||
"is_device": frank.is_device,
|
||||
"is_staff": frank.is_staff,
|
||||
"language": frank.language,
|
||||
"timezone": str(frank.timezone),
|
||||
"is_device": frank.user.is_device,
|
||||
"is_staff": frank.user.is_staff,
|
||||
"language": frank.user.language,
|
||||
"timezone": str(frank.user.timezone),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -182,14 +192,20 @@ def test_api_users_authenticated_list_by_name_and_email():
|
||||
partial query on the name and email.
|
||||
"""
|
||||
|
||||
user = factories.UserFactory(email="tester@ministry.fr", name="john doe")
|
||||
nicole = factories.UserFactory(email="nicole_foole@work.com", name="nicole foole")
|
||||
frank = factories.UserFactory(email="oleg_poole@work.com", name=None)
|
||||
david = factories.UserFactory(email=None, name="david role")
|
||||
user = factories.UserFactory(admin_email="tester@ministry.fr")
|
||||
factories.IdentityFactory(user=user, email=user.admin_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")
|
||||
|
||||
@@ -199,7 +215,7 @@ def test_api_users_authenticated_list_by_name_and_email():
|
||||
# "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.id), str(frank.id), str(david.id)]
|
||||
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(
|
||||
@@ -209,26 +225,27 @@ def test_api_users_authenticated_list_exclude_users_already_in_team(
|
||||
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", name="john doe")
|
||||
dave = factories.UserFactory(name="dave bowman", email=None)
|
||||
nicole = factories.UserFactory(name="nicole foole", email=None)
|
||||
frank = factories.UserFactory(name="frank poole", email=None)
|
||||
mary = factories.UserFactory(name="mary poole", email=None)
|
||||
factories.UserFactory(name="heywood floyd", email=None)
|
||||
factories.UserFactory(name="Andrei Smyslov", email=None)
|
||||
factories.TeamFactory.create_batch(10)
|
||||
|
||||
user = factories.UserFactory(admin_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, models.RoleChoices.MEMBER),
|
||||
(frank, models.RoleChoices.MEMBER),
|
||||
(dave.user, models.RoleChoices.MEMBER),
|
||||
(frank.user, models.RoleChoices.MEMBER),
|
||||
]
|
||||
)
|
||||
factories.TeamFactory(users=[(nicole, 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
|
||||
@@ -249,25 +266,118 @@ def test_api_users_authenticated_list_exclude_users_already_in_team(
|
||||
# - user authenticated
|
||||
# - search user query
|
||||
# - User
|
||||
with django_assert_num_queries(3):
|
||||
# - 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.id), str(nicole.id)])
|
||||
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(admin_email="tester@ministry.fr")
|
||||
factories.IdentityFactory(user=user, email=user.admin_email, name="eva karl")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
dave = factories.UserFactory()
|
||||
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(
|
||||
"/api/v1.0/users/?q=david.bowman@work.com",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
# A single user is returned, despite similarity matching both emails
|
||||
assert response.json()["count"] == 1
|
||||
assert response.json()["results"][0]["id"] == str(dave.id)
|
||||
|
||||
|
||||
def test_api_users_authenticated_list_multiple_identities_multiple_users():
|
||||
"""
|
||||
User with multiple identities should be ranked
|
||||
on their best matching identity.
|
||||
"""
|
||||
user = factories.UserFactory(admin_email="tester@ministry.fr")
|
||||
factories.IdentityFactory(user=user, email=user.admin_email, name="john doe")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
dave = factories.UserFactory()
|
||||
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(
|
||||
"/api/v1.0/users/?q=david.bowman@work.com",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json()["count"] == 3
|
||||
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", name="eva karl")
|
||||
dave = factories.UserFactory(
|
||||
email="DAVID.BOWMAN@INTENSEWORK.COM", name="DAVID BOWMAN"
|
||||
)
|
||||
user = factories.UserFactory(admin_email="tester@ministry.fr")
|
||||
factories.IdentityFactory(user=user, email=user.admin_email, name="eva karl")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
dave = factories.IdentityFactory(
|
||||
email="DAVID.BOWMAN@INTENSEWORK.COM", name="DAVID BOWMAN"
|
||||
)
|
||||
|
||||
# Unaccented full address
|
||||
response = client.get(
|
||||
"/api/v1.0/users/?q=david.bowman@intensework.com",
|
||||
@@ -275,7 +385,7 @@ def test_api_users_authenticated_list_uppercase_content():
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
assert user_ids == [str(dave.id)]
|
||||
assert user_ids == [str(dave.user.id)]
|
||||
|
||||
# Partial query
|
||||
response = client.get(
|
||||
@@ -284,17 +394,19 @@ def test_api_users_authenticated_list_uppercase_content():
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
assert user_ids == [str(dave.id)]
|
||||
assert user_ids == [str(dave.user.id)]
|
||||
|
||||
|
||||
def test_api_users_list_authenticated_capital_query():
|
||||
"""Upper case query should find lower case content."""
|
||||
user = factories.UserFactory(email="tester@ministry.fr", name="eva karl")
|
||||
dave = factories.UserFactory(email="david.bowman@work.com", name="david bowman")
|
||||
user = factories.UserFactory(admin_email="tester@ministry.fr")
|
||||
factories.IdentityFactory(user=user, email=user.admin_email, name="eva karl")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
dave = factories.IdentityFactory(email="david.bowman@work.com", name="david bowman")
|
||||
|
||||
# Full uppercase query
|
||||
response = client.get(
|
||||
"/api/v1.0/users/?q=DAVID.BOWMAN@WORK.COM",
|
||||
@@ -302,7 +414,7 @@ def test_api_users_list_authenticated_capital_query():
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
assert user_ids == [str(dave.id)]
|
||||
assert user_ids == [str(dave.user.id)]
|
||||
|
||||
# Partial uppercase email
|
||||
response = client.get(
|
||||
@@ -311,17 +423,21 @@ def test_api_users_list_authenticated_capital_query():
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
assert user_ids == [str(dave.id)]
|
||||
assert user_ids == [str(dave.user.id)]
|
||||
|
||||
|
||||
def test_api_contacts_list_authenticated_accented_query():
|
||||
"""Accented content should be found by unaccented query."""
|
||||
user = factories.UserFactory(email="tester@ministry.fr", name="john doe")
|
||||
helene = factories.UserFactory(email="helene.bowman@work.com", name="helene bowman")
|
||||
user = factories.UserFactory(admin_email="tester@ministry.fr")
|
||||
factories.IdentityFactory(user=user, email=user.admin_email, name="john doe")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
helene = factories.IdentityFactory(
|
||||
email="helene.bowman@work.com", name="helene bowman"
|
||||
)
|
||||
|
||||
# Accented full query
|
||||
response = client.get(
|
||||
"/api/v1.0/users/?q=hélène.bowman@work.com",
|
||||
@@ -329,7 +445,7 @@ def test_api_contacts_list_authenticated_accented_query():
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
assert user_ids == [str(helene.id)]
|
||||
assert user_ids == [str(helene.user.id)]
|
||||
|
||||
# Unaccented partial email
|
||||
response = client.get(
|
||||
@@ -338,7 +454,7 @@ def test_api_contacts_list_authenticated_accented_query():
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
assert user_ids == [str(helene.id)]
|
||||
assert user_ids == [str(helene.user.id)]
|
||||
|
||||
|
||||
@mock.patch.object(Pagination, "get_page_size", return_value=3)
|
||||
@@ -346,7 +462,8 @@ def test_api_users_list_pagination(
|
||||
_mock_page_size,
|
||||
):
|
||||
"""Pagination should work as expected."""
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -386,13 +503,14 @@ def test_api_users_list_pagination_page_size(
|
||||
page_size,
|
||||
):
|
||||
"""Page's size on pagination should work as expected."""
|
||||
user = factories.UserFactory()
|
||||
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")
|
||||
factories.UserFactory.create(admin_email=f"user-{i}@people.com")
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/users/?page_size={page_size}",
|
||||
@@ -410,13 +528,14 @@ def test_api_users_list_pagination_wrong_page_size(
|
||||
page_size,
|
||||
):
|
||||
"""Page's size on pagination should be limited to "max_page_size"."""
|
||||
user = factories.UserFactory()
|
||||
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")
|
||||
factories.UserFactory.create(admin_email=f"user-{i}@people.com")
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/users/?page_size={page_size}",
|
||||
@@ -444,7 +563,8 @@ 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."""
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory(is_main=True)
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -462,8 +582,8 @@ def test_api_users_retrieve_me_authenticated():
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json() == {
|
||||
"id": str(user.id),
|
||||
"name": str(user.name),
|
||||
"email": str(user.email),
|
||||
"name": str(identity.name),
|
||||
"email": str(identity.email),
|
||||
"language": user.language,
|
||||
"timezone": str(user.timezone),
|
||||
"is_device": False,
|
||||
@@ -488,7 +608,8 @@ def test_api_users_retrieve_authenticated_self():
|
||||
Authenticated users should be allowed to retrieve their own user.
|
||||
The returned object should not contain the password.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -505,10 +626,12 @@ def test_api_users_retrieve_authenticated_other():
|
||||
Authenticated users should be able to retrieve another user's detail view with
|
||||
limited information.
|
||||
"""
|
||||
user, other_user = factories.UserFactory.create_batch(2)
|
||||
identity = factories.IdentityFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
client.force_login(identity.user)
|
||||
|
||||
other_user = factories.UserFactory()
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/users/{other_user.id!s}/",
|
||||
@@ -535,7 +658,8 @@ def test_api_users_create_anonymous():
|
||||
|
||||
def test_api_users_create_authenticated():
|
||||
"""Authenticated users should not be able to create users via the API."""
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -582,7 +706,8 @@ def test_api_users_update_authenticated_self():
|
||||
Authenticated users should be able to update their own user but only "language"
|
||||
and "timezone" fields.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -610,10 +735,10 @@ def test_api_users_update_authenticated_self():
|
||||
|
||||
def test_api_users_update_authenticated_other():
|
||||
"""Authenticated users should not be allowed to update other users."""
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
client.force_login(identity.user)
|
||||
|
||||
user = factories.UserFactory()
|
||||
old_user_values = dict(serializers.UserSerializer(instance=user).data)
|
||||
@@ -663,7 +788,8 @@ def test_api_users_patch_authenticated_self():
|
||||
Authenticated users should be able to patch their own user but only "language"
|
||||
and "timezone" fields.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -692,27 +818,27 @@ def test_api_users_patch_authenticated_self():
|
||||
|
||||
def test_api_users_patch_authenticated_other():
|
||||
"""Authenticated users should not be allowed to patch other users."""
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
client.force_login(identity.user)
|
||||
|
||||
other_user = factories.UserFactory()
|
||||
old_user_values = dict(serializers.UserSerializer(instance=other_user).data)
|
||||
user = factories.UserFactory()
|
||||
old_user_values = dict(serializers.UserSerializer(instance=user).data)
|
||||
new_user_values = dict(
|
||||
serializers.UserSerializer(instance=factories.UserFactory()).data
|
||||
)
|
||||
|
||||
for key, new_value in new_user_values.items():
|
||||
response = client.put(
|
||||
f"/api/v1.0/users/{other_user.id!s}/",
|
||||
f"/api/v1.0/users/{user.id!s}/",
|
||||
{key: new_value},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == HTTP_403_FORBIDDEN
|
||||
|
||||
other_user.refresh_from_db()
|
||||
user_values = dict(serializers.UserSerializer(instance=other_user).data)
|
||||
user.refresh_from_db()
|
||||
user_values = dict(serializers.UserSerializer(instance=user).data)
|
||||
for key, value in user_values.items():
|
||||
assert value == old_user_values[key]
|
||||
|
||||
@@ -730,11 +856,11 @@ def test_api_users_delete_list_anonymous():
|
||||
|
||||
def test_api_users_delete_list_authenticated():
|
||||
"""Authenticated users should not be allowed to delete a list of users."""
|
||||
user = factories.UserFactory()
|
||||
factories.UserFactory.create_batch(2)
|
||||
identity = factories.IdentityFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
client.force_login(identity.user)
|
||||
|
||||
response = client.delete(
|
||||
"/api/v1.0/users/",
|
||||
@@ -758,10 +884,11 @@ def test_api_users_delete_authenticated():
|
||||
"""
|
||||
Authenticated users should not be allowed to delete a user other than themselves.
|
||||
"""
|
||||
user, other_user = factories.UserFactory.create_batch(2)
|
||||
identity = factories.IdentityFactory()
|
||||
other_user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
client.force_login(identity.user)
|
||||
|
||||
response = client.delete(f"/api/v1.0/users/{other_user.id!s}/")
|
||||
|
||||
@@ -771,13 +898,13 @@ def test_api_users_delete_authenticated():
|
||||
|
||||
def test_api_users_delete_self():
|
||||
"""Authenticated users should not be able to delete their own user."""
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
client.force_login(identity.user)
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/users/{user.id!s}/",
|
||||
f"/api/v1.0/users/{identity.user.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_405_METHOD_NOT_ALLOWED
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
"""
|
||||
Unit tests for the Identity model
|
||||
"""
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
import pytest
|
||||
|
||||
from core import factories, models
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_models_identities_str_main():
|
||||
"""The str representation should be the email address with indication that it is main."""
|
||||
identity = factories.IdentityFactory(email="david@example.com")
|
||||
assert str(identity) == "david@example.com[main]"
|
||||
|
||||
|
||||
def test_models_identities_str_secondary():
|
||||
"""The str representation of a secondary email should be the email address."""
|
||||
main_identity = factories.IdentityFactory()
|
||||
secondary_identity = factories.IdentityFactory(
|
||||
user=main_identity.user, email="david@example.com"
|
||||
)
|
||||
assert str(secondary_identity) == "david@example.com"
|
||||
|
||||
|
||||
def test_models_identities_is_main_automatic():
|
||||
"""The first identity created for a user should automatically be set as main."""
|
||||
user = factories.UserFactory()
|
||||
identity = models.Identity.objects.create(
|
||||
user=user, sub="123", email="david@example.com"
|
||||
)
|
||||
assert identity.is_main is True
|
||||
|
||||
|
||||
def test_models_identities_is_main_exists():
|
||||
"""A user should always keep one and only one of its identities as main."""
|
||||
user = factories.UserFactory()
|
||||
main_identity, _secondary_identity = factories.IdentityFactory.create_batch(
|
||||
2, user=user
|
||||
)
|
||||
|
||||
assert main_identity.is_main is True
|
||||
|
||||
main_identity.is_main = False
|
||||
with pytest.raises(
|
||||
ValidationError, match="A user should have one and only one main identity."
|
||||
):
|
||||
main_identity.save()
|
||||
|
||||
|
||||
def test_models_identities_is_main_switch():
|
||||
"""Setting a secondary identity as main should reset the existing main identity."""
|
||||
user = factories.UserFactory()
|
||||
first_identity, second_identity = factories.IdentityFactory.create_batch(
|
||||
2, user=user
|
||||
)
|
||||
|
||||
assert first_identity.is_main is True
|
||||
|
||||
second_identity.is_main = True
|
||||
second_identity.save()
|
||||
|
||||
second_identity.refresh_from_db()
|
||||
assert second_identity.is_main is True
|
||||
|
||||
first_identity.refresh_from_db()
|
||||
assert first_identity.is_main is False
|
||||
|
||||
|
||||
def test_models_identities_email_not_required():
|
||||
"""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."""
|
||||
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."""
|
||||
email = factories.IdentityFactory()
|
||||
|
||||
with pytest.raises(
|
||||
ValidationError,
|
||||
match="Identity with this User and Email address already exists.",
|
||||
):
|
||||
factories.IdentityFactory(user=email.user, email=email.email)
|
||||
|
||||
|
||||
def test_models_identities_email_unique_different_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."""
|
||||
email = factories.IdentityFactory()
|
||||
email.email = "Thomas.Jefferson@Example.com"
|
||||
email.save()
|
||||
assert email.email == "Thomas.Jefferson@example.com"
|
||||
|
||||
|
||||
def test_models_identities_ordering():
|
||||
"""Identities should be returned ordered by main status then by their email address."""
|
||||
user = factories.UserFactory()
|
||||
factories.IdentityFactory.create_batch(5, user=user)
|
||||
|
||||
emails = models.Identity.objects.all()
|
||||
|
||||
assert emails[0].is_main is True
|
||||
for i in range(3):
|
||||
assert emails[i + 1].is_main is False
|
||||
assert emails[i + 2].email >= emails[i + 1].email
|
||||
|
||||
|
||||
def test_models_identities_sub_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."""
|
||||
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."""
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
with pytest.raises(ValidationError, match="Identity with this Sub already exists."):
|
||||
models.Identity.objects.create(user=user, sub=identity.sub)
|
||||
|
||||
|
||||
def test_models_identities_sub_max_length():
|
||||
"""The 'sub' field should be 255 characters maximum."""
|
||||
factories.IdentityFactory(sub="a" * 255)
|
||||
with pytest.raises(ValidationError) as excinfo:
|
||||
factories.IdentityFactory(sub="a" * 256)
|
||||
|
||||
assert (
|
||||
str(excinfo.value)
|
||||
== "{'sub': ['Ensure this value has at most 255 characters (it has 256).']}"
|
||||
)
|
||||
|
||||
|
||||
def test_models_identities_sub_special_characters():
|
||||
"""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 'sub' field should not accept spaces."""
|
||||
with pytest.raises(ValidationError) as excinfo:
|
||||
factories.IdentityFactory(sub="a b")
|
||||
|
||||
assert str(excinfo.value) == (
|
||||
"{'sub': ['Enter a valid sub. This value may contain only letters, numbers, "
|
||||
"and @/./+/-/_ characters.']}"
|
||||
)
|
||||
|
||||
|
||||
def test_models_identities_sub_upper_case():
|
||||
"""The 'sub' field should accept upper case characters."""
|
||||
identity = factories.IdentityFactory(sub="John")
|
||||
assert identity.sub == "John"
|
||||
|
||||
|
||||
def test_models_identities_sub_ascii():
|
||||
"""The 'sub' field should accept non ASCII letters."""
|
||||
identity = factories.IdentityFactory(sub="rené")
|
||||
assert identity.sub == "rené"
|
||||
@@ -84,7 +84,7 @@ def test_models_invitations__is_expired(settings):
|
||||
|
||||
def test_models_invitation__new_user__convert_invitations_to_accesses():
|
||||
"""
|
||||
Upon creating a new user, invitations linked to that email
|
||||
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
|
||||
@@ -95,14 +95,16 @@ def test_models_invitation__new_user__convert_invitations_to_accesses():
|
||||
team=invitation_to_team2.team
|
||||
) # another person invited to team2
|
||||
|
||||
new_user = factories.UserFactory(email=invitation_to_team1.email)
|
||||
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_user
|
||||
team=invitation_to_team1.team, user=new_identity.user
|
||||
).exists()
|
||||
assert models.TeamAccess.objects.filter(
|
||||
team=invitation_to_team2.team, user=new_user
|
||||
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
|
||||
@@ -117,7 +119,7 @@ def test_models_invitation__new_user__convert_invitations_to_accesses():
|
||||
|
||||
def test_models_invitation__new_user__filter_expired_invitations():
|
||||
"""
|
||||
Upon creating a new user, valid invitations should be converted into accesses
|
||||
Upon creating a new identity, valid invitations should be converted into accesses
|
||||
and expired invitations should remain unchanged.
|
||||
"""
|
||||
with freeze_time("2020-01-01"):
|
||||
@@ -125,11 +127,11 @@ def test_models_invitation__new_user__filter_expired_invitations():
|
||||
user_email = expired_invitation.email
|
||||
valid_invitation = factories.InvitationFactory(email=user_email)
|
||||
|
||||
new_user = factories.UserFactory(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_user
|
||||
team=valid_invitation.team, user=new_identity.user
|
||||
).exists()
|
||||
assert not models.Invitation.objects.filter(
|
||||
team=valid_invitation.team, email=user_email
|
||||
@@ -137,14 +139,14 @@ def test_models_invitation__new_user__filter_expired_invitations():
|
||||
|
||||
# expired invitation should not have been consumed
|
||||
assert not models.TeamAccess.objects.filter(
|
||||
team=expired_invitation.team, user=new_user
|
||||
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, 4), (1, 7), (20, 7)])
|
||||
@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
|
||||
):
|
||||
@@ -158,14 +160,15 @@ def test_models_invitation__new_user__user_creation_constant_num_queries(
|
||||
for _ in range(0, num_invitations):
|
||||
factories.InvitationFactory(email=user_email, team=factories.TeamFactory())
|
||||
|
||||
factories.UserFactory()
|
||||
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.User.objects.create(
|
||||
models.Identity.objects.create(
|
||||
is_main=True,
|
||||
email=user_email,
|
||||
password="!",
|
||||
user=user,
|
||||
name="Prudence C.",
|
||||
sub=uuid.uuid4(),
|
||||
)
|
||||
|
||||
@@ -20,13 +20,16 @@ def test_models_team_accesses_str():
|
||||
"""
|
||||
The str representation should include user name, team full name and role.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
contact = factories.ContactFactory(full_name="David Bowman")
|
||||
user = contact.owner
|
||||
user.profile_contact = contact
|
||||
user.save()
|
||||
access = factories.TeamAccessFactory(
|
||||
role="member",
|
||||
user=user,
|
||||
team__name="admins",
|
||||
)
|
||||
assert str(access) == f"{user} is {access.role} in team {access.team}"
|
||||
assert str(access) == "David Bowman is member in team admins"
|
||||
|
||||
|
||||
def test_models_team_accesses_unique():
|
||||
@@ -74,7 +77,7 @@ def test_models_team_accesses_create_webhook():
|
||||
"value": [
|
||||
{
|
||||
"value": str(user.id),
|
||||
"email": user.email,
|
||||
"email": None,
|
||||
"type": "User",
|
||||
}
|
||||
],
|
||||
@@ -117,7 +120,7 @@ def test_models_team_accesses_delete_webhook():
|
||||
"value": [
|
||||
{
|
||||
"value": str(access.user.id),
|
||||
"email": access.user.email,
|
||||
"email": None,
|
||||
"type": "User",
|
||||
}
|
||||
],
|
||||
|
||||
@@ -14,18 +14,13 @@ pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_models_users_str():
|
||||
"""
|
||||
user str representation should return name or email when avalaible.
|
||||
Otherwise, it should return the sub.
|
||||
"""
|
||||
"""The str representation should be the full name."""
|
||||
user = factories.UserFactory()
|
||||
assert str(user) == user.name
|
||||
contact = factories.ContactFactory(full_name="david bowman", owner=user)
|
||||
user.profile_contact = contact
|
||||
user.save()
|
||||
|
||||
no_name_user = factories.UserFactory(name=None)
|
||||
assert str(no_name_user) == no_name_user.email
|
||||
|
||||
no_name_no_email_user = factories.UserFactory(name=None, email=None)
|
||||
assert str(no_name_no_email_user) == f"User {no_name_no_email_user.sub}"
|
||||
assert str(user) == "david bowman"
|
||||
|
||||
|
||||
def test_models_users_id_unique():
|
||||
@@ -35,99 +30,21 @@ def test_models_users_id_unique():
|
||||
factories.UserFactory(id=user.id)
|
||||
|
||||
|
||||
def test_models_users_sub_null():
|
||||
"""The 'sub' field should not be null."""
|
||||
with pytest.raises(ValidationError, match="This field cannot be null.") as excinfo:
|
||||
models.User.objects.create(sub=None, password="password")
|
||||
|
||||
assert str(excinfo.value) == "{'sub': ['This field cannot be null.']}"
|
||||
|
||||
|
||||
def test_models_users_sub_blank():
|
||||
"""The 'sub' field should not be blank."""
|
||||
with pytest.raises(ValidationError, match="This field cannot be blank.") as excinfo:
|
||||
models.User.objects.create(sub="", password="password")
|
||||
|
||||
assert str(excinfo.value) == "{'sub': ['This field cannot be blank.']}"
|
||||
|
||||
|
||||
def test_models_users_sub_unique():
|
||||
"""The 'sub' field should be unique."""
|
||||
def test_models_users_email_unique():
|
||||
"""The "admin_email" field should be unique except for the null value."""
|
||||
user = factories.UserFactory()
|
||||
with pytest.raises(ValidationError, match="User with this Sub already exists."):
|
||||
models.User.objects.create(sub=user.sub, password="password")
|
||||
|
||||
|
||||
def test_models_users_sub_max_length():
|
||||
"""The 'sub' field should be 255 characters maximum."""
|
||||
factories.UserFactory(sub="a" * 255)
|
||||
with pytest.raises(ValidationError) as excinfo:
|
||||
factories.UserFactory(sub="a" * 256)
|
||||
|
||||
assert (
|
||||
str(excinfo.value)
|
||||
== "{'sub': ['Ensure this value has at most 255 characters (it has 256).']}"
|
||||
)
|
||||
|
||||
|
||||
def test_models_users_sub_special_characters():
|
||||
"""The 'sub' field should accept periods, dashes, +, @ and underscores."""
|
||||
user = factories.UserFactory(sub="dave.bowman-1+2@hal_9000")
|
||||
assert user.sub == "dave.bowman-1+2@hal_9000"
|
||||
|
||||
|
||||
def test_models_users_sub_spaces():
|
||||
"""The 'sub' field should not accept spaces."""
|
||||
with pytest.raises(ValidationError) as excinfo:
|
||||
factories.UserFactory(sub="a b")
|
||||
|
||||
assert str(excinfo.value) == (
|
||||
"{'sub': ['Enter a valid sub. This value may contain only letters, numbers, "
|
||||
"and @/./+/-/_ characters.']}"
|
||||
)
|
||||
|
||||
|
||||
def test_models_users_sub_upper_case():
|
||||
"""The 'sub' field should accept upper case characters."""
|
||||
user = factories.UserFactory(sub="John")
|
||||
assert user.sub == "John"
|
||||
|
||||
|
||||
def test_models_users_sub_ascii():
|
||||
"""The 'sub' field should accept non ASCII letters."""
|
||||
user = factories.UserFactory(sub="rené")
|
||||
assert user.sub == "rené"
|
||||
|
||||
|
||||
def test_models_users_email_not_required():
|
||||
"""The 'email' field can be blank."""
|
||||
user = factories.UserFactory(email=None)
|
||||
assert user.email is None
|
||||
|
||||
|
||||
def test_models_users_email_normalization():
|
||||
"""The 'email' field should be automatically normalized upon saving."""
|
||||
user = factories.UserFactory()
|
||||
user.email = "Thomas.Jefferson@Example.com"
|
||||
user.save()
|
||||
|
||||
assert user.email == "Thomas.Jefferson@example.com"
|
||||
with pytest.raises(
|
||||
ValidationError, match="User with this Admin email address already exists."
|
||||
):
|
||||
models.User.objects.create(admin_email=user.admin_email, password="password")
|
||||
|
||||
|
||||
def test_models_users_email_several_null():
|
||||
"""Several users with a null value for the "email" field can co-exist."""
|
||||
factories.UserFactory(email=None)
|
||||
models.User.objects.create(email=None, sub="123", password="foo.")
|
||||
factories.UserFactory(admin_email=None)
|
||||
models.User.objects.create(admin_email=None, password="foo.")
|
||||
|
||||
assert models.User.objects.filter(email__isnull=True).count() == 2
|
||||
|
||||
|
||||
def test_models_users_email_not_unique():
|
||||
"""The 'email' field should not necessarily be unique among users."""
|
||||
user = factories.UserFactory()
|
||||
other_user = factories.UserFactory(email=user.email)
|
||||
|
||||
assert user.email == other_user.email
|
||||
assert models.User.objects.filter(admin_email__isnull=True).count() == 2
|
||||
|
||||
|
||||
def test_models_users_profile_not_owned():
|
||||
@@ -161,9 +78,10 @@ def test_models_users_profile_owned_by_other():
|
||||
|
||||
|
||||
def test_models_users_send_mail_main_existing():
|
||||
"""The 'email_user' method should send mail to the user's email address."""
|
||||
user = factories.UserFactory(email="dave@example.com")
|
||||
factories.UserFactory.create_batch(2)
|
||||
"""The 'email_user' method should send mail to the user's main email address."""
|
||||
main_email = factories.IdentityFactory(email="dave@example.com")
|
||||
user = main_email.user
|
||||
factories.IdentityFactory.create_batch(2, user=user)
|
||||
|
||||
with mock.patch("django.core.mail.send_mail") as mock_send:
|
||||
user.email_user("my subject", "my message")
|
||||
@@ -175,19 +93,22 @@ def test_models_users_send_mail_main_existing():
|
||||
|
||||
def test_models_users_send_mail_main_admin():
|
||||
"""
|
||||
The 'email_user' method should send mail to the user's email address.
|
||||
The 'email_user' method should send mail to the user's admin email address if the
|
||||
user has no related identities.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
with mock.patch("django.core.mail.send_mail") as mock_send:
|
||||
user.email_user("my subject", "my message")
|
||||
|
||||
mock_send.assert_called_once_with("my subject", "my message", None, [user.email])
|
||||
mock_send.assert_called_once_with(
|
||||
"my subject", "my message", None, [user.admin_email]
|
||||
)
|
||||
|
||||
|
||||
def test_models_users_send_mail_main_missing():
|
||||
"""The 'email_user' method should fail if the user has no email address."""
|
||||
user = factories.UserFactory(email=None)
|
||||
user = factories.UserFactory(admin_email=None)
|
||||
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
user.email_user("my subject", "my message")
|
||||
|
||||
@@ -16,7 +16,8 @@ def test_throttle():
|
||||
"""
|
||||
Throttle protection should block requests if too many.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
@@ -28,8 +28,8 @@ def test_utils_webhooks_add_user_to_group_no_webhooks():
|
||||
@mock.patch.object(Logger, "info")
|
||||
def test_utils_webhooks_add_user_to_group_success(mock_info):
|
||||
"""The user passed to the function should get added."""
|
||||
user = factories.UserFactory()
|
||||
access = factories.TeamAccessFactory(user=user)
|
||||
identity = factories.IdentityFactory()
|
||||
access = factories.TeamAccessFactory(user=identity.user)
|
||||
webhooks = factories.TeamWebhookFactory.create_batch(2, team=access.team)
|
||||
|
||||
with responses.RequestsMock() as rsps:
|
||||
@@ -64,7 +64,7 @@ def test_utils_webhooks_add_user_to_group_success(mock_info):
|
||||
"value": [
|
||||
{
|
||||
"value": str(access.user.id),
|
||||
"email": user.email,
|
||||
"email": identity.email,
|
||||
"type": "User",
|
||||
}
|
||||
],
|
||||
@@ -90,8 +90,8 @@ def test_utils_webhooks_add_user_to_group_success(mock_info):
|
||||
@mock.patch.object(Logger, "info")
|
||||
def test_utils_webhooks_remove_user_from_group_success(mock_info):
|
||||
"""The user passed to the function should get removed."""
|
||||
user = factories.UserFactory()
|
||||
access = factories.TeamAccessFactory(user=user)
|
||||
identity = factories.IdentityFactory()
|
||||
access = factories.TeamAccessFactory(user=identity.user)
|
||||
webhooks = factories.TeamWebhookFactory.create_batch(2, team=access.team)
|
||||
|
||||
with responses.RequestsMock() as rsps:
|
||||
@@ -121,7 +121,7 @@ def test_utils_webhooks_remove_user_from_group_success(mock_info):
|
||||
"value": [
|
||||
{
|
||||
"value": str(access.user.id),
|
||||
"email": user.email,
|
||||
"email": identity.email,
|
||||
"type": "User",
|
||||
}
|
||||
],
|
||||
@@ -148,8 +148,8 @@ def test_utils_webhooks_remove_user_from_group_success(mock_info):
|
||||
@mock.patch.object(Logger, "info")
|
||||
def test_utils_webhooks_add_user_to_group_failure(mock_info, mock_error):
|
||||
"""The logger should be called on webhook call failure."""
|
||||
user = factories.UserFactory()
|
||||
access = factories.TeamAccessFactory(user=user)
|
||||
identity = factories.IdentityFactory()
|
||||
access = factories.TeamAccessFactory(user=identity.user)
|
||||
webhooks = factories.TeamWebhookFactory.create_batch(2, team=access.team)
|
||||
|
||||
with responses.RequestsMock() as rsps:
|
||||
@@ -179,7 +179,7 @@ def test_utils_webhooks_add_user_to_group_failure(mock_info, mock_error):
|
||||
"value": [
|
||||
{
|
||||
"value": str(access.user.id),
|
||||
"email": user.email,
|
||||
"email": identity.email,
|
||||
"type": "User",
|
||||
}
|
||||
],
|
||||
@@ -207,8 +207,8 @@ def test_utils_webhooks_add_user_to_group_failure(mock_info, mock_error):
|
||||
@mock.patch.object(Logger, "info")
|
||||
def test_utils_webhooks_add_user_to_group_retries(mock_info, mock_error):
|
||||
"""webhooks synchronization supports retries."""
|
||||
user = factories.UserFactory()
|
||||
access = factories.TeamAccessFactory(user=user)
|
||||
identity = factories.IdentityFactory()
|
||||
access = factories.TeamAccessFactory(user=identity.user)
|
||||
webhook = factories.TeamWebhookFactory(team=access.team)
|
||||
|
||||
url = re.compile(r".*/Groups/.*")
|
||||
@@ -236,7 +236,7 @@ def test_utils_webhooks_add_user_to_group_retries(mock_info, mock_error):
|
||||
"value": [
|
||||
{
|
||||
"value": str(access.user.id),
|
||||
"email": user.email,
|
||||
"email": identity.email,
|
||||
"type": "User",
|
||||
}
|
||||
],
|
||||
@@ -262,8 +262,8 @@ def test_utils_webhooks_add_user_to_group_retries(mock_info, mock_error):
|
||||
@mock.patch.object(Logger, "info")
|
||||
def test_utils_synchronize_course_runs_max_retries_exceeded(mock_info, mock_error):
|
||||
"""Webhooks synchronization has exceeded max retries and should get logged."""
|
||||
user = factories.UserFactory()
|
||||
access = factories.TeamAccessFactory(user=user)
|
||||
identity = factories.IdentityFactory()
|
||||
access = factories.TeamAccessFactory(user=identity.user)
|
||||
webhook = factories.TeamWebhookFactory(team=access.team)
|
||||
|
||||
with responses.RequestsMock() as rsps:
|
||||
@@ -290,7 +290,7 @@ def test_utils_synchronize_course_runs_max_retries_exceeded(mock_info, mock_erro
|
||||
"value": [
|
||||
{
|
||||
"value": str(access.user.id),
|
||||
"email": user.email,
|
||||
"email": identity.email,
|
||||
"type": "User",
|
||||
}
|
||||
],
|
||||
@@ -314,8 +314,8 @@ def test_utils_synchronize_course_runs_max_retries_exceeded(mock_info, mock_erro
|
||||
|
||||
def test_utils_webhooks_add_user_to_group_authorization():
|
||||
"""Secret token should be passed in authorization header when set."""
|
||||
user = factories.UserFactory()
|
||||
access = factories.TeamAccessFactory(user=user)
|
||||
identity = factories.IdentityFactory()
|
||||
access = factories.TeamAccessFactory(user=identity.user)
|
||||
webhook = factories.TeamWebhookFactory(team=access.team, secret="123")
|
||||
|
||||
with responses.RequestsMock() as rsps:
|
||||
|
||||
@@ -115,9 +115,7 @@ def create_demo(stdout):
|
||||
for i in range(defaults.NB_OBJECTS["users"]):
|
||||
queue.push(
|
||||
models.User(
|
||||
sub=uuid4(),
|
||||
email=f"user{i:d}@example.com" if random.random() < 0.97 else None,
|
||||
name=fake.name() if random.random() < 0.97 else None,
|
||||
admin_email=f"user{i:d}@example.com",
|
||||
password="!",
|
||||
is_superuser=False,
|
||||
is_active=True,
|
||||
@@ -127,6 +125,27 @@ def create_demo(stdout):
|
||||
)
|
||||
queue.flush()
|
||||
|
||||
with Timeit(stdout, "Creating identities"):
|
||||
users_values = list(models.User.objects.values("id", "admin_email"))
|
||||
for user_dict in users_values:
|
||||
for i in range(
|
||||
random.choices(range(5), weights=[5, 50, 30, 10, 5], k=1)[0]
|
||||
):
|
||||
user_email = user_dict["admin_email"]
|
||||
queue.push(
|
||||
models.Identity(
|
||||
user_id=user_dict["id"],
|
||||
sub=uuid4(),
|
||||
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()
|
||||
|
||||
with Timeit(stdout, "Creating teams"):
|
||||
for i in range(defaults.NB_OBJECTS["teams"]):
|
||||
queue.push(
|
||||
|
||||
@@ -10,17 +10,15 @@ User = get_user_model()
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
Management command to create superusers from a username and a password for demo purposes.
|
||||
"""
|
||||
"""Management command to create superusers from an email and a password"""
|
||||
|
||||
help = "Create a superuser with a username and a password for demo purposes"
|
||||
help = "Create a superuser with an email and a password"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
"""Define required arguments "username" and "password"."""
|
||||
"""Define required arguments "email" and "password"."""
|
||||
parser.add_argument(
|
||||
"--username",
|
||||
help=("Username for the user."),
|
||||
"--admin_email",
|
||||
help=("Email for the user."),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--password",
|
||||
@@ -32,11 +30,11 @@ class Command(BaseCommand):
|
||||
Given an email and a password, create a superuser or upgrade the existing
|
||||
user to superuser status.
|
||||
"""
|
||||
username = options.get("username")
|
||||
email = options.get("admin_email")
|
||||
try:
|
||||
user = User.objects.get(sub=username)
|
||||
user = User.objects.get(admin_email=email)
|
||||
except User.DoesNotExist:
|
||||
user = User(sub=username)
|
||||
user = User(admin_email=email)
|
||||
message = "Superuser created successfully."
|
||||
else:
|
||||
if user.is_superuser and user.is_staff:
|
||||
|
||||
@@ -28,6 +28,7 @@ def test_commands_create_demo():
|
||||
call_command("create_demo")
|
||||
|
||||
assert models.User.objects.count() == 5
|
||||
assert models.Identity.objects.exists()
|
||||
assert models.Team.objects.count() == 3
|
||||
assert models.TeamAccess.objects.count() >= 3
|
||||
|
||||
@@ -38,8 +39,6 @@ def test_commands_createsuperuser():
|
||||
with superuser permissions.
|
||||
"""
|
||||
|
||||
call_command("createsuperuser", username="admin", password="admin")
|
||||
call_command("createsuperuser")
|
||||
|
||||
assert models.User.objects.count() == 1
|
||||
user = models.User.objects.get()
|
||||
assert user.sub == "admin"
|
||||
|
||||
@@ -14,28 +14,20 @@ class MailDomainAdmin(admin.ModelAdmin):
|
||||
"name",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"slug",
|
||||
"status",
|
||||
)
|
||||
search_fields = ("name",)
|
||||
readonly_fields = ["created_at", "slug"]
|
||||
readonly_fields = ["created_at"]
|
||||
|
||||
|
||||
@admin.register(models.MailDomainAccess)
|
||||
class MailDomainAccessAdmin(admin.ModelAdmin):
|
||||
"""Admin for mail domain accesses model."""
|
||||
|
||||
list_display = (
|
||||
"user",
|
||||
"domain",
|
||||
"role",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
list_display = ("user", "domain")
|
||||
|
||||
|
||||
@admin.register(models.Mailbox)
|
||||
class MailboxAdmin(admin.ModelAdmin):
|
||||
"""Admin for mailbox model."""
|
||||
|
||||
list_display = ("__str__", "first_name", "last_name")
|
||||
list_display = ("__str__", "domain")
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
from core.api import permissions as core_permissions
|
||||
|
||||
from mailbox_manager import models
|
||||
|
||||
|
||||
class AccessPermission(core_permissions.IsAuthenticated):
|
||||
"""Permission class for access objects."""
|
||||
@@ -12,12 +10,3 @@ class AccessPermission(core_permissions.IsAuthenticated):
|
||||
"""Check permission for a given object."""
|
||||
abilities = obj.get_abilities(request.user)
|
||||
return abilities.get(request.method.lower(), False)
|
||||
|
||||
|
||||
class MailBoxPermission(core_permissions.IsAuthenticated):
|
||||
"""Permission class to manage mailboxes for a mail domain"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
domain = models.MailDomain.objects.get(slug=view.kwargs.get("domain_slug", ""))
|
||||
abilities = domain.get_abilities(request.user)
|
||||
return abilities.get(request.method.lower(), False)
|
||||
|
||||
@@ -10,39 +10,21 @@ class MailboxSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = models.Mailbox
|
||||
fields = ["id", "first_name", "last_name", "local_part", "secondary_email"]
|
||||
fields = ["id", "local_part", "secondary_email"]
|
||||
|
||||
|
||||
class MailDomainSerializer(serializers.ModelSerializer):
|
||||
"""Serialize mail domain."""
|
||||
|
||||
abilities = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.MailDomain
|
||||
lookup_field = "slug"
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"slug",
|
||||
"abilities",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"slug",
|
||||
"abilities",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
def get_abilities(self, domain) -> dict:
|
||||
"""Return abilities of the logged-in user on the instance."""
|
||||
request = self.context.get("request")
|
||||
if request:
|
||||
return domain.get_abilities(request.user)
|
||||
return {}
|
||||
|
||||
|
||||
class MailDomainAccessSerializer(serializers.ModelSerializer):
|
||||
|
||||
@@ -14,6 +14,7 @@ class MailDomainViewSet(
|
||||
mixins.CreateModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
"""
|
||||
@@ -28,6 +29,9 @@ class MailDomainViewSet(
|
||||
POST /api/<version>/mail-domains/ with expected data:
|
||||
- name: str
|
||||
Return newly created domain
|
||||
|
||||
DELETE /api/<version>/mail-domains/<domain-slug>/
|
||||
Delete targeted team access
|
||||
"""
|
||||
|
||||
permission_classes = [permissions.AccessPermission]
|
||||
@@ -76,10 +80,8 @@ class MailBoxViewSet(
|
||||
):
|
||||
"""MailBox ViewSet"""
|
||||
|
||||
permission_classes = [permissions.MailBoxPermission]
|
||||
permission_classes = [drf_permissions.IsAuthenticated]
|
||||
serializer_class = serializers.MailboxSerializer
|
||||
filter_backends = [filters.OrderingFilter]
|
||||
ordering = ["-created_at"]
|
||||
queryset = models.Mailbox.objects.all()
|
||||
|
||||
def get_queryset(self):
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
"""
|
||||
Application enums declaration
|
||||
"""
|
||||
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
class MailDomainRoleChoices(models.TextChoices):
|
||||
"""Defines the possible roles a user can have to access to a mail domain."""
|
||||
|
||||
VIEWER = "viewer", _("Viewer")
|
||||
ADMIN = "administrator", _("Administrator")
|
||||
OWNER = "owner", _("Owner")
|
||||
|
||||
|
||||
class MailDomainStatusChoices(models.TextChoices):
|
||||
"""Defines the possible statuses in which a mail domain can be."""
|
||||
|
||||
PENDING = "pending", _("Pending")
|
||||
ENABLED = "enabled", _("Enabled")
|
||||
FAILED = "failed", _("Failed")
|
||||
DISABLED = "disabled", _("Disabled")
|
||||
@@ -10,15 +10,13 @@ from faker import Faker
|
||||
from core import factories as core_factories
|
||||
from core import models as core_models
|
||||
|
||||
from mailbox_manager import enums, models
|
||||
from mailbox_manager import models
|
||||
|
||||
fake = Faker()
|
||||
|
||||
|
||||
class MailDomainFactory(factory.django.DjangoModelFactory):
|
||||
"""A factory to create mail domain. Please not this is a factory to create mail domain with
|
||||
default values. So the status is pending and no mailbox can be created from it,
|
||||
until the mail domain is enabled."""
|
||||
"""A factory to create mail domain."""
|
||||
|
||||
class Meta:
|
||||
model = models.MailDomain
|
||||
@@ -42,12 +40,6 @@ class MailDomainFactory(factory.django.DjangoModelFactory):
|
||||
)
|
||||
|
||||
|
||||
class MailDomainEnabledFactory(MailDomainFactory):
|
||||
"""A factory to create mail domain enabled."""
|
||||
|
||||
status = enums.MailDomainStatusChoices.ENABLED
|
||||
|
||||
|
||||
class MailDomainAccessFactory(factory.django.DjangoModelFactory):
|
||||
"""A factory to create mail domain accesses."""
|
||||
|
||||
@@ -55,23 +47,21 @@ class MailDomainAccessFactory(factory.django.DjangoModelFactory):
|
||||
model = models.MailDomainAccess
|
||||
|
||||
user = factory.SubFactory(core_factories.UserFactory)
|
||||
domain = factory.SubFactory(MailDomainEnabledFactory)
|
||||
role = factory.fuzzy.FuzzyChoice(
|
||||
[r[0] for r in enums.MailDomainRoleChoices.choices]
|
||||
)
|
||||
domain = factory.SubFactory(MailDomainFactory)
|
||||
role = factory.fuzzy.FuzzyChoice([r[0] for r in core_models.RoleChoices.choices])
|
||||
|
||||
|
||||
class MailboxFactory(factory.django.DjangoModelFactory):
|
||||
"""A factory to create mailboxes for a mail domain."""
|
||||
"""A factory to create mailboxes for mail domain members."""
|
||||
|
||||
class Meta:
|
||||
model = models.Mailbox
|
||||
|
||||
first_name = factory.Faker("first_name", locale="fr_FR")
|
||||
last_name = factory.Faker("last_name", locale="de_DE")
|
||||
class Params:
|
||||
"""Parameters for fields."""
|
||||
|
||||
local_part = factory.LazyAttribute(
|
||||
lambda a: f"{slugify(a.first_name)}.{slugify(a.last_name)}"
|
||||
)
|
||||
domain = factory.SubFactory(MailDomainEnabledFactory)
|
||||
full_name = factory.Faker("name")
|
||||
|
||||
local_part = factory.LazyAttribute(lambda a: a.full_name.lower().replace(" ", "."))
|
||||
domain = factory.SubFactory(MailDomainFactory)
|
||||
secondary_email = factory.Faker("email")
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
# Generated by Django 5.0.6 on 2024-07-06 23:14
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('mailbox_manager', '0005_alter_maildomain_slug'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='maildomain',
|
||||
name='status',
|
||||
field=models.CharField(choices=[('pending', 'Pending'), ('enabled', 'Enabled'), ('failed', 'Failed'), ('disabled', 'Disabled')], default='pending', max_length=10),
|
||||
),
|
||||
]
|
||||
@@ -1,18 +0,0 @@
|
||||
# Generated by Django 5.0.6 on 2024-08-02 15:11
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('mailbox_manager', '0006_maildomain_status'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='maildomainaccess',
|
||||
name='role',
|
||||
field=models.CharField(choices=[('viewer', 'Viewer'), ('administrator', 'Administrator'), ('owner', 'Owner')], default='viewer', max_length=20),
|
||||
),
|
||||
]
|
||||
@@ -1,23 +0,0 @@
|
||||
# Generated by Django 5.0.6 on 2024-08-07 09:43
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('mailbox_manager', '0007_alter_maildomainaccess_role'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='mailbox',
|
||||
name='first_name',
|
||||
field=models.CharField(blank=True, max_length=200),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='mailbox',
|
||||
name='last_name',
|
||||
field=models.CharField(blank=True, max_length=200),
|
||||
),
|
||||
]
|
||||
@@ -1,22 +0,0 @@
|
||||
# Generated by Django 5.0.6 on 2024-08-07 09:43
|
||||
|
||||
from django.db import migrations, models
|
||||
from django.db.models import F
|
||||
|
||||
|
||||
def populate_first_name_last_name(apps, schema_editor):
|
||||
Mailbox = apps.get_model('mailbox_manager', 'Mailbox')
|
||||
Mailbox.objects.filter(first_name='').update(first_name=F("local_part"))
|
||||
Mailbox.objects.filter(last_name='').update(last_name=F("local_part"))
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('mailbox_manager', '0008_mailbox_first_name_mailbox_last_name'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(populate_first_name_last_name, reverse_code=migrations.RunPython.noop),
|
||||
|
||||
]
|
||||
@@ -1,23 +0,0 @@
|
||||
# Generated by Django 5.0.6 on 2024-08-07 10:28
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('mailbox_manager', '0009_fill_mailbox_first_name_mailbox_last_name'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='mailbox',
|
||||
name='first_name',
|
||||
field=models.CharField(max_length=200),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='mailbox',
|
||||
name='last_name',
|
||||
field=models.CharField(max_length=200),
|
||||
),
|
||||
]
|
||||
@@ -4,14 +4,11 @@ Declare and configure the models for the People additional application : mailbox
|
||||
|
||||
from django.conf import settings
|
||||
from django.core import validators
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.utils.text import slugify
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from core.models import BaseModel
|
||||
|
||||
from mailbox_manager.enums import MailDomainRoleChoices, MailDomainStatusChoices
|
||||
from core.models import BaseModel, RoleChoices
|
||||
|
||||
|
||||
class MailDomain(BaseModel):
|
||||
@@ -21,11 +18,6 @@ class MailDomain(BaseModel):
|
||||
_("name"), max_length=150, null=False, blank=False, unique=True
|
||||
)
|
||||
slug = models.SlugField(null=False, blank=False, unique=True, max_length=80)
|
||||
status = models.CharField(
|
||||
max_length=10,
|
||||
default=MailDomainStatusChoices.PENDING,
|
||||
choices=MailDomainStatusChoices.choices,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "people_mail_domain"
|
||||
@@ -36,14 +28,10 @@ class MailDomain(BaseModel):
|
||||
return self.name
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Override save function to compute the slug."""
|
||||
self.slug = self.get_slug()
|
||||
if not self.slug:
|
||||
self.slug = slugify(self.name)
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
def get_slug(self):
|
||||
"""Compute slug value from name."""
|
||||
return slugify(self.name)
|
||||
|
||||
def get_abilities(self, user):
|
||||
"""
|
||||
Compute and return abilities for a given user on the domain.
|
||||
@@ -57,17 +45,13 @@ class MailDomain(BaseModel):
|
||||
except (MailDomainAccess.DoesNotExist, IndexError):
|
||||
role = None
|
||||
|
||||
is_owner_or_admin = role in [
|
||||
MailDomainRoleChoices.OWNER,
|
||||
MailDomainRoleChoices.ADMIN,
|
||||
]
|
||||
is_owner_or_admin = role in [RoleChoices.OWNER, RoleChoices.ADMIN]
|
||||
|
||||
return {
|
||||
"get": bool(role),
|
||||
"patch": is_owner_or_admin,
|
||||
"put": is_owner_or_admin,
|
||||
"post": is_owner_or_admin,
|
||||
"delete": role == MailDomainRoleChoices.OWNER,
|
||||
"delete": role == RoleChoices.OWNER,
|
||||
"manage_accesses": is_owner_or_admin,
|
||||
}
|
||||
|
||||
@@ -90,9 +74,7 @@ class MailDomainAccess(BaseModel):
|
||||
blank=False,
|
||||
)
|
||||
role = models.CharField(
|
||||
max_length=20,
|
||||
choices=MailDomainRoleChoices.choices,
|
||||
default=MailDomainRoleChoices.VIEWER,
|
||||
max_length=20, choices=RoleChoices.choices, default=RoleChoices.MEMBER
|
||||
)
|
||||
|
||||
class Meta:
|
||||
@@ -108,8 +90,6 @@ class MailDomainAccess(BaseModel):
|
||||
class Mailbox(BaseModel):
|
||||
"""Mailboxes for users from mail domain."""
|
||||
|
||||
first_name = models.CharField(max_length=200, blank=False)
|
||||
last_name = models.CharField(max_length=200, blank=False)
|
||||
local_part = models.CharField(
|
||||
_("local_part"),
|
||||
max_length=150,
|
||||
@@ -136,9 +116,3 @@ class Mailbox(BaseModel):
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.local_part!s}@{self.domain.name:s}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.full_clean()
|
||||
if self.domain.status != MailDomainStatusChoices.ENABLED:
|
||||
raise ValidationError("You can create mailbox only for a domain enabled")
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@@ -8,7 +8,7 @@ from rest_framework.test import APIClient
|
||||
|
||||
from core import factories as core_factories
|
||||
|
||||
from mailbox_manager import enums, factories, models
|
||||
from mailbox_manager import factories, models
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -31,10 +31,10 @@ def test_api_mail_domains__create_name_unique():
|
||||
Creating domain should raise an error if already existing name.
|
||||
"""
|
||||
factories.MailDomainFactory(name="existing_domain.com")
|
||||
user = core_factories.UserFactory()
|
||||
identity = core_factories.IdentityFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
client.force_login(identity.user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/mail-domains/",
|
||||
@@ -53,10 +53,11 @@ def test_api_mail_domains__create_authenticated():
|
||||
and should automatically be added as owner of the newly created domain.
|
||||
"""
|
||||
|
||||
user = core_factories.UserFactory()
|
||||
identity = core_factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
client.force_login(identity.user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/mail-domains/",
|
||||
@@ -67,8 +68,6 @@ def test_api_mail_domains__create_authenticated():
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
# a new domain pending is created and the authenticated user is the owner
|
||||
domain = models.MailDomain.objects.get()
|
||||
assert domain.status == enums.MailDomainStatusChoices.PENDING
|
||||
assert domain.name == "mydomain.com"
|
||||
assert domain.accesses.filter(role="owner", user=user).exists()
|
||||
|
||||
@@ -14,7 +14,7 @@ pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_mail_domains__delete_anonymous():
|
||||
"""Anonymous users should not be allowed to destroy a domain."""
|
||||
"""Anonymous users should not be allowed to destroy a team."""
|
||||
domain = factories.MailDomainFactory()
|
||||
|
||||
response = APIClient().delete(
|
||||
@@ -25,18 +25,83 @@ def test_api_mail_domains__delete_anonymous():
|
||||
assert models.MailDomain.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_mail_domains__delete_authenticated():
|
||||
def test_api_mail_domains__delete_authenticated_unrelated():
|
||||
"""
|
||||
Delete a domain is not allowed.
|
||||
Authenticated users should not be allowed to delete a domain to which they are not
|
||||
related.
|
||||
"""
|
||||
user = core_factories.UserFactory()
|
||||
identity = core_factories.IdentityFactory()
|
||||
domain = factories.MailDomainFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(identity.user)
|
||||
response = client.delete(
|
||||
f"/api/v1.0/mail-domains/{domain.slug}/",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert response.json() == {"detail": "No MailDomain matches the given query."}
|
||||
assert models.MailDomain.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_mail_domains__delete_authenticated_member():
|
||||
"""
|
||||
Authenticated users should not be allowed to delete a domain
|
||||
to which they are only a member.
|
||||
"""
|
||||
identity = core_factories.IdentityFactory()
|
||||
user = identity.user
|
||||
domain = factories.MailDomainFactory(users=[(user, "member")])
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.delete(
|
||||
f"/api/v1.0/mail-domains/{domain.slug}/",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
assert models.MailDomain.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_mail_domains__delete_authenticated_administrator():
|
||||
"""
|
||||
Authenticated users should not be allowed to delete a domain
|
||||
for which they are administrator.
|
||||
"""
|
||||
identity = core_factories.IdentityFactory()
|
||||
user = identity.user
|
||||
domain = factories.MailDomainFactory(users=[(user, "administrator")])
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.delete(
|
||||
f"/api/v1.0/mail-domains/{domain.slug}/",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
assert models.MailDomain.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_mail_domains__delete_authenticated_owner():
|
||||
"""
|
||||
Authenticated users should be able to delete a domain
|
||||
for which they are directly owner.
|
||||
"""
|
||||
identity = core_factories.IdentityFactory()
|
||||
user = identity.user
|
||||
domain = factories.MailDomainFactory(users=[(user, "owner")])
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.delete(
|
||||
f"/api/v1.0/mail-domains/{domain.slug}/",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
assert models.MailDomain.objects.exists() is False
|
||||
|
||||
@@ -32,7 +32,8 @@ def test_api_mail_domains__list_authenticated():
|
||||
to which they have access.
|
||||
"""
|
||||
|
||||
user = core_factories.UserFactory()
|
||||
identity = core_factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -41,8 +42,8 @@ def test_api_mail_domains__list_authenticated():
|
||||
str(access.domain.id)
|
||||
for access in factories.MailDomainAccessFactory.create_batch(5, user=user)
|
||||
}
|
||||
factories.MailDomainFactory.create_batch(2) # Other domains
|
||||
factories.MailDomainAccessFactory.create_batch(2) # Other domains and accesses
|
||||
factories.MailDomainFactory.create_batch(2) # Other teams
|
||||
factories.MailDomainAccessFactory.create_batch(2) # Other teams and accesses
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/mail-domains/",
|
||||
|
||||
@@ -16,7 +16,7 @@ pytestmark = pytest.mark.django_db
|
||||
def test_api_mail_domains__retrieve_anonymous():
|
||||
"""Anonymous users should not be allowed to retrieve a domain."""
|
||||
|
||||
domain = factories.MailDomainEnabledFactory()
|
||||
domain = factories.MailDomainFactory()
|
||||
response = APIClient().get(f"/api/v1.0/mail-domains/{domain.slug}/")
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
@@ -25,32 +25,17 @@ def test_api_mail_domains__retrieve_anonymous():
|
||||
}
|
||||
|
||||
|
||||
def test_api_domains__retrieve_non_existing():
|
||||
"""
|
||||
Authenticated users should have an explicit error when trying to retrive
|
||||
a domain that doesn't exist.
|
||||
"""
|
||||
client = APIClient()
|
||||
client.force_login(core_factories.UserFactory())
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/mail-domains/nonexistent.domain/",
|
||||
)
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert response.json() == {"detail": "Not found."}
|
||||
|
||||
|
||||
def test_api_mail_domains__retrieve_authenticated_unrelated():
|
||||
"""
|
||||
Authenticated users should not be allowed to retrieve a domain
|
||||
to which they have access.
|
||||
"""
|
||||
user = core_factories.UserFactory()
|
||||
identity = core_factories.IdentityFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
client.force_login(identity.user)
|
||||
|
||||
domain = factories.MailDomainEnabledFactory()
|
||||
domain = factories.MailDomainFactory()
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/mail-domains/{domain.slug}/",
|
||||
@@ -64,12 +49,13 @@ def test_api_mail_domains__retrieve_authenticated_related():
|
||||
Authenticated users should be allowed to retrieve a domain
|
||||
to which they have access.
|
||||
"""
|
||||
user = core_factories.UserFactory()
|
||||
identity = core_factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
domain = factories.MailDomainEnabledFactory()
|
||||
domain = factories.MailDomainFactory()
|
||||
factories.MailDomainAccessFactory(domain=domain, user=user)
|
||||
|
||||
response = client.get(
|
||||
@@ -80,8 +66,6 @@ def test_api_mail_domains__retrieve_authenticated_related():
|
||||
assert response.json() == {
|
||||
"id": str(domain.id),
|
||||
"name": domain.name,
|
||||
"slug": domain.slug,
|
||||
"created_at": domain.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"updated_at": domain.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"abilities": domain.get_abilities(user),
|
||||
}
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
"""
|
||||
Unit tests for the mailbox API
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories as core_factories
|
||||
|
||||
from mailbox_manager import enums, factories, models
|
||||
from mailbox_manager.api import serializers
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_mailboxes__create_anonymous_forbidden():
|
||||
"""Anonymous users should not be able to create a new mailbox via the API."""
|
||||
mail_domain = factories.MailDomainEnabledFactory()
|
||||
mailbox_values = serializers.MailboxSerializer(
|
||||
factories.MailboxFactory.build()
|
||||
).data
|
||||
response = APIClient().post(
|
||||
f"/api/v1.0/mail-domains/{mail_domain.slug}/mailboxes/",
|
||||
mailbox_values,
|
||||
)
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
assert not models.Mailbox.objects.exists()
|
||||
|
||||
|
||||
def test_api_mailboxes__create_authenticated_failure():
|
||||
"""Authenticated users should not be able to create mailbox
|
||||
without specific role on mail domain."""
|
||||
user = core_factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
mailbox_values = serializers.MailboxSerializer(
|
||||
factories.MailboxFactory.build()
|
||||
).data
|
||||
mail_domain = factories.MailDomainEnabledFactory()
|
||||
response = client.post(
|
||||
f"/api/v1.0/mail-domains/{mail_domain.slug}/mailboxes/",
|
||||
mailbox_values,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert not models.Mailbox.objects.exists()
|
||||
|
||||
|
||||
def test_api_mailboxes__create_viewer_failure():
|
||||
"""Users with viewer role should not be able to create mailbox on the mail domain."""
|
||||
mail_domain = factories.MailDomainEnabledFactory()
|
||||
access = factories.MailDomainAccessFactory(
|
||||
role=enums.MailDomainRoleChoices.VIEWER, domain=mail_domain
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(access.user)
|
||||
|
||||
mailbox_values = serializers.MailboxSerializer(
|
||||
factories.MailboxFactory.build()
|
||||
).data
|
||||
response = client.post(
|
||||
f"/api/v1.0/mail-domains/{mail_domain.slug}/mailboxes/",
|
||||
mailbox_values,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert not models.Mailbox.objects.exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"role",
|
||||
[
|
||||
enums.MailDomainRoleChoices.OWNER,
|
||||
enums.MailDomainRoleChoices.ADMIN,
|
||||
],
|
||||
)
|
||||
def test_api_mailboxes__create_roles_success(role):
|
||||
"""Users with owner or admin role should be able to create mailbox on the mail domain."""
|
||||
mail_domain = factories.MailDomainEnabledFactory()
|
||||
access = factories.MailDomainAccessFactory(role=role, domain=mail_domain)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(access.user)
|
||||
|
||||
mailbox_values = serializers.MailboxSerializer(
|
||||
factories.MailboxFactory.build()
|
||||
).data
|
||||
response = client.post(
|
||||
f"/api/v1.0/mail-domains/{mail_domain.slug}/mailboxes/",
|
||||
mailbox_values,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
mailbox = models.Mailbox.objects.get()
|
||||
|
||||
assert mailbox.local_part == mailbox_values["local_part"]
|
||||
assert mailbox.secondary_email == mailbox_values["secondary_email"]
|
||||
assert response.json() == {
|
||||
"id": str(mailbox.id),
|
||||
"first_name": str(mailbox.first_name),
|
||||
"last_name": str(mailbox.last_name),
|
||||
"local_part": str(mailbox.local_part),
|
||||
"secondary_email": str(mailbox.secondary_email),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"role",
|
||||
[
|
||||
enums.MailDomainRoleChoices.OWNER,
|
||||
enums.MailDomainRoleChoices.ADMIN,
|
||||
],
|
||||
)
|
||||
def test_api_mailboxes__create_with_accent_success(role):
|
||||
"""Users with proper abilities should be able to create mailbox on the mail domain with a
|
||||
first_name accentuated."""
|
||||
mail_domain = factories.MailDomainEnabledFactory()
|
||||
access = factories.MailDomainAccessFactory(role=role, domain=mail_domain)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(access.user)
|
||||
|
||||
mailbox_values = serializers.MailboxSerializer(
|
||||
factories.MailboxFactory.build(first_name="Aimé")
|
||||
).data
|
||||
response = client.post(
|
||||
f"/api/v1.0/mail-domains/{mail_domain.slug}/mailboxes/",
|
||||
mailbox_values,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
mailbox = models.Mailbox.objects.get()
|
||||
|
||||
assert mailbox.local_part == mailbox_values["local_part"]
|
||||
assert mailbox.secondary_email == mailbox_values["secondary_email"]
|
||||
assert response.json() == {
|
||||
"id": str(mailbox.id),
|
||||
"first_name": str(mailbox.first_name),
|
||||
"last_name": str(mailbox.last_name),
|
||||
"local_part": str(mailbox.local_part),
|
||||
"secondary_email": str(mailbox.secondary_email),
|
||||
}
|
||||
|
||||
|
||||
def test_api_mailboxes__create_administrator_missing_fields():
|
||||
"""
|
||||
Administrator users should not be able to create mailboxes
|
||||
without local part or secondary mail.
|
||||
"""
|
||||
mail_domain = factories.MailDomainEnabledFactory()
|
||||
access = factories.MailDomainAccessFactory(
|
||||
role=enums.MailDomainRoleChoices.ADMIN, domain=mail_domain
|
||||
)
|
||||
client = APIClient()
|
||||
client.force_login(access.user)
|
||||
|
||||
mailbox_values = serializers.MailboxSerializer(
|
||||
factories.MailboxFactory.build()
|
||||
).data
|
||||
del mailbox_values["local_part"]
|
||||
response = client.post(
|
||||
f"/api/v1.0/mail-domains/{mail_domain.slug}/mailboxes/",
|
||||
mailbox_values,
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert not models.Mailbox.objects.exists()
|
||||
assert response.json() == {"local_part": ["This field is required."]}
|
||||
|
||||
mailbox_values = serializers.MailboxSerializer(
|
||||
factories.MailboxFactory.build()
|
||||
).data
|
||||
del mailbox_values["secondary_email"]
|
||||
response = client.post(
|
||||
f"/api/v1.0/mail-domains/{mail_domain.slug}/mailboxes/",
|
||||
mailbox_values,
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert not models.Mailbox.objects.exists()
|
||||
assert response.json() == {"secondary_email": ["This field is required."]}
|
||||
@@ -0,0 +1,103 @@
|
||||
"""
|
||||
Unit tests for the mailbox API
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories as core_factories
|
||||
|
||||
from mailbox_manager import factories, models
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_mailboxes__create_anonymous_forbidden():
|
||||
"""Anonymous users should not be able to create a new mailbox via the API."""
|
||||
mail_domain = factories.MailDomainFactory()
|
||||
|
||||
response = APIClient().post(
|
||||
f"/api/v1.0/mail-domains/{mail_domain.slug}/mailboxes/",
|
||||
{
|
||||
"first_name": "jean",
|
||||
"last_name": "doe",
|
||||
"local_part": "jean.doe",
|
||||
"secondary_email": "jean.doe@gmail.com",
|
||||
"phone_number": "+33150142700",
|
||||
},
|
||||
)
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
assert not models.Mailbox.objects.exists()
|
||||
|
||||
|
||||
def test_api_mailboxes__create_authenticated_missing_fields():
|
||||
"""
|
||||
Authenticated users should not be able to create mailboxes
|
||||
without local part or secondary mail.
|
||||
"""
|
||||
user = core_factories.UserFactory(admin_email="tester@ministry.fr")
|
||||
core_factories.IdentityFactory(user=user, email=user.admin_email, name="john doe")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
mail_domain = factories.MailDomainFactory()
|
||||
response = client.post(
|
||||
f"/api/v1.0/mail-domains/{mail_domain.slug}/mailboxes/",
|
||||
{
|
||||
"first_name": "jean",
|
||||
"last_name": "doe",
|
||||
"secondary_email": "jean.doe@gmail.com",
|
||||
"phone_number": "+33150142700",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert models.Mailbox.objects.exists() is False
|
||||
assert response.json() == {"local_part": ["This field is required."]}
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/mail-domains/{mail_domain.slug}/mailboxes/",
|
||||
{
|
||||
"first_name": "jean",
|
||||
"last_name": "doe",
|
||||
"local_part": "jean.doe",
|
||||
"phone_number": "+33150142700",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert models.Mailbox.objects.exists() is False
|
||||
assert response.json() == {"secondary_email": ["This field is required."]}
|
||||
|
||||
|
||||
def test_api_mailboxes__create_authenticated_successful():
|
||||
"""Authenticated users should be able to create mailbox."""
|
||||
user = core_factories.UserFactory(admin_email="tester@ministry.fr")
|
||||
core_factories.IdentityFactory(user=user, email=user.admin_email, name="john doe")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
mail_domain = factories.MailDomainFactory(name="saint-jean.collectivite.fr")
|
||||
response = client.post(
|
||||
f"/api/v1.0/mail-domains/{mail_domain.slug}/mailboxes/",
|
||||
{
|
||||
"first_name": "jean",
|
||||
"last_name": "doe",
|
||||
"local_part": "jean.doe",
|
||||
"secondary_email": "jean.doe@gmail.com",
|
||||
"phone_number": "+33150142700",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
mailbox = models.Mailbox.objects.get()
|
||||
assert mailbox.local_part == "jean.doe"
|
||||
assert mailbox.secondary_email == "jean.doe@gmail.com"
|
||||
assert response.json() == {
|
||||
"id": str(mailbox.id),
|
||||
"local_part": str(mailbox.local_part),
|
||||
"secondary_email": str(mailbox.secondary_email),
|
||||
}
|
||||
@@ -8,14 +8,14 @@ from rest_framework.test import APIClient
|
||||
|
||||
from core import factories as core_factories
|
||||
|
||||
from mailbox_manager import enums, factories
|
||||
from mailbox_manager import factories
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_mailboxes__list_anonymous():
|
||||
"""Anonymous users should not be allowed to list mailboxes."""
|
||||
mail_domain = factories.MailDomainEnabledFactory()
|
||||
mail_domain = factories.MailDomainFactory()
|
||||
factories.MailboxFactory.create_batch(2, domain=mail_domain)
|
||||
|
||||
response = APIClient().get(f"/api/v1.0/mail-domains/{mail_domain.slug}/mailboxes/")
|
||||
@@ -25,56 +25,29 @@ def test_api_mailboxes__list_anonymous():
|
||||
}
|
||||
|
||||
|
||||
def test_api_mailboxes__list_authenticated():
|
||||
"""Authenticated users should not be able to list mailboxes"""
|
||||
user = core_factories.UserFactory()
|
||||
def test_api_mailboxes__list_authenticated_no_query():
|
||||
"""Authenticated users should be able to list mailboxes without applying a query."""
|
||||
user = core_factories.UserFactory(admin_email="tester@ministry.fr")
|
||||
core_factories.IdentityFactory(user=user, email=user.admin_email, name="john doe")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
mail_domain = factories.MailDomainEnabledFactory()
|
||||
factories.MailboxFactory.create_batch(2, domain=mail_domain)
|
||||
|
||||
response = client.get(f"/api/v1.0/mail-domains/{mail_domain.slug}/mailboxes/")
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"role",
|
||||
[
|
||||
enums.MailDomainRoleChoices.OWNER,
|
||||
enums.MailDomainRoleChoices.ADMIN,
|
||||
enums.MailDomainRoleChoices.VIEWER,
|
||||
],
|
||||
)
|
||||
def test_api_mailboxes__list_roles(role):
|
||||
"""Owner, admin and viewer users should be able to list mailboxes"""
|
||||
mail_domain = factories.MailDomainEnabledFactory()
|
||||
mail_domain = factories.MailDomainFactory()
|
||||
mailbox1 = factories.MailboxFactory(domain=mail_domain)
|
||||
mailbox2 = factories.MailboxFactory(domain=mail_domain)
|
||||
|
||||
access = factories.MailDomainAccessFactory(role=role, domain=mail_domain)
|
||||
client = APIClient()
|
||||
client.force_login(access.user)
|
||||
|
||||
response = client.get(f"/api/v1.0/mail-domains/{mail_domain.slug}/mailboxes/")
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["results"] == [
|
||||
{
|
||||
"id": str(mailbox2.id),
|
||||
"first_name": str(mailbox2.first_name),
|
||||
"last_name": str(mailbox2.last_name),
|
||||
"local_part": str(mailbox2.local_part),
|
||||
"secondary_email": str(mailbox2.secondary_email),
|
||||
},
|
||||
{
|
||||
"id": str(mailbox1.id),
|
||||
"first_name": str(mailbox1.first_name),
|
||||
"last_name": str(mailbox1.last_name),
|
||||
"local_part": str(mailbox1.local_part),
|
||||
"secondary_email": str(mailbox1.secondary_email),
|
||||
},
|
||||
{
|
||||
"id": str(mailbox2.id),
|
||||
"local_part": str(mailbox2.local_part),
|
||||
"secondary_email": str(mailbox2.secondary_email),
|
||||
},
|
||||
]
|
||||
@@ -6,7 +6,7 @@ from django.core.exceptions import ValidationError
|
||||
|
||||
import pytest
|
||||
|
||||
from mailbox_manager import enums, factories
|
||||
from mailbox_manager import factories
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -89,40 +89,3 @@ def test_models_mailboxes__secondary_email_cannot_be_null():
|
||||
"""The "secondary_email" field should not be null."""
|
||||
with pytest.raises(ValidationError, match="This field cannot be null"):
|
||||
factories.MailboxFactory(secondary_email=None)
|
||||
|
||||
|
||||
def test_models_mailboxes__cannot_be_created_for_disabled_maildomain():
|
||||
"""Mailbox creation is allowed only for a domain enabled.
|
||||
A disabled status for the mail domain raises an error."""
|
||||
with pytest.raises(
|
||||
ValidationError, match="You can create mailbox only for a domain enabled"
|
||||
):
|
||||
factories.MailboxFactory(
|
||||
domain=factories.MailDomainFactory(
|
||||
status=enums.MailDomainStatusChoices.DISABLED
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_models_mailboxes__cannot_be_created_for_failed_maildomain():
|
||||
"""Mailbox creation is allowed only for a domain enabled.
|
||||
A failed status for the mail domain raises an error."""
|
||||
with pytest.raises(
|
||||
ValidationError, match="You can create mailbox only for a domain enabled"
|
||||
):
|
||||
factories.MailboxFactory(
|
||||
domain=factories.MailDomainFactory(
|
||||
status=enums.MailDomainStatusChoices.FAILED
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_models_mailboxes__cannot_be_created_for_pending_maildomain():
|
||||
"""Mailbox creation is allowed only for a domain enabled.
|
||||
A pending status for the mail domain raises an error."""
|
||||
with pytest.raises(
|
||||
ValidationError, match="You can create mailbox only for a domain enabled"
|
||||
):
|
||||
# MailDomainFactory initializes a mail domain with default values,
|
||||
# so mail domain status is pending!
|
||||
factories.MailboxFactory(domain=factories.MailDomainFactory())
|
||||
|
||||
@@ -2,18 +2,15 @@
|
||||
Unit tests for the MailDomain model
|
||||
"""
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.text import slugify
|
||||
|
||||
import pytest
|
||||
|
||||
from core import factories as core_factories
|
||||
|
||||
from mailbox_manager import enums, factories
|
||||
from mailbox_manager import factories
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
# NAME FIELD
|
||||
|
||||
|
||||
@@ -27,84 +24,3 @@ def test_models_mail_domain__domain_name_should_not_be_null():
|
||||
"""The domain name field should not be null."""
|
||||
with pytest.raises(ValidationError, match="This field cannot be null."):
|
||||
factories.MailDomainFactory(name=None)
|
||||
|
||||
|
||||
def test_models_mail_domain__slug_inferred_from_name():
|
||||
"""Passed slug is ignored. Slug is automatically generated from name."""
|
||||
|
||||
name = "N3w_D0main-Name$.com"
|
||||
domain = factories.MailDomainFactory(name=name, slug="something else")
|
||||
assert domain.slug == slugify(name)
|
||||
|
||||
|
||||
# get_abilities
|
||||
|
||||
|
||||
def test_models_maildomains_get_abilities_anonymous():
|
||||
"""Check abilities returned for an anonymous user."""
|
||||
maildomain = factories.MailDomainFactory()
|
||||
abilities = maildomain.get_abilities(AnonymousUser())
|
||||
assert abilities == {
|
||||
"delete": False,
|
||||
"get": False,
|
||||
"patch": False,
|
||||
"put": False,
|
||||
"post": False,
|
||||
"manage_accesses": False,
|
||||
}
|
||||
|
||||
|
||||
def test_models_maildomains_get_abilities_authenticated():
|
||||
"""Check abilities returned for an authenticated user."""
|
||||
maildomain = factories.MailDomainFactory()
|
||||
abilities = maildomain.get_abilities(core_factories.UserFactory())
|
||||
assert abilities == {
|
||||
"delete": False,
|
||||
"get": False,
|
||||
"patch": False,
|
||||
"put": False,
|
||||
"post": False,
|
||||
"manage_accesses": False,
|
||||
}
|
||||
|
||||
|
||||
def test_models_maildomains_get_abilities_owner():
|
||||
"""Check abilities returned for the owner of a maildomain."""
|
||||
access = factories.MailDomainAccessFactory(role=enums.MailDomainRoleChoices.OWNER)
|
||||
abilities = access.domain.get_abilities(access.user)
|
||||
assert abilities == {
|
||||
"delete": True,
|
||||
"get": True,
|
||||
"patch": True,
|
||||
"put": True,
|
||||
"post": True,
|
||||
"manage_accesses": True,
|
||||
}
|
||||
|
||||
|
||||
def test_models_maildomains_get_abilities_administrator():
|
||||
"""Check abilities returned for the administrator of a maildomain."""
|
||||
access = factories.MailDomainAccessFactory(role=enums.MailDomainRoleChoices.ADMIN)
|
||||
abilities = access.domain.get_abilities(access.user)
|
||||
assert abilities == {
|
||||
"delete": False,
|
||||
"get": True,
|
||||
"patch": True,
|
||||
"put": True,
|
||||
"post": True,
|
||||
"manage_accesses": True,
|
||||
}
|
||||
|
||||
|
||||
def test_models_maildomains_get_abilities_viewer():
|
||||
"""Check abilities returned for the member of a mail domain. It's a viewer role."""
|
||||
access = factories.MailDomainAccessFactory(role=enums.MailDomainRoleChoices.VIEWER)
|
||||
abilities = access.domain.get_abilities(access.user)
|
||||
assert abilities == {
|
||||
"delete": False,
|
||||
"get": True,
|
||||
"patch": False,
|
||||
"put": False,
|
||||
"post": False,
|
||||
"manage_accesses": False,
|
||||
}
|
||||
|
||||
@@ -515,6 +515,12 @@ class Production(Base):
|
||||
CSRF_TRUSTED_ORIGINS = values.ListValue([])
|
||||
SECURE_BROWSER_XSS_FILTER = True
|
||||
SECURE_CONTENT_TYPE_NOSNIFF = True
|
||||
SECURE_HSTS_SECONDS = values.IntegerValue(
|
||||
default=31536000, environ_name="SECURE_HSTS_SECONDS"
|
||||
)
|
||||
SECURE_SSL_REDIRECT = values.BooleanValue(
|
||||
default=True, environ_name="SECURE_SSL_REDIRECT"
|
||||
)
|
||||
|
||||
# SECURE_PROXY_SSL_HEADER allows to fix the scheme in Django's HttpRequest
|
||||
# object when your application is behind a reverse proxy.
|
||||
|
||||
@@ -33,7 +33,7 @@ if settings.DEBUG:
|
||||
if settings.USE_SWAGGER or settings.DEBUG:
|
||||
urlpatterns += [
|
||||
path(
|
||||
f"api/{API_VERSION}/swagger.json",
|
||||
f"{API_VERSION}/swagger.json",
|
||||
SpectacularJSONAPIView.as_view(
|
||||
api_version=API_VERSION,
|
||||
urlconf="people.api_urls",
|
||||
@@ -41,12 +41,12 @@ if settings.USE_SWAGGER or settings.DEBUG:
|
||||
name="client-api-schema",
|
||||
),
|
||||
path(
|
||||
f"api/{API_VERSION}/swagger/",
|
||||
f"{API_VERSION}/swagger/",
|
||||
SpectacularSwaggerView.as_view(url_name="client-api-schema"),
|
||||
name="swagger-ui-schema",
|
||||
),
|
||||
re_path(
|
||||
f"api/{API_VERSION}/redoc/",
|
||||
f"{API_VERSION}/redoc/",
|
||||
SpectacularRedocView.as_view(url_name="client-api-schema"),
|
||||
name="redoc-schema",
|
||||
),
|
||||
|
||||
@@ -25,32 +25,32 @@ license = { file = "LICENSE" }
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"boto3==1.34.153",
|
||||
"boto3==1.34.127",
|
||||
"Brotli==1.1.0",
|
||||
"celery[redis]==5.4.0",
|
||||
"django-configurations==2.5.1",
|
||||
"django-cors-headers==4.4.0",
|
||||
"django-cors-headers==4.3.1",
|
||||
"django-countries==7.6.1",
|
||||
"django-parler==2.3",
|
||||
"redis==5.0.8",
|
||||
"redis==5.0.6",
|
||||
"django-redis==5.4.0",
|
||||
"django-storages==1.14.4",
|
||||
"django-storages==1.14.3",
|
||||
"django-timezone-field>=5.1",
|
||||
"django==5.0.8",
|
||||
"djangorestframework==3.15.2",
|
||||
"django==5.0.6",
|
||||
"djangorestframework==3.15.1",
|
||||
"drf_spectacular==0.27.2",
|
||||
"dockerflow==2024.4.2",
|
||||
"easy_thumbnails==2.9",
|
||||
"easy_thumbnails==2.8.5",
|
||||
"factory_boy==3.3.0",
|
||||
"gunicorn==22.0.0",
|
||||
"jsonschema==4.23.0",
|
||||
"jsonschema==4.22.0",
|
||||
"nested-multipart-parser==1.5.0",
|
||||
"psycopg[binary]==3.2.1",
|
||||
"PyJWT==2.9.0",
|
||||
"requests==2.32.3",
|
||||
"sentry-sdk==2.12.0",
|
||||
"psycopg[binary]==3.1.19",
|
||||
"PyJWT==2.8.0",
|
||||
"requests==2.31.0",
|
||||
"sentry-sdk==2.5.1",
|
||||
"url-normalize==1.4.3",
|
||||
"whitenoise==6.7.0",
|
||||
"whitenoise==6.6.0",
|
||||
"mozilla-django-oidc==4.0.1",
|
||||
]
|
||||
|
||||
@@ -63,20 +63,20 @@ dependencies = [
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"django-extensions==3.2.3",
|
||||
"drf-spectacular-sidecar==2024.7.1",
|
||||
"drf-spectacular-sidecar==2024.6.1",
|
||||
"ipdb==0.13.13",
|
||||
"ipython==8.26.0",
|
||||
"pyfakefs==5.6.0",
|
||||
"ipython==8.25.0",
|
||||
"pyfakefs==5.5.0",
|
||||
"pylint-django==2.5.5",
|
||||
"pylint==3.2.6",
|
||||
"pylint==3.2.3",
|
||||
"pytest-cov==5.0.0",
|
||||
"pytest-django==4.8.0",
|
||||
"pytest==8.3.2",
|
||||
"pytest==8.2.2",
|
||||
"pytest-icdiff==0.9",
|
||||
"pytest-xdist==3.6.1",
|
||||
"responses==0.25.3",
|
||||
"ruff==0.5.6",
|
||||
"types-requests==2.32.0.20240712",
|
||||
"ruff==0.4.9",
|
||||
"types-requests==2.32.0.20240602",
|
||||
"freezegun==1.5.1",
|
||||
]
|
||||
|
||||
@@ -137,22 +137,3 @@ python_files = [
|
||||
"test_*.py",
|
||||
"tests.py",
|
||||
]
|
||||
|
||||
[tool.coverage.run]
|
||||
branch = true
|
||||
omit = [
|
||||
"*/admin.py",
|
||||
"*/migrations/*",
|
||||
"*/tests/*",
|
||||
"*/urls.py",
|
||||
"manage.py",
|
||||
"celery_app.py",
|
||||
"wsgi.py",
|
||||
]
|
||||
|
||||
[tool.coverage.report]
|
||||
show_missing = true
|
||||
skip_empty = true
|
||||
|
||||
[tool.coverage.json]
|
||||
pretty_print = true
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env python
|
||||
"""Setup file for the people module. All configuration stands in the setup.cfg file."""
|
||||
# coding: utf-8
|
||||
|
||||
from setuptools import setup
|
||||
|
||||
setup()
|
||||
@@ -8,14 +8,6 @@ server {
|
||||
try_files $uri index.html $uri/ =404;
|
||||
}
|
||||
|
||||
location /teams/ {
|
||||
error_page 404 /teams/[id]/;
|
||||
}
|
||||
|
||||
location /mail-domains/ {
|
||||
error_page 404 /mail-domains/[slug]/;
|
||||
}
|
||||
|
||||
error_page 404 /404.html;
|
||||
location = /404.html {
|
||||
internal;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: 'export',
|
||||
trailingSlash: true,
|
||||
images: {
|
||||
unoptimized: true,
|
||||
},
|
||||
|
||||
@@ -15,34 +15,34 @@
|
||||
"test:watch": "jest --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@gouvfr-lasuite/integration": "1.0.2",
|
||||
"@hookform/resolvers": "3.9.0",
|
||||
"@gouvfr-lasuite/integration": "1.0.1",
|
||||
"@hookform/resolvers": "3.6.0",
|
||||
"@openfun/cunningham-react": "2.9.3",
|
||||
"@tanstack/react-query": "5.51.16",
|
||||
"i18next": "23.12.2",
|
||||
"@tanstack/react-query": "5.45.1",
|
||||
"i18next": "23.11.5",
|
||||
"lodash": "4.17.21",
|
||||
"luxon": "3.4.4",
|
||||
"next": "14.2.5",
|
||||
"next": "14.2.4",
|
||||
"react": "*",
|
||||
"react-aria-components": "1.3.1",
|
||||
"react-aria-components": "1.2.1",
|
||||
"react-dom": "*",
|
||||
"react-hook-form": "7.52.1",
|
||||
"react-i18next": "15.0.0",
|
||||
"react-hook-form": "7.52.0",
|
||||
"react-i18next": "14.1.2",
|
||||
"react-select": "5.8.0",
|
||||
"styled-components": "6.1.12",
|
||||
"styled-components": "6.1.11",
|
||||
"zod": "3.23.8",
|
||||
"zustand": "4.5.4"
|
||||
"zustand": "4.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hookform/devtools": "4.3.1",
|
||||
"@svgr/webpack": "8.1.0",
|
||||
"@tanstack/react-query-devtools": "5.51.16",
|
||||
"@testing-library/dom": "10.4.0",
|
||||
"@testing-library/jest-dom": "6.4.8",
|
||||
"@tanstack/react-query-devtools": "5.45.1",
|
||||
"@testing-library/dom": "10.1.0",
|
||||
"@testing-library/jest-dom": "6.4.6",
|
||||
"@testing-library/react": "16.0.0",
|
||||
"@testing-library/user-event": "14.5.2",
|
||||
"@types/jest": "29.5.12",
|
||||
"@types/lodash": "4.17.7",
|
||||
"@types/lodash": "4.17.5",
|
||||
"@types/luxon": "3.4.2",
|
||||
"@types/node": "*",
|
||||
"@types/react": "18.3.3",
|
||||
@@ -53,10 +53,10 @@
|
||||
"jest": "29.7.0",
|
||||
"jest-environment-jsdom": "29.7.0",
|
||||
"node-fetch": "2.7.0",
|
||||
"prettier": "3.3.3",
|
||||
"stylelint": "16.8.1",
|
||||
"stylelint-config-standard": "36.0.1",
|
||||
"stylelint-prettier": "5.0.2",
|
||||
"prettier": "3.3.2",
|
||||
"stylelint": "16.6.1",
|
||||
"stylelint-config-standard": "36.0.0",
|
||||
"stylelint-prettier": "5.0.0",
|
||||
"typescript": "*"
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 13 KiB |
@@ -28,7 +28,6 @@ export interface BoxProps {
|
||||
$position?: CSSProperties['position'];
|
||||
$radius?: CSSProperties['borderRadius'];
|
||||
$width?: CSSProperties['width'];
|
||||
$zIndex?: CSSProperties['zIndex'];
|
||||
}
|
||||
|
||||
export type BoxType = ComponentPropsWithRef<typeof Box>;
|
||||
@@ -54,5 +53,4 @@ export const Box = styled('div')<BoxProps>`
|
||||
${({ $maxWidth }) => $maxWidth && `max-width: ${$maxWidth};`}
|
||||
${({ $minWidth }) => $minWidth && `min-width: ${$minWidth};`}
|
||||
${({ $css }) => $css && `${$css};`}
|
||||
${({ $zIndex }) => $zIndex && `z-index: ${$zIndex};`}
|
||||
`;
|
||||
|
||||
@@ -2,11 +2,10 @@ import React, {
|
||||
PropsWithChildren,
|
||||
ReactNode,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Button, DialogTrigger, Popover } from 'react-aria-components';
|
||||
import styled, { createGlobalStyle } from 'styled-components';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledPopover = styled(Popover)`
|
||||
background-color: white;
|
||||
@@ -30,12 +29,6 @@ const StyledButton = styled(Button)`
|
||||
text-wrap: nowrap;
|
||||
`;
|
||||
|
||||
const GlobalStyle = createGlobalStyle`
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
`;
|
||||
|
||||
interface DropButtonProps {
|
||||
button: ReactNode;
|
||||
isOpen?: boolean;
|
||||
@@ -51,18 +44,10 @@ export const DropButton = ({
|
||||
const [opacity, setOpacity] = useState(false);
|
||||
const [isLocalOpen, setIsLocalOpen] = useState(isOpen);
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setIsLocalOpen(isOpen);
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (ref.current) {
|
||||
ref.current[isLocalOpen ? 'focus' : 'blur']();
|
||||
}
|
||||
}, [isLocalOpen]);
|
||||
|
||||
const onOpenChangeHandler = (isOpen: boolean) => {
|
||||
setIsLocalOpen(isOpen);
|
||||
onOpenChange?.(isOpen);
|
||||
@@ -72,20 +57,15 @@ export const DropButton = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<GlobalStyle />
|
||||
<DialogTrigger onOpenChange={onOpenChangeHandler} isOpen={isLocalOpen}>
|
||||
<StyledButton>{button}</StyledButton>
|
||||
<StyledPopover
|
||||
style={{ opacity: opacity ? 1 : 0 }}
|
||||
isOpen={isLocalOpen}
|
||||
onOpenChange={onOpenChangeHandler}
|
||||
>
|
||||
<div ref={ref} tabIndex={-1}>
|
||||
{children}
|
||||
</div>
|
||||
</StyledPopover>
|
||||
</DialogTrigger>
|
||||
</>
|
||||
<DialogTrigger onOpenChange={onOpenChangeHandler} isOpen={isLocalOpen}>
|
||||
<StyledButton>{button}</StyledButton>
|
||||
<StyledPopover
|
||||
style={{ opacity: opacity ? 1 : 0 }}
|
||||
isOpen={isLocalOpen}
|
||||
onOpenChange={onOpenChangeHandler}
|
||||
>
|
||||
{children}
|
||||
</StyledPopover>
|
||||
</DialogTrigger>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
import Link from 'next/link';
|
||||
import styled from 'styled-components';
|
||||
|
||||
export interface LinkProps {
|
||||
$css?: string;
|
||||
}
|
||||
|
||||
export const StyledLink = styled(Link)<LinkProps>`
|
||||
export const StyledLink = styled(Link)`
|
||||
text-decoration: none;
|
||||
color: #ffffff33;
|
||||
&[aria-current='page'] {
|
||||
color: #ffffff;
|
||||
}
|
||||
display: flex;
|
||||
${({ $css }) => $css && `${$css};`}
|
||||
`;
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import Image from 'next/image';
|
||||
import React, { PropsWithChildren } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { default as IconDevise } from '@/assets/icons/icon-devise.svg?url';
|
||||
import { default as IconMarianne } from '@/assets/icons/icon-marianne.svg?url';
|
||||
|
||||
import { Box } from './Box';
|
||||
import { Text, TextType } from './Text';
|
||||
|
||||
interface LogoGouvProps {
|
||||
imagesWidth?: number;
|
||||
textProps?: TextType;
|
||||
}
|
||||
|
||||
export const LogoGouv = ({
|
||||
imagesWidth,
|
||||
children,
|
||||
textProps,
|
||||
}: PropsWithChildren<LogoGouvProps>) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box>
|
||||
<Image
|
||||
priority
|
||||
src={IconMarianne}
|
||||
alt={t('Marianne Logo')}
|
||||
width={imagesWidth}
|
||||
/>
|
||||
</Box>
|
||||
<Text $weight="bold" {...textProps}>
|
||||
{children}
|
||||
</Text>
|
||||
<Image
|
||||
width={imagesWidth}
|
||||
priority
|
||||
src={IconDevise}
|
||||
alt={t('Freedom Equality Fraternity Logo')}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -4,6 +4,5 @@ export * from './Card';
|
||||
export * from './DropButton';
|
||||
export * from './IconOptions';
|
||||
export * from './Link';
|
||||
export * from './LogoGouv';
|
||||
export * from './Text';
|
||||
export * from './TextErrors';
|
||||
|
||||
@@ -1,27 +1,19 @@
|
||||
import { PropsWithChildren } from 'react';
|
||||
|
||||
import { Box } from '@/components';
|
||||
import { Footer } from '@/features/footer/Footer';
|
||||
import { HEADER_HEIGHT, Header } from '@/features/header';
|
||||
import { Menu } from '@/features/menu';
|
||||
|
||||
export function MainLayout({ children }: PropsWithChildren) {
|
||||
return (
|
||||
<Box>
|
||||
<Box $height="100vh">
|
||||
<Header />
|
||||
<Box $css="flex: 1;" $direction="row">
|
||||
<Menu />
|
||||
<Box
|
||||
as="main"
|
||||
$height={`calc(100vh - ${HEADER_HEIGHT})`}
|
||||
$width="100%"
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
<Box $height="100vh" $css="overflow:hidden;">
|
||||
<Header />
|
||||
<Box $css="flex: 1;" $direction="row">
|
||||
<Menu />
|
||||
<Box as="main" $height={`calc(100vh - ${HEADER_HEIGHT})`} $width="100%">
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
<Footer />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import { PropsWithChildren } from 'react';
|
||||
|
||||
import { Box } from '@/components';
|
||||
import { Footer } from '@/features/footer/Footer';
|
||||
import { Header } from '@/features/header';
|
||||
|
||||
export function PageLayout({ children }: PropsWithChildren) {
|
||||
return (
|
||||
<Box>
|
||||
<Header />
|
||||
<Box as="main" $width="100%">
|
||||
{children}
|
||||
</Box>
|
||||
<Footer />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,2 @@
|
||||
export * from './AppProvider';
|
||||
export * from './MainLayout';
|
||||
export * from './PageLayout';
|
||||
|
||||
@@ -5,15 +5,6 @@
|
||||
@import url('./cunningham-custom-tokens.css');
|
||||
@import url('../assets/fonts/Marianne/Marianne-font.css');
|
||||
|
||||
a:focus-visible,
|
||||
button:focus-visible,
|
||||
.c__button:focus-visible,
|
||||
.c__select__wrapper:focus-visible,
|
||||
.c__datagrid__header:focus-visible {
|
||||
outline: var(--c--theme--colors--primary-600) solid 2px;
|
||||
border-radius: var(--c--components--button--border-radius--focus);
|
||||
}
|
||||
|
||||
.c__input,
|
||||
.c__field,
|
||||
.c__select,
|
||||
@@ -102,20 +93,6 @@ button:focus-visible,
|
||||
0 0 2px;
|
||||
}
|
||||
|
||||
.c__input__wrapper--error.c__input__wrapper:focus-within {
|
||||
border-color: var(
|
||||
--c--components--forms-input--border--color-error-hover
|
||||
) !important;
|
||||
}
|
||||
|
||||
.c__input__wrapper--error.c__input__wrapper:focus-within label {
|
||||
color: var(--c--theme--colors--danger-600);
|
||||
}
|
||||
|
||||
.c__input__wrapper--error .labelled-box label.placeholder {
|
||||
color: var(--c--theme--colors--danger-600);
|
||||
}
|
||||
|
||||
.c__input__wrapper--error:not(.c__input__wrapper--disabled):hover label {
|
||||
color: var(--c--components--forms-input--border--color-error-hover);
|
||||
}
|
||||
@@ -323,10 +300,6 @@ input:-webkit-autofill:focus {
|
||||
transition: all 0.8s ease-in-out;
|
||||
}
|
||||
|
||||
.c__radio input:focus-visible {
|
||||
outline: var(--c--theme--colors--primary-600) solid 2px;
|
||||
}
|
||||
|
||||
/**
|
||||
* Button
|
||||
*/
|
||||
@@ -353,8 +326,7 @@ input:-webkit-autofill:focus {
|
||||
var(--c--theme--spacings--s);
|
||||
}
|
||||
|
||||
.c__button--primary,
|
||||
.c__button--primary:focus-visible {
|
||||
.c__button--primary {
|
||||
background-color: var(--c--components--button--primary--background--color);
|
||||
color: var(--c--components--button--primary--color);
|
||||
}
|
||||
@@ -484,11 +456,6 @@ input:-webkit-autofill:focus {
|
||||
/**
|
||||
* Modal
|
||||
*/
|
||||
.c__modal:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.c__modal__backdrop {
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
@@ -2,9 +2,10 @@ import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { APIError, errorCauses, fetchAPI } from '@/api';
|
||||
import { User } from '@/core/auth';
|
||||
import { Role, Team } from '@/features/teams/team-management';
|
||||
import { Invitation } from '@/features/members';
|
||||
import { Role, Team } from '@/features/teams';
|
||||
|
||||
import { Invitation, OptionType } from '../types';
|
||||
import { OptionType } from '../types';
|
||||
|
||||
interface CreateInvitationParams {
|
||||
email: User['email'];
|
||||
@@ -2,16 +2,8 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { APIError, errorCauses, fetchAPI } from '@/api';
|
||||
import { User } from '@/core/auth';
|
||||
import {
|
||||
Access,
|
||||
KEY_LIST_TEAM_ACCESSES,
|
||||
} from '@/features/teams/member-management';
|
||||
import {
|
||||
KEY_LIST_TEAM,
|
||||
KEY_TEAM,
|
||||
Role,
|
||||
Team,
|
||||
} from '@/features/teams/team-management';
|
||||
import { Access, KEY_LIST_TEAM_ACCESSES } from '@/features/members';
|
||||
import { KEY_LIST_TEAM, KEY_TEAM, Role, Team } from '@/features/teams';
|
||||
|
||||
import { OptionType } from '../types';
|
||||
|
||||
@@ -2,7 +2,7 @@ import { UseQueryOptions, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { APIError, APIList, errorCauses, fetchAPI } from '@/api';
|
||||
import { User } from '@/core/auth';
|
||||
import { Team } from '@/features/teams/team-management';
|
||||
import { Team } from '@/features/teams';
|
||||
|
||||
export type UsersParams = {
|
||||
query: string;
|
||||
|
Before Width: | Height: | Size: 925 B After Width: | Height: | Size: 925 B |
@@ -12,8 +12,8 @@ import { createGlobalStyle } from 'styled-components';
|
||||
import { APIError } from '@/api';
|
||||
import { Box, Text } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { ChooseRole } from '@/features/teams/member-management';
|
||||
import { Role, Team } from '@/features/teams/team-management';
|
||||
import { ChooseRole } from '@/features/members';
|
||||
import { Role, Team } from '@/features/teams';
|
||||
|
||||
import { useCreateInvitation, useCreateTeamAccess } from '../api';
|
||||
import IconAddMember from '../assets/add-member.svg';
|
||||
@@ -158,17 +158,13 @@ export const ModalAddMembers = ({
|
||||
disabled={!selectedMembers.length || isPending}
|
||||
onClick={() => void handleValidate()}
|
||||
>
|
||||
{t('Add to group')}
|
||||
{t('Validate')}
|
||||
</Button>
|
||||
}
|
||||
size={ModalSize.MEDIUM}
|
||||
title={
|
||||
<Box $align="center" $gap="1rem">
|
||||
<IconAddMember
|
||||
width={48}
|
||||
color={colorsTokens()['primary-text']}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<IconAddMember width={48} color={colorsTokens()['primary-text']} />
|
||||
<Text $size="h3" $margin="none">
|
||||
{t('Add a member')}
|
||||
</Text>
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Options } from 'react-select';
|
||||
import AsyncSelect from 'react-select/async';
|
||||
|
||||
import { Team } from '@/features/teams/team-management';
|
||||
import { Team } from '@/features/teams';
|
||||
import { isValidEmail } from '@/utils';
|
||||
|
||||
import { KEY_LIST_USER, useUsers } from '../api/useUsers';
|
||||
@@ -1,7 +1,5 @@
|
||||
import { User } from '@/core/auth';
|
||||
|
||||
import { Role, Team } from '../team-management';
|
||||
|
||||
export enum OptionType {
|
||||
INVITATION = 'invitation',
|
||||
NEW_MEMBER = 'new_member',
|
||||
@@ -26,13 +24,3 @@ export interface OptionNewMember {
|
||||
}
|
||||
|
||||
export type OptionSelect = OptionNewMember | OptionInvitation;
|
||||
|
||||
export interface Invitation {
|
||||
id: string;
|
||||
created_at: string;
|
||||
email: string;
|
||||
team: Team['id'];
|
||||
role: Role;
|
||||
issuer: User['id'];
|
||||
is_expired: boolean;
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { Box, LogoGouv, StyledLink, Text } from '@/components';
|
||||
|
||||
import IconLink from './assets/external-link.svg';
|
||||
|
||||
const BlueStripe = styled.div`
|
||||
position: absolute;
|
||||
height: 2px;
|
||||
width: 100%;
|
||||
background: var(--c--theme--colors--primary-600);
|
||||
top: 0;
|
||||
`;
|
||||
|
||||
export const Footer = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Box $position="relative" as="footer">
|
||||
<BlueStripe />
|
||||
<Box $padding={{ top: 'large', horizontal: 'big', bottom: 'small' }}>
|
||||
<Box
|
||||
$direction="row"
|
||||
$gap="1.5rem"
|
||||
$align="center"
|
||||
$justify="space-between"
|
||||
$css="flex-wrap: wrap;"
|
||||
>
|
||||
<Box>
|
||||
<LogoGouv
|
||||
textProps={{
|
||||
$size: '1.2rem',
|
||||
$margin: { vertical: '0.3rem' },
|
||||
$css: `
|
||||
line-height:18px;
|
||||
text-transform: uppercase;
|
||||
`,
|
||||
$maxWidth: '100px',
|
||||
}}
|
||||
imagesWidth={66}
|
||||
>
|
||||
République Française
|
||||
</LogoGouv>
|
||||
</Box>
|
||||
<Box
|
||||
$direction="row"
|
||||
$css={`
|
||||
column-gap: 1.5rem;
|
||||
row-gap: .5rem;
|
||||
flex-wrap: wrap;
|
||||
`}
|
||||
>
|
||||
{[
|
||||
{
|
||||
label: 'legifrance.gouv.fr',
|
||||
href: 'https://legifrance.gouv.fr/',
|
||||
},
|
||||
{
|
||||
label: 'info.gouv.fr',
|
||||
href: 'https://info.gouv.fr/',
|
||||
},
|
||||
{
|
||||
label: 'service-public.fr',
|
||||
href: 'https://service-public.fr/',
|
||||
},
|
||||
{
|
||||
label: 'data.gouv.fr',
|
||||
href: 'https://data.gouv.fr/',
|
||||
},
|
||||
].map(({ label, href }) => (
|
||||
<StyledLink
|
||||
key={label}
|
||||
href={href}
|
||||
target="__blank"
|
||||
$css={`
|
||||
gap:0.2rem;
|
||||
transition: box-shadow 0.3s;
|
||||
&:hover {
|
||||
box-shadow: 0px 2px 0 0 var(--c--theme--colors--greyscale-text);
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Text $weight="bold">{label}</Text>
|
||||
<IconLink width={18} aria-hidden="true" />
|
||||
</StyledLink>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box
|
||||
$direction="row"
|
||||
$margin={{ top: 'big' }}
|
||||
$padding={{ top: 'tiny' }}
|
||||
$css="
|
||||
flex-wrap: wrap;
|
||||
border-top: 1px solid var(--c--theme--colors--greyscale-200);
|
||||
column-gap: 1rem;
|
||||
row-gap: .5rem;
|
||||
"
|
||||
>
|
||||
{[
|
||||
{
|
||||
label: t('Legal Notice'),
|
||||
href: '/legal-notice',
|
||||
},
|
||||
{
|
||||
label: t('Personal data and cookies'),
|
||||
href: '/personal-data-cookies',
|
||||
},
|
||||
{
|
||||
label: t('Accessibility: non-compliant'),
|
||||
href: '/accessibility',
|
||||
},
|
||||
].map(({ label, href }) => (
|
||||
<StyledLink
|
||||
key={label}
|
||||
href={href}
|
||||
$css={`
|
||||
padding-right: 1rem;
|
||||
&:not(:last-child) {
|
||||
box-shadow: inset -1px 0px 0px 0px var(--c--theme--colors--greyscale-200);
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Text
|
||||
$variation="600"
|
||||
$size="m"
|
||||
$css={`
|
||||
transition: box-shadow 0.3s;
|
||||
&:hover {
|
||||
box-shadow: 0px 2px 0 0 var(--c--theme--colors--greyscale-text);
|
||||
}
|
||||
`}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</StyledLink>
|
||||
))}
|
||||
</Box>
|
||||
<Text
|
||||
as="p"
|
||||
$size="m"
|
||||
$margin={{ top: 'big' }}
|
||||
$variation="600"
|
||||
$display="inline"
|
||||
>
|
||||
<Trans>
|
||||
Unless otherwise stated, all content on this site is under
|
||||
<StyledLink
|
||||
href="https://github.com/etalab/licence-ouverte/blob/master/LO.md"
|
||||
target="__blank"
|
||||
$css={`
|
||||
display:inline-flex;
|
||||
box-shadow: 0px 1px 0 0 var(--c--theme--colors--greyscale-text);
|
||||
margin-left: 0.3rem;
|
||||
`}
|
||||
>
|
||||
<Text $variation="600">licence etalab-2.0</Text>
|
||||
<IconLink width={18} aria-hidden="true" />
|
||||
</StyledLink>
|
||||
</Trans>
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M10 6v2H5v11h11v-5h2v6a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h6Zm11-3v8h-2V6.413l-7.793 7.794-1.414-1.414L17.585 5H13V3h8Z"
|
||||
/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 219 B |
@@ -15,7 +15,7 @@ export const AccountDropdown = () => {
|
||||
button={
|
||||
<Box $flex $direction="row" $align="center">
|
||||
<Text $theme="primary">{t('My account')}</Text>
|
||||
<Text className="material-icons" $theme="primary" aria-hidden="true">
|
||||
<Text className="material-icons" $theme="primary">
|
||||
arrow_drop_down
|
||||
</Text>
|
||||
</Box>
|
||||
@@ -24,11 +24,7 @@ export const AccountDropdown = () => {
|
||||
<Button
|
||||
onClick={logout}
|
||||
color="primary-text"
|
||||
icon={
|
||||
<span className="material-icons" aria-hidden="true">
|
||||
logout
|
||||
</span>
|
||||
}
|
||||
icon={<span className="material-icons">logout</span>}
|
||||
aria-label={t('Logout')}
|
||||
>
|
||||
<Text $weight="normal">{t('Logout')}</Text>
|
||||
|
||||
@@ -3,13 +3,15 @@ import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { Box, LogoGouv, StyledLink, Text } from '@/components/';
|
||||
import { Box, Text } from '@/components/';
|
||||
import { AccountDropdown } from '@/features/header/AccountDropdown';
|
||||
import { LaGaufre } from '@/features/header/LaGaufre';
|
||||
|
||||
import { LanguagePicker } from '../language/';
|
||||
|
||||
import { AccountDropdown } from './AccountDropdown';
|
||||
import { default as IconApplication } from './assets/icon-application.svg?url';
|
||||
import { default as IconGouv } from './assets/icon-gouv.svg?url';
|
||||
import { default as IconMarianne } from './assets/icon-marianne.svg?url';
|
||||
|
||||
export const HEADER_HEIGHT = '100px';
|
||||
|
||||
@@ -21,18 +23,22 @@ const RedStripe = styled.div`
|
||||
top: 0;
|
||||
`;
|
||||
|
||||
const StyledHeader = styled.header`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
height: ${HEADER_HEIGHT};
|
||||
width: 100%;
|
||||
background: white;
|
||||
box-shadow: 0 1px 4px #00000040;
|
||||
z-index: 100;
|
||||
`;
|
||||
|
||||
export const Header = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Box
|
||||
as="header"
|
||||
$justify="center"
|
||||
$width="100%"
|
||||
$height={HEADER_HEIGHT}
|
||||
$zIndex="100"
|
||||
$css="box-shadow: 0 1px 4px #00000040;"
|
||||
>
|
||||
<StyledHeader>
|
||||
<RedStripe />
|
||||
<Box
|
||||
$margin={{ horizontal: 'xbig' }}
|
||||
@@ -40,35 +46,28 @@ export const Header = () => {
|
||||
$justify="space-between"
|
||||
$direction="row"
|
||||
>
|
||||
<Box $align="center" $gap="6rem" $direction="row">
|
||||
<LogoGouv
|
||||
textProps={{
|
||||
$size: 't',
|
||||
$css: `
|
||||
line-height:11px;
|
||||
text-transform: uppercase;
|
||||
`,
|
||||
$margin: { vertical: '3px' },
|
||||
$maxWidth: '100px',
|
||||
}}
|
||||
>
|
||||
République Française
|
||||
</LogoGouv>
|
||||
<StyledLink href="/">
|
||||
<Box>
|
||||
<Image priority src={IconMarianne} alt={t('Marianne Logo')} />
|
||||
<Box $align="center" $gap="6rem" $direction="row">
|
||||
<Image
|
||||
priority
|
||||
src={IconGouv}
|
||||
alt={t('Freedom Equality Fraternity Logo')}
|
||||
/>
|
||||
<Box $align="center" $gap="1rem" $direction="row">
|
||||
<Image priority src={IconApplication} alt="" />
|
||||
<Image priority src={IconApplication} alt={t('Equipes Logo')} />
|
||||
<Text $margin="none" as="h2" $theme="primary">
|
||||
{t('Régie')}
|
||||
{t('Equipes Test')}
|
||||
</Text>
|
||||
</Box>
|
||||
</StyledLink>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box $align="center" $gap="1rem" $direction="row">
|
||||
<Box $align="center" $gap="1rem" $justify="flex-end" $direction="row">
|
||||
<AccountDropdown />
|
||||
<LanguagePicker />
|
||||
<LaGaufre />
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</StyledHeader>
|
||||
);
|
||||
};
|
||||
|
||||
|
After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
@@ -30,11 +30,6 @@ const SelectStyled = styled(Select)<{ $isSmall?: boolean }>`
|
||||
&:hover {
|
||||
border-color: var(--c--theme--colors--primary-500);
|
||||
}
|
||||
|
||||
.c__button--tertiary-text:focus-visible {
|
||||
outline: var(--c--theme--colors--primary-600) solid 2px;
|
||||
border-radius: var(--c--components--button--border-radius--focus);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -53,12 +48,12 @@ export const LanguagePicker = () => {
|
||||
$gap="0.7rem"
|
||||
$align="center"
|
||||
>
|
||||
<Image priority src={IconLanguage} alt="" />
|
||||
<Image priority src={IconLanguage} alt={t('Language Icon')} />
|
||||
<Text $theme="primary">{lang.toUpperCase()}</Text>
|
||||
</Box>
|
||||
),
|
||||
}));
|
||||
}, [languages]);
|
||||
}, [languages, t]);
|
||||
|
||||
return (
|
||||
<SelectStyled
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { UUID } from 'crypto';
|
||||
|
||||
import {
|
||||
UseMutationOptions,
|
||||
useMutation,
|
||||
@@ -13,14 +15,15 @@ export interface CreateMailboxParams {
|
||||
last_name: string;
|
||||
local_part: string;
|
||||
secondary_email: string;
|
||||
mailDomainSlug: string;
|
||||
phone_number: string;
|
||||
mailDomainId: UUID;
|
||||
}
|
||||
|
||||
export const createMailbox = async ({
|
||||
mailDomainSlug,
|
||||
mailDomainId,
|
||||
...data
|
||||
}: CreateMailboxParams): Promise<void> => {
|
||||
const response = await fetchAPI(`mail-domains/${mailDomainSlug}/mailboxes/`, {
|
||||
const response = await fetchAPI(`mail-domains/${mailDomainId}/mailboxes/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
@@ -34,7 +37,7 @@ export const createMailbox = async ({
|
||||
}
|
||||
};
|
||||
|
||||
type UseCreateMailboxParams = { mailDomainSlug: string } & UseMutationOptions<
|
||||
type UseCreateMailboxParams = { domainId: UUID } & UseMutationOptions<
|
||||
void,
|
||||
APIError,
|
||||
CreateMailboxParams
|
||||
@@ -46,10 +49,7 @@ export function useCreateMailbox(options: UseCreateMailboxParams) {
|
||||
mutationFn: createMailbox,
|
||||
onSuccess: (data, variables, context) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [
|
||||
KEY_LIST_MAILBOX,
|
||||
{ mailDomainSlug: variables.mailDomainSlug },
|
||||
],
|
||||
queryKey: [KEY_LIST_MAILBOX, { id: variables.mailDomainId }],
|
||||
});
|
||||
if (options?.onSuccess) {
|
||||
options.onSuccess(data, variables, context);
|
||||
|
||||
@@ -5,19 +5,19 @@ import { APIError, errorCauses, fetchAPI } from '@/api';
|
||||
import { MailDomain } from '../types';
|
||||
|
||||
export type MailDomainParams = {
|
||||
slug: string;
|
||||
id: string;
|
||||
};
|
||||
|
||||
type MailDomainResponse = MailDomain;
|
||||
|
||||
export const getMailDomain = async ({
|
||||
slug,
|
||||
id,
|
||||
}: MailDomainParams): Promise<MailDomainResponse> => {
|
||||
const response = await fetchAPI(`mail-domains/${slug}`);
|
||||
const response = await fetchAPI(`mail-domains/${id}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new APIError(
|
||||
`Failed to get the mail domain ${slug}`,
|
||||
`Failed to get the mail domain ${id}`,
|
||||
await errorCauses(response),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { APIError, APIList, errorCauses, fetchAPI } from '@/api';
|
||||
import { MailDomainMailbox } from '../types';
|
||||
|
||||
export type MailDomainMailboxesParams = {
|
||||
mailDomainSlug: string;
|
||||
id: string;
|
||||
page: number;
|
||||
ordering?: string;
|
||||
};
|
||||
@@ -13,11 +13,11 @@ export type MailDomainMailboxesParams = {
|
||||
type MailDomainMailboxesResponse = APIList<MailDomainMailbox>;
|
||||
|
||||
export const getMailDomainMailboxes = async ({
|
||||
mailDomainSlug,
|
||||
id,
|
||||
page,
|
||||
ordering,
|
||||
}: MailDomainMailboxesParams): Promise<MailDomainMailboxesResponse> => {
|
||||
let url = `mail-domains/${mailDomainSlug}/mailboxes/?page=${page}`;
|
||||
let url = `mail-domains/${id}/mailboxes/?page=${page}`;
|
||||
|
||||
if (ordering) {
|
||||
url += '&ordering=' + ordering;
|
||||
@@ -27,7 +27,7 @@ export const getMailDomainMailboxes = async ({
|
||||
|
||||
if (!response.ok) {
|
||||
throw new APIError(
|
||||
`Failed to get the mailboxes of mail domain ${mailDomainSlug}`,
|
||||
`Failed to get the mailboxes of mail domain ${id}`,
|
||||
await errorCauses(response),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M21.34 26.04C20.9 26.02 20.46 26 20 26C15.16 26 10.64 27.34 6.78 29.64C5.02 30.68 4 32.64 4 34.7V40H22.52C20.94 37.74 20 34.98 20 32C20 29.86 20.5 27.86 21.34 26.04Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M20 24C24.4183 24 28 20.4183 28 16C28 11.5817 24.4183 8 20 8C15.5817 8 12 11.5817 12 16C12 20.4183 15.5817 24 20 24Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M33 24C28.032 24 24 28.032 24 33C24 37.968 28.032 42 33 42C37.968 42 42 37.968 42 33C42 28.032 37.968 24 33 24ZM36.6 33.9H33.9V36.6C33.9 37.095 33.495 37.5 33 37.5C32.505 37.5 32.1 37.095 32.1 36.6V33.9H29.4C28.905 33.9 28.5 33.495 28.5 33C28.5 32.505 28.905 32.1 29.4 32.1H32.1V29.4C32.1 28.905 32.505 28.5 33 28.5C33.495 28.5 33.9 28.905 33.9 29.4V32.1H36.6C37.095 32.1 37.5 32.505 37.5 33C37.5 33.495 37.095 33.9 36.6 33.9Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 925 B |
@@ -46,11 +46,10 @@ function formatSortModel(
|
||||
|
||||
export function MailDomainsContent({ mailDomain }: { mailDomain: MailDomain }) {
|
||||
const [sortModel, setSortModel] = useState<SortModel>([]);
|
||||
const { t } = useTranslation();
|
||||
const [isCreateMailboxFormVisible, setIsCreateMailboxFormVisible] =
|
||||
useState(false);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const pagination = usePagination({
|
||||
defaultPage: 1,
|
||||
pageSize: PAGE_SIZE,
|
||||
@@ -60,7 +59,7 @@ export function MailDomainsContent({ mailDomain }: { mailDomain: MailDomain }) {
|
||||
const ordering = sortModel.length ? formatSortModel(sortModel[0]) : undefined;
|
||||
|
||||
const { data, isLoading, error } = useMailboxes({
|
||||
mailDomainSlug: mailDomain.slug,
|
||||
id: mailDomain.id,
|
||||
page,
|
||||
ordering,
|
||||
});
|
||||
@@ -85,7 +84,7 @@ export function MailDomainsContent({ mailDomain }: { mailDomain: MailDomain }) {
|
||||
{isCreateMailboxFormVisible && mailDomain ? (
|
||||
<CreateMailboxForm
|
||||
mailDomain={mailDomain}
|
||||
closeModal={() => setIsCreateMailboxFormVisible(false)}
|
||||
setIsFormVisible={setIsCreateMailboxFormVisible}
|
||||
/>
|
||||
) : null}
|
||||
<TopBanner
|
||||
@@ -114,10 +113,6 @@ export function MailDomainsContent({ mailDomain }: { mailDomain: MailDomain }) {
|
||||
displayGoto: false,
|
||||
}}
|
||||
aria-label={t('Mailboxes list')}
|
||||
hideEmptyPlaceholderImage={true}
|
||||
emptyPlaceholderLabel={t(
|
||||
'No mail box was created with this mail domain.',
|
||||
)}
|
||||
/>
|
||||
</Card>
|
||||
</>
|
||||
@@ -141,7 +136,7 @@ const TopBanner = ({
|
||||
$margin={{ all: 'big', vertical: 'xbig' }}
|
||||
$gap="2.25rem"
|
||||
>
|
||||
<MailDomainsLogo aria-hidden="true" />
|
||||
<MailDomainsLogo aria-label={t('Mail Domains icon')} />
|
||||
<Text $margin="none" as="h3" $size="h3">
|
||||
{name}
|
||||
</Text>
|
||||
|
||||
@@ -15,54 +15,35 @@ import {
|
||||
useForm,
|
||||
} from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { createGlobalStyle } from 'styled-components';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Box, Text, TextErrors } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
|
||||
import { CreateMailboxParams, useCreateMailbox } from '../../api';
|
||||
import IconCreateMailbox from '../../assets/create-mailbox.svg';
|
||||
import { MailDomain } from '../../types';
|
||||
|
||||
const FORM_ID: string = 'form-create-mailbox';
|
||||
|
||||
const GlobalStyle = createGlobalStyle`
|
||||
.c__field__footer__top > .c__field__text {
|
||||
text-align: left;
|
||||
white-space: pre-line;
|
||||
}
|
||||
`;
|
||||
const createMailboxValidationSchema = z.object({
|
||||
first_name: z.string().min(1),
|
||||
last_name: z.string().min(1),
|
||||
local_part: z.string().min(1),
|
||||
secondary_email: z.string().min(1),
|
||||
phone_number: z.string().min(1),
|
||||
});
|
||||
|
||||
export const CreateMailboxForm = ({
|
||||
mailDomain,
|
||||
closeModal,
|
||||
setIsFormVisible,
|
||||
}: {
|
||||
mailDomain: MailDomain;
|
||||
closeModal: () => void;
|
||||
setIsFormVisible: (value: boolean) => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToastProvider();
|
||||
|
||||
const messageInvalidMinChar = t('You must have minimum 1 character');
|
||||
|
||||
const createMailboxValidationSchema = z.object({
|
||||
first_name: z.string().min(1, t('Please enter your first name')),
|
||||
last_name: z.string().min(1, t('Please enter your last name')),
|
||||
local_part: z
|
||||
.string()
|
||||
.regex(
|
||||
/^((?!@|\s)([a-zA-Z0-9.\-]))*$/,
|
||||
t(
|
||||
'It must not contain spaces, accents or special characters (except "." or "-"). E.g.: jean.dupont',
|
||||
),
|
||||
)
|
||||
.min(1, messageInvalidMinChar),
|
||||
secondary_email: z
|
||||
.string()
|
||||
.regex(
|
||||
/[^@\s]+@[^@\s]+\.[^@\s]+/,
|
||||
t('Please enter a valid email address.\nE.g. : jean.dupont@mail.fr'),
|
||||
),
|
||||
});
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
|
||||
const methods = useForm<CreateMailboxParams>({
|
||||
delayError: 0,
|
||||
@@ -71,44 +52,33 @@ export const CreateMailboxForm = ({
|
||||
last_name: '',
|
||||
local_part: '',
|
||||
secondary_email: '',
|
||||
phone_number: '',
|
||||
},
|
||||
mode: 'onChange',
|
||||
reValidateMode: 'onChange',
|
||||
resolver: zodResolver(createMailboxValidationSchema),
|
||||
});
|
||||
|
||||
const { mutate: createMailbox, error } = useCreateMailbox({
|
||||
mailDomainSlug: mailDomain.slug,
|
||||
const { mutate: createMailbox, ...queryState } = useCreateMailbox({
|
||||
domainId: mailDomain.id,
|
||||
onSuccess: () => {
|
||||
toast(t('Mailbox created!'), VariantType.SUCCESS, {
|
||||
duration: 4000,
|
||||
});
|
||||
|
||||
closeModal();
|
||||
setIsFormVisible(false);
|
||||
},
|
||||
});
|
||||
|
||||
const closeModal = () => setIsFormVisible(false);
|
||||
|
||||
const onSubmitCallback = (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
void methods.handleSubmit((data) =>
|
||||
createMailbox({ ...data, mailDomainSlug: mailDomain.slug }),
|
||||
createMailbox({ ...data, mailDomainId: mailDomain.id }),
|
||||
)();
|
||||
};
|
||||
|
||||
const causes = error?.cause?.filter((cause) => {
|
||||
const isFound =
|
||||
cause === 'Mailbox with this Local_part and Domain already exists.';
|
||||
|
||||
if (isFound) {
|
||||
methods.setError('local_part', {
|
||||
type: 'manual',
|
||||
message: t('This email prefix is already used.'),
|
||||
});
|
||||
}
|
||||
|
||||
return !isFound;
|
||||
});
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<Modal
|
||||
@@ -134,35 +104,27 @@ export const CreateMailboxForm = ({
|
||||
form={FORM_ID}
|
||||
disabled={methods.formState.isSubmitting}
|
||||
>
|
||||
{t('Create the mailbox')}
|
||||
{t('Submit')}
|
||||
</Button>
|
||||
}
|
||||
size={ModalSize.MEDIUM}
|
||||
title={
|
||||
<Text
|
||||
$size="h3"
|
||||
$margin="none"
|
||||
role="heading"
|
||||
aria-level={3}
|
||||
title={t('Create a mailbox')}
|
||||
>
|
||||
{t('Create a mailbox')}
|
||||
</Text>
|
||||
<Box $align="center" $gap="1rem">
|
||||
<IconCreateMailbox
|
||||
width={48}
|
||||
color={colorsTokens()['primary-text']}
|
||||
title={t('Mailbox creation form')}
|
||||
/>
|
||||
<Text $size="h3" $margin="none" role="heading" aria-level={3}>
|
||||
{t('Create a mailbox')}
|
||||
</Text>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<GlobalStyle />
|
||||
<Box $width="100%" $margin={{ top: 'none', bottom: 'xl' }}>
|
||||
{!!causes?.length && (
|
||||
<TextErrors $margin={{ bottom: 'small' }} causes={causes} />
|
||||
<Box $width="100%" $margin={{ top: 'large', bottom: 'xl' }}>
|
||||
{queryState.isError && (
|
||||
<TextErrors className="mb-s" causes={queryState.error.cause} />
|
||||
)}
|
||||
<Text
|
||||
$margin={{ horizontal: 'none', vertical: 'big' }}
|
||||
$size="m"
|
||||
$display="inline-block"
|
||||
$textAlign="left"
|
||||
>
|
||||
{t('All fields are mandatory.')}
|
||||
</Text>
|
||||
{methods ? (
|
||||
<Form
|
||||
methods={methods}
|
||||
@@ -191,39 +153,76 @@ const Form = ({
|
||||
<form onSubmit={onSubmitCallback} id={FORM_ID}>
|
||||
<Box $direction="column" $width="100%" $gap="2rem" $margin="auto">
|
||||
<Box $margin={{ horizontal: 'none' }}>
|
||||
<FieldMailBox
|
||||
<Controller
|
||||
control={methods.control}
|
||||
name="first_name"
|
||||
label={t('First name')}
|
||||
methods={methods}
|
||||
render={({ fieldState }) => (
|
||||
<Input
|
||||
aria-invalid={!!fieldState.error}
|
||||
label={t('First name')}
|
||||
state={fieldState.error ? 'error' : 'default'}
|
||||
text={
|
||||
fieldState.error
|
||||
? t('Please enter your first name')
|
||||
: undefined
|
||||
}
|
||||
{...methods.register('first_name')}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box $margin={{ horizontal: 'none' }}>
|
||||
<FieldMailBox
|
||||
<Controller
|
||||
control={methods.control}
|
||||
name="last_name"
|
||||
label={t('Last name')}
|
||||
methods={methods}
|
||||
render={({ fieldState }) => (
|
||||
<Input
|
||||
aria-invalid={!!fieldState.error}
|
||||
label={t('Last name')}
|
||||
state={fieldState.error ? 'error' : 'default'}
|
||||
text={
|
||||
fieldState.error
|
||||
? t('Please enter your last name')
|
||||
: undefined
|
||||
}
|
||||
{...methods.register('last_name')}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box $margin={{ horizontal: 'none' }} $direction="row">
|
||||
<Box $width="65%">
|
||||
<FieldMailBox
|
||||
<Controller
|
||||
control={methods.control}
|
||||
name="local_part"
|
||||
label={t('Email address prefix')}
|
||||
methods={methods}
|
||||
text={t(
|
||||
'It must not contain spaces, accents or special characters (except "." or "-"). E.g.: jean.dupont',
|
||||
render={({ fieldState }) => (
|
||||
<Input
|
||||
aria-invalid={!!fieldState.error}
|
||||
label={t('Main email address')}
|
||||
state={fieldState.error ? 'error' : 'default'}
|
||||
text={
|
||||
fieldState.error
|
||||
? t(
|
||||
'Please enter the first part of the email address, without including "@" in it',
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
{...methods.register('local_part')}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Text
|
||||
as="span"
|
||||
$theme="primary"
|
||||
$size="1rem"
|
||||
$display="inline-block"
|
||||
$css={`
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
left: 1rem;
|
||||
top: 1rem;
|
||||
`}
|
||||
@@ -233,41 +232,45 @@ const Form = ({
|
||||
</Box>
|
||||
|
||||
<Box $margin={{ horizontal: 'none' }}>
|
||||
<FieldMailBox
|
||||
<Controller
|
||||
control={methods.control}
|
||||
name="secondary_email"
|
||||
label={t('Secondary email address')}
|
||||
methods={methods}
|
||||
text={t('E.g. : jean.dupont@mail.fr')}
|
||||
render={({ fieldState }) => (
|
||||
<Input
|
||||
aria-invalid={!!fieldState.error}
|
||||
label={t('Secondary email address')}
|
||||
state={fieldState.error ? 'error' : 'default'}
|
||||
text={
|
||||
fieldState.error
|
||||
? t('Please enter your secondary email address')
|
||||
: undefined
|
||||
}
|
||||
{...methods.register('secondary_email')}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box $margin={{ horizontal: 'none' }}>
|
||||
<Controller
|
||||
control={methods.control}
|
||||
name="phone_number"
|
||||
render={({ fieldState }) => (
|
||||
<Input
|
||||
aria-invalid={!!fieldState.error}
|
||||
label={t('Phone number')}
|
||||
state={fieldState.error ? 'error' : 'default'}
|
||||
text={
|
||||
fieldState.error
|
||||
? t('Please enter your phone number')
|
||||
: undefined
|
||||
}
|
||||
{...methods.register('phone_number')}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
interface FieldMailBoxProps {
|
||||
name: 'first_name' | 'last_name' | 'local_part' | 'secondary_email';
|
||||
label: string;
|
||||
methods: UseFormReturn<CreateMailboxParams>;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
const FieldMailBox = ({ name, label, methods, text }: FieldMailBoxProps) => {
|
||||
return (
|
||||
<Controller
|
||||
control={methods.control}
|
||||
name={name}
|
||||
render={({ fieldState }) => (
|
||||
<Input
|
||||
aria-invalid={!!fieldState.error}
|
||||
aria-required
|
||||
required
|
||||
label={label}
|
||||
state={fieldState.error ? 'error' : 'default'}
|
||||
text={fieldState?.error?.message ? fieldState.error.message : text}
|
||||
{...methods.register(name)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -44,7 +44,7 @@ export const ItemList = () => {
|
||||
scrollContainer={containerRef.current}
|
||||
as="ul"
|
||||
$margin={{ top: 'none' }}
|
||||
$padding="none"
|
||||
$padding={{ all: 'none' }}
|
||||
role="listbox"
|
||||
>
|
||||
<ItemListState
|
||||
@@ -76,7 +76,7 @@ const ItemListState = ({
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box $align="center" $margin="large">
|
||||
<Box $align="center" $margin={{ all: 'large' }}>
|
||||
<Loader aria-label={t('mail domains list loading')} />
|
||||
</Box>
|
||||
);
|
||||
@@ -84,8 +84,13 @@ const ItemListState = ({
|
||||
|
||||
if (!mailDomains?.length) {
|
||||
return (
|
||||
<Box $justify="center" $margin="small">
|
||||
<Text as="p" $margin={{ vertical: 'none' }}>
|
||||
<Box $justify="center" $margin={{ all: 'small' }}>
|
||||
<Text
|
||||
as="p"
|
||||
$margin={{ vertical: 'none' }}
|
||||
$theme="greyscale"
|
||||
$variation="500"
|
||||
>
|
||||
{t(`0 mail domain to display.`)}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
@@ -53,7 +53,7 @@ export const Panel = () => {
|
||||
`}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
>
|
||||
<IconOpenClose width={24} height={24} aria-hidden="true" />
|
||||
<IconOpenClose width={24} height={24} />
|
||||
</BoxButton>
|
||||
<Box
|
||||
$css={`
|
||||
|
||||