♻️(backend) refactor post hackathon to a first working version

This project was copied and hacked to make a POC in a 2-day hackathon.
We need to clean and refactor things in order to get a first version
of the product we want.
This commit is contained in:
Samuel Paccoud - DINUM
2024-02-09 19:32:12 +01:00
committed by Samuel Paccoud
parent 0a3e26486e
commit 0f9327a1de
41 changed files with 2259 additions and 2567 deletions

View File

@@ -152,7 +152,6 @@ test-back-parallel: ## run all back-end tests in parallel
bin/pytest -n auto $${args:-${1}}
.PHONY: test-back-parallel
makemigrations: ## run django makemigrations for the publish project.
@echo "$(BOLD)Running makemigrations$(RESET)"
@$(COMPOSE) up -d postgresql

View File

@@ -1,6 +1,6 @@
# Publish
publish is an application to handle users and teams.
publish is an application to handle users and templates.
publish is built on top of [Django Rest
Framework](https://www.django-rest-framework.org/).

View File

@@ -172,7 +172,7 @@ ignore-on-opaque-inference=yes
# for classes with dynamically set attributes). This supports the use of
# qualified names.
ignored-classes=optparse.Values,thread._local,_thread._local,responses,
Team,Contact
Template,Contact
# List of module names for which member attributes should not be checked
# (useful for modules/projects where namespaces are manipulated during runtime

View File

@@ -1,22 +1,11 @@
"""Admin classes and registrations for Magnify's core app."""
"""Admin classes and registrations for core app."""
from django.contrib import admin
from django.contrib.auth import admin as auth_admin
from django.utils.translation import gettext_lazy as _
from . import models
class IdentityInline(admin.TabularInline):
"""Inline admin class for user identities."""
model = models.Identity
extra = 0
@admin.register(models.User)
class UserAdmin(admin.ModelAdmin):
"""User admin interface declaration."""
inlines = (IdentityInline,)
class TemplateAccessInline(admin.TabularInline):
"""Inline admin class for template accesses."""
@@ -25,6 +14,65 @@ class TemplateAccessInline(admin.TabularInline):
extra = 0
@admin.register(models.User)
class UserAdmin(auth_admin.UserAdmin):
"""Admin class for the User model"""
fieldsets = (
(
None,
{
"fields": (
"id",
"admin_email",
"password",
)
},
),
(_("Personal info"), {"fields": ("sub", "email", "language", "timezone")}),
(
_("Permissions"),
{
"fields": (
"is_active",
"is_device",
"is_staff",
"is_superuser",
"groups",
"user_permissions",
),
},
),
(_("Important dates"), {"fields": ("created_at", "updated_at")}),
)
add_fieldsets = (
(
None,
{
"classes": ("wide",),
"fields": ("email", "password1", "password2"),
},
),
)
inlines = (TemplateAccessInline,)
list_display = (
"id",
"sub",
"admin_email",
"email",
"is_active",
"is_staff",
"is_superuser",
"is_device",
"created_at",
"updated_at",
)
list_filter = ("is_staff", "is_superuser", "is_device", "is_active")
ordering = ("is_active", "-is_superuser", "-is_staff", "-is_device", "-updated_at")
readonly_fields = ("id", "sub", "email", "created_at", "updated_at")
search_fields = ("id", "sub", "admin_email", "email")
@admin.register(models.Template)
class TemplateAdmin(admin.ModelAdmin):
"""Template admin interface declaration."""

View File

@@ -11,7 +11,16 @@ class IsAuthenticated(permissions.BasePermission):
"""
def has_permission(self, request, view):
return bool(request.auth) if request.auth else request.user.is_authenticated
return bool(request.auth) or request.user.is_authenticated
class IsAuthenticatedOrSafe(IsAuthenticated):
"""Allows access to authenticated users (or anonymous users but only on safe methods)."""
def has_permission(self, request, view):
if request.method in permissions.SAFE_METHODS:
return True
return super().has_permission(request, view)
class IsSelf(IsAuthenticated):
@@ -45,10 +54,10 @@ class IsOwnedOrPublic(IsAuthenticated):
return False
class AccessPermission(IsAuthenticated):
class AccessPermission(permissions.BasePermission):
"""Permission class for access objects."""
def has_object_permission(self, request, view, obj):
"""Check permission for a given object."""
abilities = obj.get_abilities(request.user)
return abilities.get(request.method.lower(), False)
return abilities.get(view.action, False)

View File

@@ -1,31 +1,12 @@
"""Client serializers for the publish core app."""
from django.utils.translation import gettext_lazy as _
from rest_framework import exceptions, serializers
from timezone_field.rest_framework import TimeZoneSerializerField
from core import models
class ContactSerializer(serializers.ModelSerializer):
"""Serialize contacts."""
class Meta:
model = models.Contact
fields = [
"id",
"base",
"data",
"full_name",
"owner",
"short_name",
]
read_only_fields = ["id", "owner"]
def update(self, instance, validated_data):
"""Make "base" field readonly but only for update/patch."""
validated_data.pop("base", None)
return super().update(instance, validated_data)
class UserSerializer(serializers.ModelSerializer):
"""Serialize users."""
@@ -42,13 +23,14 @@ class UserSerializer(serializers.ModelSerializer):
]
read_only_fields = ["id", "is_device", "is_staff"]
class TeamAccessSerializer(serializers.ModelSerializer):
"""Serialize team accesses."""
class TemplateAccessSerializer(serializers.ModelSerializer):
"""Serialize template accesses."""
abilities = serializers.SerializerMethodField(read_only=True)
class Meta:
model = models.TeamAccess
model = models.TemplateAccess
fields = ["id", "user", "role", "abilities"]
read_only_fields = ["id", "abilities"]
@@ -80,58 +62,65 @@ class TeamAccessSerializer(serializers.ModelSerializer):
message = (
f"You are only allowed to set role to {', '.join(can_set_role_to)}"
if can_set_role_to
else "You are not allowed to set this role for this team."
else "You are not allowed to set this role for this template."
)
raise exceptions.PermissionDenied(message)
# Create
else:
try:
team_id = self.context["team_id"]
template_id = self.context["template_id"]
except KeyError as exc:
raise exceptions.ValidationError(
"You must set a team ID in kwargs to create a new team access."
"You must set a template ID in kwargs to create a new template access."
) from exc
if not models.TeamAccess.objects.filter(
team=team_id,
if not models.TemplateAccess.objects.filter(
template=template_id,
user=user,
role__in=[models.RoleChoices.OWNER, models.RoleChoices.ADMIN],
).exists():
raise exceptions.PermissionDenied(
"You are not allowed to manage accesses for this team."
"You are not allowed to manage accesses for this template."
)
if (
role == models.RoleChoices.OWNER
and not models.TeamAccess.objects.filter(
team=team_id,
and not models.TemplateAccess.objects.filter(
template=template_id,
user=user,
role=models.RoleChoices.OWNER,
).exists()
):
raise exceptions.PermissionDenied(
"Only owners of a team can assign other users as owners."
"Only owners of a template can assign other users as owners."
)
attrs["team_id"] = self.context["team_id"]
attrs["template_id"] = self.context["template_id"]
return attrs
class TeamSerializer(serializers.ModelSerializer):
"""Serialize teams."""
class TemplateSerializer(serializers.ModelSerializer):
"""Serialize templates."""
abilities = serializers.SerializerMethodField(read_only=True)
accesses = TeamAccessSerializer(many=True, read_only=True)
accesses = TemplateAccessSerializer(many=True, read_only=True)
class Meta:
model = models.Team
fields = ["id", "name", "accesses", "abilities"]
model = models.Template
fields = ["id", "title", "accesses", "abilities"]
read_only_fields = ["id", "accesses", "abilities"]
def get_abilities(self, team) -> dict:
def get_abilities(self, template) -> dict:
"""Return abilities of the logged-in user on the instance."""
request = self.context.get("request")
if request:
return team.get_abilities(request.user)
return template.get_abilities(request.user)
return {}
# pylint: disable=abstract-method
class DocumentGenerationSerializer(serializers.Serializer):
"""Serializer to receive a request to generate a document on a template."""
body = serializers.CharField(label=_("Markdown Body"))

View File

@@ -1,27 +1,31 @@
"""API endpoints"""
from django.contrib.postgres.search import TrigramSimilarity
from django.core.cache import cache
from io import BytesIO
from django.db.models import (
Func,
OuterRef,
Q,
Subquery,
Value,
)
from django.http import FileResponse
from rest_framework import (
decorators,
exceptions,
mixins,
pagination,
response,
status,
viewsets,
)
from rest_framework import (
response as drf_response,
)
from core import models
from . import permissions, serializers
# pylint: disable=too-many-ancestors
class NestedGenericViewSet(viewsets.GenericViewSet):
"""
@@ -103,83 +107,6 @@ class Pagination(pagination.PageNumberPagination):
page_size_query_param = "page_size"
# pylint: disable=too-many-ancestors
class ContactViewSet(
mixins.CreateModelMixin,
mixins.DestroyModelMixin,
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
):
"""Contact ViewSet"""
permission_classes = [permissions.IsOwnedOrPublic]
queryset = models.Contact.objects.all()
serializer_class = serializers.ContactSerializer
def list(self, request, *args, **kwargs):
"""Limit listed users by a query with throttle protection."""
user = self.request.user
queryset = self.filter_queryset(self.get_queryset())
if not user.is_authenticated:
return queryset.none()
# Exclude contacts that:
queryset = queryset.filter(
# - belong to another user (keep public and owned contacts)
Q(owner__isnull=True) | Q(owner=user),
# - are profile contacts for a user
user__isnull=True,
# - are overriden base contacts
overriding_contacts__isnull=True,
)
# Search by case-insensitive and accent-insensitive trigram similarity
if query := self.request.GET.get("q", ""):
query = Func(Value(query), function="unaccent")
similarity = TrigramSimilarity(
Func("full_name", function="unaccent"),
query,
) + TrigramSimilarity(Func("short_name", function="unaccent"), query)
queryset = (
queryset.annotate(similarity=similarity)
.filter(
similarity__gte=0.05
) # Value determined by testing (test_api_contacts.py)
.order_by("-similarity")
)
# Throttle protection
key_base = f"throttle-contact-list-{user.id!s}"
key_minute = f"{key_base:s}-minute"
key_hour = f"{key_base:s}-hour"
try:
count_minute = cache.incr(key_minute)
except ValueError:
cache.set(key_minute, 1, 60)
count_minute = 1
try:
count_hour = cache.incr(key_hour)
except ValueError:
cache.set(key_hour, 1, 3600)
count_hour = 1
if count_minute > 20 or count_hour > 150:
raise exceptions.Throttled()
serializer = self.get_serializer(queryset, many=True)
return response.Response(serializer.data)
def perform_create(self, serializer):
"""Set the current user as owner of the newly created contact."""
user = self.request.user
serializer.validated_data["owner"] = user
return super().perform_create(serializer)
class UserViewSet(
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
@@ -202,12 +129,12 @@ class UserViewSet(
Return information on currently logged user
"""
context = {"request": request}
return response.Response(
return drf_response.Response(
self.serializer_class(request.user, context=context).data
)
class TeamViewSet(
class TemplateViewSet(
mixins.CreateModelMixin,
mixins.DestroyModelMixin,
mixins.ListModelMixin,
@@ -215,32 +142,69 @@ class TeamViewSet(
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
):
"""Team ViewSet"""
"""Template ViewSet"""
permission_classes = [permissions.AccessPermission]
serializer_class = serializers.TeamSerializer
queryset = models.Team.objects.all()
permission_classes = [
permissions.IsAuthenticatedOrSafe,
permissions.AccessPermission,
]
serializer_class = serializers.TemplateSerializer
queryset = models.Template.objects.all()
def get_queryset(self):
"""Custom queryset to get user related teams."""
user_role_query = models.TeamAccess.objects.filter(
user=self.request.user, team=OuterRef("pk")
"""Custom queryset to get user related templates."""
if not self.request.user.is_authenticated:
return models.Template.objects.filter(is_public=True)
user_role_query = models.TemplateAccess.objects.filter(
user=self.request.user, template=OuterRef("pk")
).values("role")[:1]
return models.Team.objects.filter(accesses__user=self.request.user).annotate(
user_role=Subquery(user_role_query)
return (
models.Template.objects.filter(
Q(accesses__user=self.request.user) | Q(is_public=True)
)
.annotate(user_role=Subquery(user_role_query))
.distinct()
)
def perform_create(self, serializer):
"""Set the current user as owner of the newly created team."""
team = serializer.save()
models.TeamAccess.objects.create(
team=team,
"""Set the current user as owner of the newly created template."""
template = serializer.save()
models.TemplateAccess.objects.create(
template=template,
user=self.request.user,
role=models.RoleChoices.OWNER,
)
@decorators.action(
detail=True,
methods=["post"],
url_path="generate-document",
permission_classes=[permissions.AccessPermission],
)
# pylint: disable=unused-argument
def generate_document(self, request, pk=None):
"""
Generate and return pdf for this template with the content passed.
"""
serializer = serializers.DocumentGenerationSerializer(data=request.data)
class TeamAccessViewSet(
if not serializer.is_valid():
return drf_response.Response(
serializer.errors, status=status.HTTP_400_BAD_REQUEST
)
body = serializer.validated_data["body"]
template = self.get_object()
pdf_content = template.generate_document(body)
response = FileResponse(BytesIO(pdf_content), content_type="application/pdf")
response["Content-Disposition"] = f"attachment; filename={template.title}.pdf"
return response
class TemplateAccessViewSet(
mixins.CreateModelMixin,
mixins.DestroyModelMixin,
mixins.ListModelMixin,
@@ -249,37 +213,37 @@ class TeamAccessViewSet(
viewsets.GenericViewSet,
):
"""
API ViewSet for all interactions with team accesses.
API ViewSet for all interactions with template accesses.
GET /api/v1.0/teams/<team_id>/accesses/:<team_access_id>
Return list of all team accesses related to the logged-in user or one
team access if an id is provided.
GET /api/v1.0/templates/<template_id>/accesses/:<template_access_id>
Return list of all template accesses related to the logged-in user or one
template access if an id is provided.
POST /api/v1.0/teams/<team_id>/accesses/ with expected data:
POST /api/v1.0/templates/<template_id>/accesses/ with expected data:
- user: str
- role: str [owner|admin|member]
Return newly created team access
Return newly created template access
PUT /api/v1.0/teams/<team_id>/accesses/<team_access_id>/ with expected data:
PUT /api/v1.0/templates/<template_id>/accesses/<template_access_id>/ with expected data:
- role: str [owner|admin|member]
Return updated team access
Return updated template access
PATCH /api/v1.0/teams/<team_id>/accesses/<team_access_id>/ with expected data:
PATCH /api/v1.0/templates/<template_id>/accesses/<template_access_id>/ with expected data:
- role: str [owner|admin|member]
Return partially updated team access
Return partially updated template access
DELETE /api/v1.0/teams/<team_id>/accesses/<team_access_id>/
Delete targeted team access
DELETE /api/v1.0/templates/<template_id>/accesses/<template_access_id>/
Delete targeted template access
"""
lookup_field = "pk"
pagination_class = Pagination
permission_classes = [permissions.AccessPermission]
queryset = models.TeamAccess.objects.all().select_related("user")
serializer_class = serializers.TeamAccessSerializer
permission_classes = [permissions.IsAuthenticated, permissions.AccessPermission]
queryset = models.TemplateAccess.objects.all().select_related("user")
serializer_class = serializers.TemplateAccessSerializer
def get_permissions(self):
"""User only needs to be authenticated to list team accesses"""
"""User only needs to be authenticated to list template accesses"""
if self.action == "list":
permission_classes = [permissions.IsAuthenticated]
else:
@@ -290,24 +254,26 @@ class TeamAccessViewSet(
def get_serializer_context(self):
"""Extra context provided to the serializer class."""
context = super().get_serializer_context()
context["team_id"] = self.kwargs["team_id"]
context["template_id"] = self.kwargs["template_id"]
return context
def get_queryset(self):
"""Return the queryset according to the action."""
queryset = super().get_queryset()
queryset = queryset.filter(team=self.kwargs["team_id"])
queryset = queryset.filter(template=self.kwargs["template_id"])
if self.action == "list":
# Limit to team access instances related to a team THAT also has a team access
# instance for the logged-in user (we don't want to list only the team access
# instances pointing to the logged-in user)
user_role_query = models.TeamAccess.objects.filter(
team__accesses__user=self.request.user
# Limit to template access instances related to a template THAT also has
# a template access
# instance for the logged-in user (we don't want to list only the template
# access instances pointing to the logged-in user)
user_role_query = models.TemplateAccess.objects.filter(
template=self.kwargs["template_id"],
template__accesses__user=self.request.user,
).values("role")[:1]
queryset = (
queryset.filter(
team__accesses__user=self.request.user,
template__accesses__user=self.request.user,
)
.annotate(user_role=Subquery(user_role_query))
.distinct()
@@ -317,13 +283,16 @@ class TeamAccessViewSet(
def destroy(self, request, *args, **kwargs):
"""Forbid deleting the last owner access"""
instance = self.get_object()
team = instance.team
template = instance.template
# Check if the access being deleted is the last owner access for the team
if instance.role == "owner" and team.accesses.filter(role="owner").count() == 1:
return response.Response(
{"detail": "Cannot delete the last owner access for the team."},
status=400,
# Check if the access being deleted is the last owner access for the template
if (
instance.role == "owner"
and template.accesses.filter(role="owner").count() == 1
):
return drf_response.Response(
{"detail": "Cannot delete the last owner access for the template."},
status=403,
)
return super().destroy(request, *args, **kwargs)
@@ -337,13 +306,13 @@ class TeamAccessViewSet(
"role" in self.request.data
and self.request.data["role"] != models.RoleChoices.OWNER
):
team = instance.team
# Check if the access being updated is the last owner access for the team
template = instance.template
# Check if the access being updated is the last owner access for the template
if (
instance.role == models.RoleChoices.OWNER
and team.accesses.filter(role=models.RoleChoices.OWNER).count() == 1
and template.accesses.filter(role=models.RoleChoices.OWNER).count() == 1
):
message = "Cannot change the role to a non-owner role for the last owner access."
raise exceptions.ValidationError({"role": message})
raise exceptions.PermissionDenied({"detail": message})
serializer.save()

View File

@@ -19,48 +19,39 @@ class UserFactory(factory.django.DjangoModelFactory):
class Meta:
model = models.User
sub = factory.Sequence(lambda n: f"user{n!s}")
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 TemplateFactory(factory.django.DjangoModelFactory):
"""A factory to create templates"""
class Meta:
model = models.Identity
django_get_or_create = ("sub",)
model = models.Template
django_get_or_create = ("title",)
user = factory.SubFactory(UserFactory)
sub = factory.Sequence(lambda n: f"user{n!s}")
email = factory.Faker("email")
class TeamFactory(factory.django.DjangoModelFactory):
"""A factory to create teams"""
class Meta:
model = models.Team
django_get_or_create = ("name",)
name = factory.Sequence(lambda n: f"team{n}")
title = factory.Sequence(lambda n: f"template{n}")
is_public = factory.Faker("boolean")
@factory.post_generation
def users(self, create, extracted, **kwargs):
"""Add users to team from a given list of users with or without roles."""
"""Add users to template from a given list of users with or without roles."""
if create and extracted:
for item in extracted:
if isinstance(item, models.User):
TeamAccessFactory(team=self, user=item)
TemplateAccessFactory(template=self, user=item)
else:
TeamAccessFactory(team=self, user=item[0], role=item[1])
TemplateAccessFactory(template=self, user=item[0], role=item[1])
class TeamAccessFactory(factory.django.DjangoModelFactory):
"""Create fake team user accesses for testing."""
class TemplateAccessFactory(factory.django.DjangoModelFactory):
"""Create fake template user accesses for testing."""
class Meta:
model = models.TeamAccess
model = models.TemplateAccess
team = factory.SubFactory(TeamFactory)
template = factory.SubFactory(TemplateFactory)
user = factory.SubFactory(UserFactory)
role = factory.fuzzy.FuzzyChoice([r[0] for r in models.RoleChoices.choices])

View File

@@ -1,8 +0,0 @@
"""Forms for the core app of Publish"""
from django import forms
from .models import Template
class DocumentGenerationForm(forms.Form):
body = forms.CharField(widget=forms.Textarea, label="Markdown Body")
template = forms.ModelChoiceField(queryset=Template.objects.all(), label="Choose Template")

View File

@@ -1,4 +1,4 @@
# Generated by Django 5.0 on 2024-01-06 18:05
# Generated by Django 5.0.2 on 2024-02-22 20:34
import django.contrib.auth.models
import django.core.validators
@@ -24,7 +24,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 on')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
('name', models.CharField(max_length=100)),
('name', models.CharField(max_length=100, unique=True)),
],
options={
'verbose_name': 'Team',
@@ -61,7 +61,9 @@ 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 on')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
('email', models.EmailField(max_length=254, unique=True, verbose_name='email address')),
('sub', models.CharField(blank=True, help_text='Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only.', max_length=255, null=True, unique=True, validators=[django.core.validators.RegexValidator(message='Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters.', regex='^[\\w.@+-]+\\Z')], verbose_name='sub')),
('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='identity email address')),
('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')),
@@ -79,24 +81,6 @@ class Migration(migrations.Migration):
('objects', django.contrib.auth.models.UserManager()),
],
),
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 on')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
('sub', models.CharField(blank=True, help_text='Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only.', max_length=255, null=True, unique=True, validators=[django.core.validators.RegexValidator(message='Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters.', regex='^[\\w.@+-]+\\Z')], verbose_name='sub')),
('email', models.EmailField(max_length=254, verbose_name='email address')),
('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': 'publish_identity',
'ordering': ('-is_main', 'email'),
},
),
migrations.CreateModel(
name='TemplateAccess',
fields=[
@@ -114,10 +98,6 @@ class Migration(migrations.Migration):
'db_table': 'publish_template_access',
},
),
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='templateaccess',
constraint=models.UniqueConstraint(fields=('user', 'template'), name='unique_template_user', violation_error_message='This user is already in this template.'),

View File

@@ -1,32 +1,26 @@
"""
Declare and configure the models for the publish core application
"""
import json
import os
import textwrap
import uuid
from django.conf import settings
from django.contrib.auth import models as auth_models
from django.contrib.auth.base_user import AbstractBaseUser
from django.core import exceptions, mail, validators
from django.core import mail, validators
from django.db import models
from django.template.base import Template as DjangoTemplate
from django.template.context import Context
from django.utils.functional import lazy
from django.utils.translation import gettext_lazy as _
from django.template.base import Template as DjangoTemplate
from django.template.context import Context
from django.template.engine import Engine
import frontmatter
import markdown
from weasyprint import CSS, HTML
from weasyprint.text.fonts import FontConfiguration
import jsonschema
from rest_framework_simplejwt.exceptions import InvalidToken
from rest_framework_simplejwt.settings import api_settings
from timezone_field import TimeZoneField
from weasyprint import CSS, HTML
from weasyprint.text.fonts import FontConfiguration
class RoleChoices(models.TextChoices):
@@ -77,7 +71,33 @@ class BaseModel(models.Model):
class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
"""User model to work with OIDC only authentication."""
email = models.EmailField(_("email address"), unique=True)
sub_validator = validators.RegexValidator(
regex=r"^[\w.@+-]+\Z",
message=_(
"Enter a valid sub. This value may contain only letters, "
"numbers, and @/./+/-/_ characters."
),
)
sub = models.CharField(
_("sub"),
help_text=_(
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
),
max_length=255,
unique=True,
validators=[sub_validator],
blank=True,
null=True,
)
email = models.EmailField(_("identity email address"), blank=True, null=True)
# Unlike the "email" field which stores the email coming from the OIDC token, this field
# stores the email used by staff users to login to the admin site
admin_email = models.EmailField(
_("admin email address"), unique=True, blank=True, null=True
)
language = models.CharField(
max_length=10,
choices=lazy(lambda: settings.LANGUAGES, tuple)(),
@@ -112,7 +132,7 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
objects = auth_models.UserManager()
USERNAME_FIELD = "email"
USERNAME_FIELD = "admin_email"
REQUIRED_FIELDS = []
class Meta:
@@ -120,80 +140,20 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
verbose_name = _("user")
verbose_name_plural = _("users")
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],
blank=True,
null=True,
)
email = models.EmailField(_("email address"))
is_main = models.BooleanField(
_("main"),
default=False,
help_text=_("Designates whether the email is the main one."),
)
class Meta:
db_table = "publish_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 ""
return f"{self.email:s}{main_str:s}"
return self.email or self.admin_email or str(self.id)
def clean(self):
"""Normalize the email field and clean the 'is_main' field."""
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()
def save(self, *args, **kwargs):
"""Ensure users always have one and only one main identity."""
super().save(*args, **kwargs)
if self.is_main is True:
self.user.identities.exclude(id=self.id).update(is_main=False)
def email_user(self, subject, message, from_email=None, **kwargs):
"""Email this user."""
if not self.email:
raise ValueError("User has no email address.")
mail.send_mail(subject, message, from_email, [self.email], **kwargs)
class Team(BaseModel):
"""Team used for role based access control when matched with teams in OIDC tokens."""
"""Team used for role based access control when matched with templates in OIDC tokens."""
name = models.CharField(max_length=100)
name = models.CharField(max_length=100, unique=True)
class Meta:
db_table = "publish_role"
@@ -227,11 +187,6 @@ class Template(BaseModel):
def __str__(self):
return self.title
if not self.body:
return ""
return markdown.markdown(textwrap.dedent(self.body))
def generate_document(self, body):
"""
Generate and return a PDF document for this template around the
@@ -240,23 +195,27 @@ class Template(BaseModel):
document = frontmatter.loads(body)
metadata = document.metadata
markdown_body = document.content.strip()
body_html = markdown.markdown(textwrap.dedent(markdown_body)) if markdown_body else ""
body_html = (
markdown.markdown(textwrap.dedent(markdown_body)) if markdown_body else ""
)
document_html = HTML(string=DjangoTemplate(self.code).render(Context({"body": body_html, **metadata})))
document_html = HTML(
string=DjangoTemplate(self.code).render(
Context({"body": body_html, **metadata})
)
)
css = CSS(
string=self.css,
font_config=FontConfiguration(),
)
return document_html.write_pdf(stylesheets=[css], zoom=1)
def get_abilities(self, user):
"""
Compute and return abilities for a given user on the template.
"""
is_owner_or_admin = False
# Compute user role
role = None
if user.is_authenticated:
try:
role = self.user_role
@@ -266,19 +225,20 @@ class Template(BaseModel):
except (TemplateAccess.DoesNotExist, IndexError):
role = None
is_owner_or_admin = role in [RoleChoices.OWNER, RoleChoices.ADMIN]
is_owner_or_admin = role in [RoleChoices.OWNER, RoleChoices.ADMIN]
can_get = self.is_public or role is not None
return {
"get": True,
"patch": is_owner_or_admin,
"put": is_owner_or_admin,
"delete": role == RoleChoices.OWNER,
"destroy": role == RoleChoices.OWNER,
"generate_document": can_get,
"manage_accesses": is_owner_or_admin,
"update": is_owner_or_admin,
"retrieve": can_get,
}
class TemplateAccess(BaseModel):
"""Link table between templates and contacts."""
"""Relation model to give access to a template for a user or a team with a role."""
template = models.ForeignKey(
Template,
@@ -337,16 +297,17 @@ class TemplateAccess(BaseModel):
except AttributeError:
try:
role = self._meta.model.objects.filter(
template=self.template_id, user=user
template=self.template_id,
user=user,
).values("role")[0]["role"]
except (self._meta.model.DoesNotExist, IndexError):
role = None
is_template_owner_or_admin = role in [RoleChoices.OWNER, RoleChoices.ADMIN]
is_template_owner_or_admin = role in [RoleChoices.OWNER, RoleChoices.ADMIN]
if self.role == RoleChoices.OWNER:
can_delete = (
user.id == self.user_id
role == RoleChoices.OWNER
and self.template.accesses.filter(role=RoleChoices.OWNER).count() > 1
)
set_role_to = [RoleChoices.ADMIN, RoleChoices.MEMBER] if can_delete else []
@@ -365,10 +326,9 @@ class TemplateAccess(BaseModel):
pass
return {
"delete": can_delete,
"get": bool(role),
"patch": bool(set_role_to),
"put": bool(set_role_to),
"destroy": can_delete,
"update": bool(set_role_to),
"retrieve": bool(role),
"set_role_to": set_role_to,
}
@@ -390,9 +350,8 @@ def oidc_user_getter(validated_token):
) from exc
try:
user = User.objects.get(identities__sub=user_id)
user = User.objects.get(sub=user_id)
except User.DoesNotExist:
user = User.objects.create()
Identities.objects.create(user=user, sub=user_id, email=validated_token["email"])
user = User.objects.create(sub=user_id, email=validated_token.get("email"))
return user

View File

@@ -23,7 +23,7 @@ def test_openapi_client_schema():
"--api-version",
"v1.0",
"--urlconf",
"publish.api_urls",
"core.urls",
"--format",
"openapi-json",
"--file",

View File

@@ -1,50 +0,0 @@
"""
Tests for Teams API endpoint in publish's core app: create
"""
import pytest
from rest_framework.test import APIClient
from rest_framework_simplejwt.tokens import AccessToken
from core.factories import IdentityFactory, TeamFactory, UserFactory
from core.models import Team
from ..utils import OIDCToken
pytestmark = pytest.mark.django_db
def test_api_teams_create_anonymous():
"""Anonymous users should not be allowed to create teams."""
response = APIClient().post(
"/api/v1.0/teams/",
{
"name": "my team",
},
)
assert response.status_code == 401
assert not Team.objects.exists()
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.
"""
identity = IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
response = APIClient().post(
"/api/v1.0/teams/",
{
"name": "my team",
},
format="json",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 201
team = Team.objects.get()
assert team.name == "my team"
assert team.accesses.filter(role="owner", user=user).exists()

View File

@@ -1,107 +0,0 @@
"""
Tests for Teams API endpoint in publish's core app: delete
"""
import pytest
from rest_framework.test import APIClient
from rest_framework_simplejwt.tokens import AccessToken
from core import factories, models
from ..utils import OIDCToken
pytestmark = pytest.mark.django_db
def test_api_teams_delete_anonymous():
"""Anonymous users should not be allowed to destroy a team."""
team = factories.TeamFactory()
response = APIClient().delete(
f"/api/v1.0/teams/{team.id!s}/",
)
assert response.status_code == 401
assert models.Team.objects.count() == 1
def test_api_teams_delete_authenticated_unrelated():
"""
Authenticated users should not be allowed to delete a team to which they are not
related.
"""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
team = factories.TeamFactory()
response = APIClient().delete(
f"/api/v1.0/teams/{team.id!s}/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
)
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
assert models.Team.objects.count() == 1
def test_api_teams_delete_authenticated_member():
"""
Authenticated users should not be allowed to delete a team for which they are
only a member.
"""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
team = factories.TeamFactory(users=[(user, "member")])
response = APIClient().delete(
f"/api/v1.0/teams/{team.id}/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
assert models.Team.objects.count() == 1
def test_api_teams_delete_authenticated_administrator():
"""
Authenticated users should not be allowed to delete a team for which they are
administrator.
"""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
team = factories.TeamFactory(users=[(user, "administrator")])
response = APIClient().delete(
f"/api/v1.0/teams/{team.id}/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
assert models.Team.objects.count() == 1
def test_api_teams_delete_authenticated_owner():
"""
Authenticated users should be able to delete a team for which they are directly
owner.
"""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
team = factories.TeamFactory(users=[(user, "owner")])
response = APIClient().delete(
f"/api/v1.0/teams/{team.id}/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
)
assert response.status_code == 204
assert models.Team.objects.exists() is False

View File

@@ -1,118 +0,0 @@
"""
Tests for Teams API endpoint in publish's core app: list
"""
from unittest import mock
import pytest
from rest_framework.pagination import PageNumberPagination
from rest_framework.status import HTTP_200_OK, HTTP_401_UNAUTHORIZED
from rest_framework.test import APIClient
from core import factories, models
from core.api import serializers
from ..utils import OIDCToken
pytestmark = pytest.mark.django_db
def test_api_teams_list_anonymous():
"""Anonymous users should not be allowed to list teams."""
factories.TeamFactory.create_batch(2)
response = APIClient().get("/api/v1.0/teams/")
assert response.status_code == HTTP_401_UNAUTHORIZED
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_teams_list_authenticated():
"""Authenticated users should be able to list teams they are an owner/administrator/member of."""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
expected_ids = {
str(access.team.id)
for access in factories.TeamAccessFactory.create_batch(5, user=user)
}
factories.TeamFactory.create_batch(2) # Other teams
response = APIClient().get(
"/api/v1.0/teams/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
)
assert response.status_code == HTTP_200_OK
results = response.json()["results"]
assert len(results) == 5
results_id = {result["id"] for result in results}
assert expected_ids == results_id
@mock.patch.object(PageNumberPagination, "get_page_size", return_value=2)
def test_api_teams_list_pagination(
_mock_page_size,
):
"""Pagination should work as expected."""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
team_ids = [
str(access.team.id)
for access in factories.TeamAccessFactory.create_batch(3, user=user)
]
# Get page 1
response = APIClient().get(
"/api/v1.0/teams/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
)
assert response.status_code == HTTP_200_OK
content = response.json()
assert content["count"] == 3
assert content["next"] == "http://testserver/api/v1.0/teams/?page=2"
assert content["previous"] is None
assert len(content["results"]) == 2
for item in content["results"]:
team_ids.remove(item["id"])
# Get page 2
response = APIClient().get(
"/api/v1.0/teams/?page=2", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
)
assert response.status_code == HTTP_200_OK
content = response.json()
assert content["count"] == 3
assert content["next"] is None
assert content["previous"] == "http://testserver/api/v1.0/teams/"
assert len(content["results"]) == 1
team_ids.remove(content["results"][0]["id"])
assert team_ids == []
def test_api_teams_list_authenticated_distinct():
"""A team with several related users should only be listed once."""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
other_user = factories.UserFactory()
team = factories.TeamFactory(users=[user, other_user])
response = APIClient().get(
"/api/v1.0/teams/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
)
assert response.status_code == HTTP_200_OK
content = response.json()
assert len(content["results"]) == 1
assert content["results"][0]["id"] == str(team.id)

View File

@@ -1,86 +0,0 @@
"""
Tests for Teams API endpoint in publish's core app: retrieve
"""
import random
from collections import Counter
from unittest import mock
import pytest
from rest_framework.test import APIClient
from core import factories
from ..utils import OIDCToken
pytestmark = pytest.mark.django_db
def test_api_teams_retrieve_anonymous():
"""Anonymous users should not be allowed to retrieve a team."""
team = factories.TeamFactory()
response = APIClient().get(f"/api/v1.0/teams/{team.id}/")
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_teams_retrieve_authenticated_unrelated():
"""
Authenticated users should not be allowed to retrieve a team to which they are
not related.
"""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
team = factories.TeamFactory()
response = APIClient().get(
f"/api/v1.0/teams/{team.id!s}/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
)
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
def test_api_teams_retrieve_authenticated_related():
"""
Authenticated users should be allowed to retrieve a team to which they
are related whatever the role.
"""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
team = factories.TeamFactory()
access1 = factories.TeamAccessFactory(team=team, user=user)
access2 = factories.TeamAccessFactory(team=team)
response = APIClient().get(
f"/api/v1.0/teams/{team.id!s}/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
)
assert response.status_code == 200
content = response.json()
assert sorted(content.pop("accesses"), key=lambda x: x["user"]) == sorted(
[
{
"id": str(access1.id),
"user": str(user.id),
"role": access1.role,
"abilities": access1.get_abilities(user),
},
{
"id": str(access2.id),
"user": str(access2.user.id),
"role": access2.role,
"abilities": access2.get_abilities(user),
},
],
key=lambda x: x["user"],
)
assert response.json() == {
"id": str(team.id),
"name": team.name,
"abilities": team.get_abilities(user),
}

View File

@@ -1,176 +0,0 @@
"""
Tests for Teams API endpoint in publish's core app: update
"""
import random
import pytest
from rest_framework.test import APIClient
from rest_framework_simplejwt.tokens import AccessToken
from core import factories, models
from core.api import serializers
from ..utils import OIDCToken
pytestmark = pytest.mark.django_db
def test_api_teams_update_anonymous():
"""Anonymous users should not be allowed to update a team."""
team = factories.TeamFactory()
old_team_values = serializers.TeamSerializer(instance=team).data
new_team_values = serializers.TeamSerializer(instance=factories.TeamFactory()).data
response = APIClient().put(
f"/api/v1.0/teams/{team.id!s}/",
new_team_values,
format="json",
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
team.refresh_from_db()
team_values = serializers.TeamSerializer(instance=team).data
assert team_values == old_team_values
def test_api_teams_update_authenticated_unrelated():
"""
Authenticated users should not be allowed to update a team to which they are not related.
"""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
team = factories.TeamFactory()
old_team_values = serializers.TeamSerializer(instance=team).data
new_team_values = serializers.TeamSerializer(instance=factories.TeamFactory()).data
response = APIClient().put(
f"/api/v1.0/teams/{team.id!s}/",
new_team_values,
format="json",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
team.refresh_from_db()
team_values = serializers.TeamSerializer(instance=team).data
assert team_values == old_team_values
def test_api_teams_update_authenticated_members():
"""
Users who are members of a team but not administrators should
not be allowed to update it.
"""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
team = factories.TeamFactory(users=[(user, "member")])
old_team_values = serializers.TeamSerializer(instance=team).data
new_team_values = serializers.TeamSerializer(instance=factories.TeamFactory()).data
response = APIClient().put(
f"/api/v1.0/teams/{team.id!s}/",
new_team_values,
format="json",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
team.refresh_from_db()
team_values = serializers.TeamSerializer(instance=team).data
assert team_values == old_team_values
def test_api_teams_update_authenticated_administrators():
"""Administrators of a team should be allowed to update it."""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
team = factories.TeamFactory(users=[(user, "administrator")])
old_team_values = serializers.TeamSerializer(instance=team).data
new_team_values = serializers.TeamSerializer(instance=factories.TeamFactory()).data
response = APIClient().put(
f"/api/v1.0/teams/{team.id!s}/",
new_team_values,
format="json",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 200
team.refresh_from_db()
team_values = serializers.TeamSerializer(instance=team).data
for key, value in team_values.items():
if key in ["id", "accesses"]:
assert value == old_team_values[key]
else:
assert value == new_team_values[key]
def test_api_teams_update_authenticated_owners():
"""Administrators of a team should be allowed to update it."""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
team = factories.TeamFactory(users=[(user, "owner")])
old_team_values = serializers.TeamSerializer(instance=team).data
new_team_values = serializers.TeamSerializer(instance=factories.TeamFactory()).data
response = APIClient().put(
f"/api/v1.0/teams/{team.id!s}/",
new_team_values,
format="json",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 200
team.refresh_from_db()
team_values = serializers.TeamSerializer(instance=team).data
for key, value in team_values.items():
if key in ["id", "accesses"]:
assert value == old_team_values[key]
else:
assert value == new_team_values[key]
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.
"""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
factories.TeamFactory(users=[(user, random.choice(["administrator", "owner"]))])
team = factories.TeamFactory(name="Old name")
old_team_values = serializers.TeamSerializer(instance=team).data
new_team_values = serializers.TeamSerializer(instance=factories.TeamFactory()).data
response = APIClient().put(
f"/api/v1.0/teams/{team.id!s}/",
new_team_values,
format="json",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
team.refresh_from_db()
team_values = serializers.TeamSerializer(instance=team).data
assert team_values == old_team_values

View File

@@ -0,0 +1,47 @@
"""
Tests for Templates API endpoint in publish's core app: create
"""
import pytest
from rest_framework.test import APIClient
from core import factories
from core.models import Template
from core.tests.utils import OIDCToken
pytestmark = pytest.mark.django_db
def test_api_templates_create_anonymous():
"""Anonymous users should not be allowed to create templates."""
response = APIClient().post(
"/api/v1.0/templates/",
{
"title": "my template",
},
)
assert response.status_code == 401
assert not Template.objects.exists()
def test_api_templates_create_authenticated():
"""
Authenticated users should be able to create templates and should automatically be declared
as the owner of the newly created template.
"""
user = factories.UserFactory()
jwt_token = OIDCToken.for_user(user)
response = APIClient().post(
"/api/v1.0/templates/",
{
"title": "my template",
},
format="json",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 201
template = Template.objects.get()
assert template.title == "my template"
assert template.accesses.filter(role="owner", user=user).exists()

View File

@@ -0,0 +1,84 @@
"""
Tests for Templates API endpoint in publish's core app: delete
"""
import random
import pytest
from rest_framework.test import APIClient
from core import factories, models
from core.tests.utils import OIDCToken
pytestmark = pytest.mark.django_db
def test_api_templates_delete_anonymous():
"""Anonymous users should not be allowed to destroy a template."""
template = factories.TemplateFactory()
response = APIClient().delete(
f"/api/v1.0/templates/{template.id!s}/",
)
assert response.status_code == 401
assert models.Template.objects.count() == 1
def test_api_templates_delete_authenticated_unrelated():
"""
Authenticated users should not be allowed to delete a template to which they are not
related.
"""
user = factories.UserFactory()
jwt_token = OIDCToken.for_user(user)
is_public = random.choice([True, False])
template = factories.TemplateFactory(is_public=is_public)
response = APIClient().delete(
f"/api/v1.0/templates/{template.id!s}/",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 403 if is_public else 404
assert models.Template.objects.count() == 1
@pytest.mark.parametrize("role", ["member", "administrator"])
def test_api_templates_delete_authenticated_member(role):
"""
Authenticated users should not be allowed to delete a template for which they are
only a member.
"""
user = factories.UserFactory()
jwt_token = OIDCToken.for_user(user)
template = factories.TemplateFactory(users=[(user, role)])
response = APIClient().delete(
f"/api/v1.0/templates/{template.id}/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
assert models.Template.objects.count() == 1
def test_api_templates_delete_authenticated_owner():
"""
Authenticated users should be able to delete a template for which they are directly
owner.
"""
user = factories.UserFactory()
jwt_token = OIDCToken.for_user(user)
template = factories.TemplateFactory(users=[(user, "owner")])
response = APIClient().delete(
f"/api/v1.0/templates/{template.id}/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
)
assert response.status_code == 204
assert models.Template.objects.exists() is False

View File

@@ -0,0 +1,107 @@
"""
Test users API endpoints in the publish core app.
"""
import pytest
from rest_framework.test import APIClient
from core import factories
from core.tests.utils import OIDCToken
pytestmark = pytest.mark.django_db
def test_api_templates_generate_document_anonymous_public():
"""Anonymous users can generate pdf document with public templates."""
template = factories.TemplateFactory(is_public=True)
data = {
"body": "# Test markdown body",
}
response = APIClient().post(
f"/api/v1.0/templates/{template.id!s}/generate-document/",
data,
format="json",
)
assert response.status_code == 200
assert response.headers["content-type"] == "application/pdf"
def test_api_templates_generate_document_anonymous_not_public():
"""
Anonymous users should not be allowed to generate pdf document with templates
that are not marked as public.
"""
template = factories.TemplateFactory(is_public=False)
data = {
"body": "# Test markdown body",
}
response = APIClient().post(
f"/api/v1.0/templates/{template.id!s}/generate-document/",
data,
format="json",
)
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
def test_api_templates_generate_document_authenticated_public():
"""Authenticated users can generate pdf document with public templates."""
user = factories.UserFactory()
jwt_token = OIDCToken.for_user(user)
template = factories.TemplateFactory(is_public=True)
data = {"body": "# Test markdown body"}
response = APIClient().post(
f"/api/v1.0/templates/{template.id!s}/generate-document/",
data,
format="json",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 200
assert response.headers["content-type"] == "application/pdf"
def test_api_templates_generate_document_authenticated_not_public():
"""
Authenticated users should not be allowed to generate pdf document with templates
that are not marked as public.
"""
user = factories.UserFactory()
jwt_token = OIDCToken.for_user(user)
template = factories.TemplateFactory(is_public=False)
data = {"body": "# Test markdown body"}
response = APIClient().post(
f"/api/v1.0/templates/{template.id!s}/generate-document/",
data,
format="json",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
def test_api_templates_generate_document_related():
"""Users related to a template can generate pdf document."""
user = factories.UserFactory()
jwt_token = OIDCToken.for_user(user)
access = factories.TemplateAccessFactory(user=user)
data = {"body": "# Test markdown body"}
response = APIClient().post(
f"/api/v1.0/templates/{access.template.id!s}/generate-document/",
data,
format="json",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 200
assert response.headers["content-type"] == "application/pdf"

View File

@@ -0,0 +1,124 @@
"""
Tests for Templates API endpoint in publish's core app: list
"""
from unittest import mock
import pytest
from rest_framework.pagination import PageNumberPagination
from rest_framework.status import HTTP_200_OK
from rest_framework.test import APIClient
from core import factories
from core.tests.utils import OIDCToken
pytestmark = pytest.mark.django_db
def test_api_templates_list_anonymous():
"""Anonymous users should only be able to list public templates."""
factories.TemplateFactory.create_batch(2, is_public=False)
templates = factories.TemplateFactory.create_batch(2, is_public=True)
expected_ids = {str(template.id) for template in templates}
response = APIClient().get("/api/v1.0/templates/")
assert response.status_code == HTTP_200_OK
results = response.json()["results"]
assert len(results) == 2
results_id = {result["id"] for result in results}
assert expected_ids == results_id
def test_api_templates_list_authenticated():
"""
Authenticated users should be able to list templates they are
an owner/administrator/member of.
"""
user = factories.UserFactory()
jwt_token = OIDCToken.for_user(user)
related_templates = [
access.template
for access in factories.TemplateAccessFactory.create_batch(5, user=user)
]
public_templates = factories.TemplateFactory.create_batch(2, is_public=True)
factories.TemplateFactory.create_batch(2, is_public=False)
expected_ids = {
str(template.id) for template in related_templates + public_templates
}
response = APIClient().get(
"/api/v1.0/templates/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
)
assert response.status_code == HTTP_200_OK
results = response.json()["results"]
assert len(results) == 7
results_id = {result["id"] for result in results}
assert expected_ids == results_id
@mock.patch.object(PageNumberPagination, "get_page_size", return_value=2)
def test_api_templates_list_pagination(
_mock_page_size,
):
"""Pagination should work as expected."""
user = factories.UserFactory()
jwt_token = OIDCToken.for_user(user)
template_ids = [
str(access.template.id)
for access in factories.TemplateAccessFactory.create_batch(3, user=user)
]
# Get page 1
response = APIClient().get(
"/api/v1.0/templates/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
)
assert response.status_code == HTTP_200_OK
content = response.json()
assert content["count"] == 3
assert content["next"] == "http://testserver/api/v1.0/templates/?page=2"
assert content["previous"] is None
assert len(content["results"]) == 2
for item in content["results"]:
template_ids.remove(item["id"])
# Get page 2
response = APIClient().get(
"/api/v1.0/templates/?page=2", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
)
assert response.status_code == HTTP_200_OK
content = response.json()
assert content["count"] == 3
assert content["next"] is None
assert content["previous"] == "http://testserver/api/v1.0/templates/"
assert len(content["results"]) == 1
template_ids.remove(content["results"][0]["id"])
assert template_ids == []
def test_api_templates_list_authenticated_distinct():
"""A template with several related users should only be listed once."""
user = factories.UserFactory()
jwt_token = OIDCToken.for_user(user)
other_user = factories.UserFactory()
template = factories.TemplateFactory(users=[user, other_user], is_public=True)
response = APIClient().get(
"/api/v1.0/templates/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
)
assert response.status_code == HTTP_200_OK
content = response.json()
assert len(content["results"]) == 1
assert content["results"][0]["id"] == str(template.id)

View File

@@ -0,0 +1,130 @@
"""
Tests for Templates API endpoint in publish's core app: retrieve
"""
import pytest
from rest_framework.test import APIClient
from core import factories
from core.tests.utils import OIDCToken
pytestmark = pytest.mark.django_db
def test_api_templates_retrieve_anonymous_public():
"""Anonymous users should be allowed to retrieve public templates."""
template = factories.TemplateFactory(is_public=True)
response = APIClient().get(f"/api/v1.0/templates/{template.id!s}/")
assert response.status_code == 200
assert response.json() == {
"id": str(template.id),
"abilities": {
"destroy": False,
"generate_document": True,
"manage_accesses": False,
"retrieve": True,
"update": False,
},
"accesses": [],
"title": template.title,
}
def test_api_templates_retrieve_anonymous_not_public():
"""Anonymous users should not be able to retrieve a template that is not public."""
template = factories.TemplateFactory(is_public=False)
response = APIClient().get(f"/api/v1.0/templates/{template.id!s}/")
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
def test_api_templates_retrieve_authenticated_unrelated_public():
"""
Authenticated users should be able to retrieve a public template to which they are
not related.
"""
user = factories.UserFactory()
jwt_token = OIDCToken.for_user(user)
template = factories.TemplateFactory(is_public=True)
response = APIClient().get(
f"/api/v1.0/templates/{template.id!s}/",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 200
assert response.json() == {
"id": str(template.id),
"abilities": {
"destroy": False,
"generate_document": True,
"manage_accesses": False,
"retrieve": True,
"update": False,
},
"accesses": [],
"title": template.title,
}
def test_api_templates_retrieve_authenticated_unrelated_not_public():
"""
Authenticated users should not be allowed to retrieve a template that is not public and
to which they are not related.
"""
user = factories.UserFactory()
jwt_token = OIDCToken.for_user(user)
template = factories.TemplateFactory(is_public=False)
response = APIClient().get(
f"/api/v1.0/templates/{template.id!s}/",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
def test_api_templates_retrieve_authenticated_related():
"""
Authenticated users should be allowed to retrieve a template to which they
are related whatever the role.
"""
user = factories.UserFactory()
jwt_token = OIDCToken.for_user(user)
template = factories.TemplateFactory()
access1 = factories.TemplateAccessFactory(template=template, user=user)
access2 = factories.TemplateAccessFactory(template=template)
response = APIClient().get(
f"/api/v1.0/templates/{template.id!s}/",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 200
content = response.json()
assert sorted(content.pop("accesses"), key=lambda x: x["user"]) == sorted(
[
{
"id": str(access1.id),
"user": str(user.id),
"role": access1.role,
"abilities": access1.get_abilities(user),
},
{
"id": str(access2.id),
"user": str(access2.user.id),
"role": access2.role,
"abilities": access2.get_abilities(user),
},
],
key=lambda x: x["user"],
)
assert response.json() == {
"id": str(template.id),
"title": template.title,
"abilities": template.get_abilities(user),
}

View File

@@ -0,0 +1,154 @@
"""
Tests for Templates API endpoint in publish's core app: update
"""
import random
import pytest
from rest_framework.test import APIClient
from core import factories
from core.api import serializers
from core.tests.utils import OIDCToken
pytestmark = pytest.mark.django_db
def test_api_templates_update_anonymous():
"""Anonymous users should not be allowed to update a template."""
template = factories.TemplateFactory()
old_template_values = serializers.TemplateSerializer(instance=template).data
new_template_values = serializers.TemplateSerializer(
instance=factories.TemplateFactory()
).data
response = APIClient().put(
f"/api/v1.0/templates/{template.id!s}/",
new_template_values,
format="json",
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
template.refresh_from_db()
template_values = serializers.TemplateSerializer(instance=template).data
assert template_values == old_template_values
def test_api_templates_update_authenticated_unrelated():
"""
Authenticated users should not be allowed to update a template to which they are not related.
"""
user = factories.UserFactory()
jwt_token = OIDCToken.for_user(user)
template = factories.TemplateFactory(is_public=False)
old_template_values = serializers.TemplateSerializer(instance=template).data
new_template_values = serializers.TemplateSerializer(
instance=factories.TemplateFactory()
).data
response = APIClient().put(
f"/api/v1.0/templates/{template.id!s}/",
new_template_values,
format="json",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
template.refresh_from_db()
template_values = serializers.TemplateSerializer(instance=template).data
assert template_values == old_template_values
def test_api_templates_update_authenticated_members():
"""
Users who are members of a template but not administrators should
not be allowed to update it.
"""
user = factories.UserFactory()
jwt_token = OIDCToken.for_user(user)
template = factories.TemplateFactory(users=[(user, "member")])
old_template_values = serializers.TemplateSerializer(instance=template).data
new_template_values = serializers.TemplateSerializer(
instance=factories.TemplateFactory()
).data
response = APIClient().put(
f"/api/v1.0/templates/{template.id!s}/",
new_template_values,
format="json",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
template.refresh_from_db()
template_values = serializers.TemplateSerializer(instance=template).data
assert template_values == old_template_values
@pytest.mark.parametrize("role", ["administrator", "owner"])
def test_api_templates_update_authenticated_administrators(role):
"""Administrators of a template should be allowed to update it."""
user = factories.UserFactory()
jwt_token = OIDCToken.for_user(user)
template = factories.TemplateFactory(users=[(user, role)])
old_template_values = serializers.TemplateSerializer(instance=template).data
new_template_values = serializers.TemplateSerializer(
instance=factories.TemplateFactory()
).data
response = APIClient().put(
f"/api/v1.0/templates/{template.id!s}/",
new_template_values,
format="json",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 200
template.refresh_from_db()
template_values = serializers.TemplateSerializer(instance=template).data
for key, value in template_values.items():
if key in ["id", "accesses"]:
assert value == old_template_values[key]
else:
assert value == new_template_values[key]
def test_api_templates_update_administrator_or_owner_of_another():
"""
Being administrator or owner of a template should not grant authorization to update
another template.
"""
user = factories.UserFactory()
jwt_token = OIDCToken.for_user(user)
factories.TemplateFactory(users=[(user, random.choice(["administrator", "owner"]))])
is_public = random.choice([True, False])
template = factories.TemplateFactory(title="Old title", is_public=is_public)
old_template_values = serializers.TemplateSerializer(instance=template).data
new_template_values = serializers.TemplateSerializer(
instance=factories.TemplateFactory()
).data
response = APIClient().put(
f"/api/v1.0/templates/{template.id!s}/",
new_template_values,
format="json",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 403 if is_public else 404
template.refresh_from_db()
template_values = serializers.TemplateSerializer(instance=template).data
assert template_values == old_template_values

View File

@@ -1,843 +0,0 @@
"""
Test team accesses API endpoints for users in publish's core app.
"""
import random
from uuid import uuid4
import pytest
from rest_framework.test import APIClient
from core import factories, models
from core.api import serializers
from .utils import OIDCToken
pytestmark = pytest.mark.django_db
def test_api_team_accesses_list_anonymous():
"""Anonymous users should not be allowed to list team accesses."""
team = factories.TeamFactory()
factories.TeamAccessFactory.create_batch(2, team=team)
response = APIClient().get(f"/api/v1.0/teams/{team.id!s}/accesses/")
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_team_accesses_list_authenticated_unrelated():
"""
Authenticated users should not be allowed to list team accesses for a team
to which they are not related.
"""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
team = factories.TeamFactory()
factories.TeamAccessFactory.create_batch(3, team=team)
# Accesses for other teams to which the user is related should not be listed either
other_access = factories.TeamAccessFactory(user=user)
factories.TeamAccessFactory(team=other_access.team)
response = APIClient().get(
f"/api/v1.0/teams/{team.id!s}/accesses/",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 200
assert response.json() == {
"count": 0,
"next": None,
"previous": None,
"results": [],
}
def test_api_team_accesses_list_authenticated_related():
"""
Authenticated users should be able to list team accesses for a team
to which they are related, whatever their role in the team.
"""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
team = factories.TeamFactory()
user_access = models.TeamAccess.objects.create(team=team, user=user) # random role
access1, access2 = factories.TeamAccessFactory.create_batch(2, team=team)
# Accesses for other teams to which the user is related should not be listed either
other_access = factories.TeamAccessFactory(user=user)
factories.TeamAccessFactory(team=other_access.team)
response = APIClient().get(
f"/api/v1.0/teams/{team.id!s}/accesses/",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 200
content = response.json()
assert len(content["results"]) == 3
assert sorted(content["results"], key=lambda x: x["id"]) == sorted(
[
{
"id": str(user_access.id),
"user": str(user.id),
"role": user_access.role,
"abilities": user_access.get_abilities(user),
},
{
"id": str(access1.id),
"user": str(access1.user.id),
"role": access1.role,
"abilities": access1.get_abilities(user),
},
{
"id": str(access2.id),
"user": str(access2.user.id),
"role": access2.role,
"abilities": access2.get_abilities(user),
},
],
key=lambda x: x["id"],
)
def test_api_team_accesses_retrieve_anonymous():
"""
Anonymous users should not be allowed to retrieve a team access.
"""
access = factories.TeamAccessFactory()
response = APIClient().get(
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_team_accesses_retrieve_authenticated_unrelated():
"""
Authenticated users should not be allowed to retrieve a team access for
a team to which they are not related.
"""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
team = factories.TeamFactory()
access = factories.TeamAccessFactory(team=team)
response = APIClient().get(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
# Accesses related to another team should be excluded even if the user is related to it
for access in [
factories.TeamAccessFactory(),
factories.TeamAccessFactory(user=user),
]:
response = APIClient().get(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
def test_api_team_accesses_retrieve_authenticated_related():
"""
A user who is related to a team should be allowed to retrieve the
associated team user accesses.
"""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
team = factories.TeamFactory(users=[user])
access = factories.TeamAccessFactory(team=team)
response = APIClient().get(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 200
assert response.json() == {
"id": str(access.id),
"user": str(access.user.id),
"role": access.role,
"abilities": access.get_abilities(user),
}
def test_api_team_accesses_create_anonymous():
"""Anonymous users should not be allowed to create team accesses."""
user = factories.UserFactory()
team = factories.TeamFactory()
response = APIClient().post(
f"/api/v1.0/teams/{team.id!s}/accesses/",
{
"user": str(user.id),
"team": str(team.id),
"role": random.choice(models.RoleChoices.choices)[0],
},
format="json",
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
assert models.TeamAccess.objects.exists() is False
def test_api_team_accesses_create_authenticated_unrelated():
"""
Authenticated users should not be allowed to create team accesses for a team to
which they are not related.
"""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
other_user = factories.UserFactory()
team = factories.TeamFactory()
response = APIClient().post(
f"/api/v1.0/teams/{team.id!s}/accesses/",
{
"user": str(other_user.id),
},
format="json",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You are not allowed to manage accesses for this team."
}
assert not models.TeamAccess.objects.filter(user=other_user).exists()
def test_api_team_accesses_create_authenticated_member():
"""Members of a team should not be allowed to create team accesses."""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
team = factories.TeamFactory(users=[(user, "member")])
other_user = factories.UserFactory()
api_client = APIClient()
for role in [role[0] for role in models.RoleChoices.choices]:
response = api_client.post(
f"/api/v1.0/teams/{team.id!s}/accesses/",
{
"user": str(other_user.id),
"role": role,
},
format="json",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You are not allowed to manage accesses for this team."
}
assert not models.TeamAccess.objects.filter(user=other_user).exists()
def test_api_team_accesses_create_authenticated_administrator():
"""
Administrators of a team should be able to create team accesses except for the "owner" role.
"""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
team = factories.TeamFactory(users=[(user, "administrator")])
other_user = factories.UserFactory()
api_client = APIClient()
# It should not be allowed to create an owner access
response = api_client.post(
f"/api/v1.0/teams/{team.id!s}/accesses/",
{
"user": str(other_user.id),
"role": "owner",
},
format="json",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 403
assert response.json() == {
"detail": "Only owners of a team can assign other users as owners."
}
# It should be allowed to create a lower access
role = random.choice(
[role[0] for role in models.RoleChoices.choices if role[0] != "owner"]
)
response = api_client.post(
f"/api/v1.0/teams/{team.id!s}/accesses/",
{
"user": str(other_user.id),
"role": role,
},
format="json",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 201
assert models.TeamAccess.objects.filter(user=other_user).count() == 1
new_team_access = models.TeamAccess.objects.filter(user=other_user).get()
assert response.json() == {
"abilities": new_team_access.get_abilities(user),
"id": str(new_team_access.id),
"role": role,
"user": str(other_user.id),
}
def test_api_team_accesses_create_authenticated_owner():
"""
Owners of a team should be able to create team accesses whatever the role.
"""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
team = factories.TeamFactory(users=[(user, "owner")])
other_user = factories.UserFactory()
role = random.choice([role[0] for role in models.RoleChoices.choices])
response = APIClient().post(
f"/api/v1.0/teams/{team.id!s}/accesses/",
{
"user": str(other_user.id),
"role": role,
},
format="json",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 201
assert models.TeamAccess.objects.filter(user=other_user).count() == 1
new_team_access = models.TeamAccess.objects.filter(user=other_user).get()
assert response.json() == {
"abilities": new_team_access.get_abilities(user),
"id": str(new_team_access.id),
"role": role,
"user": str(other_user.id),
}
def test_api_team_accesses_update_anonymous():
"""Anonymous users should not be allowed to update a team access."""
access = factories.TeamAccessFactory()
old_values = serializers.TeamAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.choices)[0],
}
api_client = APIClient()
for field, value in new_values.items():
response = api_client.put(
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
{**old_values, field: value},
format="json",
)
assert response.status_code == 401
access.refresh_from_db()
updated_values = serializers.TeamAccessSerializer(instance=access).data
assert updated_values == old_values
def test_api_team_accesses_update_authenticated_unrelated():
"""
Authenticated users should not be allowed to update a team access for a team to which
they are not related.
"""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
access = factories.TeamAccessFactory()
old_values = serializers.TeamAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.choices)[0],
}
api_client = APIClient()
for field, value in new_values.items():
response = api_client.put(
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
{**old_values, field: value},
format="json",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 403
access.refresh_from_db()
updated_values = serializers.TeamAccessSerializer(instance=access).data
assert updated_values == old_values
def test_api_team_accesses_update_authenticated_member():
"""Members of a team should not be allowed to update its accesses."""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
team = factories.TeamFactory(users=[(user, "member")])
access = factories.TeamAccessFactory(team=team)
old_values = serializers.TeamAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.choices)[0],
}
api_client = APIClient()
for field, value in new_values.items():
response = api_client.put(
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
{**old_values, field: value},
format="json",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 403
access.refresh_from_db()
updated_values = serializers.TeamAccessSerializer(instance=access).data
assert updated_values == old_values
def test_api_team_accesses_update_administrator_except_owner():
"""
A user who is an administrator in a team should be allowed to update a user
access for this team, as long as they don't try to set the role to owner.
"""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
team = factories.TeamFactory(users=[(user, "administrator")])
access = factories.TeamAccessFactory(
team=team,
role=random.choice(["administrator", "member"]),
)
old_values = serializers.TeamAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": random.choice(["administrator", "member"]),
}
api_client = APIClient()
for field, value in new_values.items():
new_data = {**old_values, field: value}
response = api_client.put(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
data=new_data,
format="json",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
if (
new_data["role"] == old_values["role"]
): # we are not really updating the role
assert response.status_code == 403
else:
assert response.status_code == 200
access.refresh_from_db()
updated_values = serializers.TeamAccessSerializer(instance=access).data
if field == "role":
assert updated_values == {**old_values, "role": new_values["role"]}
else:
assert updated_values == old_values
def test_api_team_accesses_update_administrator_from_owner():
"""
A user who is an administrator in a team, should not be allowed to update
the user access of an "owner" for this team.
"""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
team = factories.TeamFactory(users=[(user, "administrator")])
other_user = factories.UserFactory()
access = factories.TeamAccessFactory(team=team, user=other_user, role="owner")
old_values = serializers.TeamAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.choices)[0],
}
api_client = APIClient()
for field, value in new_values.items():
response = api_client.put(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
data={**old_values, field: value},
format="json",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 403
access.refresh_from_db()
updated_values = serializers.TeamAccessSerializer(instance=access).data
assert updated_values == old_values
def test_api_team_accesses_update_administrator_to_owner():
"""
A user who is an administrator in a team, should not be allowed to update
the user access of another user to grant team ownership.
"""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
team = factories.TeamFactory(users=[(user, "administrator")])
other_user = factories.UserFactory()
access = factories.TeamAccessFactory(
team=team,
user=other_user,
role=random.choice(["administrator", "member"]),
)
old_values = serializers.TeamAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": "owner",
}
api_client = APIClient()
for field, value in new_values.items():
new_data = {**old_values, field: value}
response = api_client.put(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
data=new_data,
format="json",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
# We are not allowed or not really updating the role
if field == "role" or new_data["role"] == old_values["role"]:
assert response.status_code == 403
else:
assert response.status_code == 200
access.refresh_from_db()
updated_values = serializers.TeamAccessSerializer(instance=access).data
assert updated_values == old_values
def test_api_team_accesses_update_owner_except_owner():
"""
A user who is an owner in a team should be allowed to update
a user access for this team except for existing "owner" accesses.
"""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
team = factories.TeamFactory(users=[(user, "owner")])
factories.UserFactory()
access = factories.TeamAccessFactory(
team=team,
role=random.choice(["administrator", "member"]),
)
old_values = serializers.TeamAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.choices)[0],
}
api_client = APIClient()
for field, value in new_values.items():
new_data = {**old_values, field: value}
response = api_client.put(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
data=new_data,
format="json",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
if (
new_data["role"] == old_values["role"]
): # we are not really updating the role
assert response.status_code == 403
else:
assert response.status_code == 200
access.refresh_from_db()
updated_values = serializers.TeamAccessSerializer(instance=access).data
if field == "role":
assert updated_values == {**old_values, "role": new_values["role"]}
else:
assert updated_values == old_values
def test_api_team_accesses_update_owner_for_owners():
"""
A user who is "owner" of a team should not be allowed to update
an existing owner access for this team.
"""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
team = factories.TeamFactory(users=[(user, "owner")])
access = factories.TeamAccessFactory(team=team, role="owner")
old_values = serializers.TeamAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.choices)[0],
}
api_client = APIClient()
for field, value in new_values.items():
response = api_client.put(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
data={**old_values, field: value},
content_type="application/json",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 403
access.refresh_from_db()
updated_values = serializers.TeamAccessSerializer(instance=access).data
assert updated_values == old_values
def test_api_team_accesses_update_owner_self():
"""
A user who is owner of a team should be allowed to update
their own user access provided there are other owners in the team.
"""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
team = factories.TeamFactory()
access = factories.TeamAccessFactory(team=team, user=user, role="owner")
old_values = serializers.TeamAccessSerializer(instance=access).data
new_role = random.choice(["administrator", "member"])
api_client = APIClient()
response = api_client.put(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
data={**old_values, "role": new_role},
format="json",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 403
access.refresh_from_db()
assert access.role == "owner"
# Add another owner and it should now work
factories.TeamAccessFactory(team=team, role="owner")
response = api_client.put(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
data={**old_values, "role": new_role},
format="json",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 200
access.refresh_from_db()
assert access.role == new_role
# Delete
def test_api_team_accesses_delete_anonymous():
"""Anonymous users should not be allowed to destroy a team access."""
access = factories.TeamAccessFactory()
response = APIClient().delete(
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 401
assert models.TeamAccess.objects.count() == 1
def test_api_team_accesses_delete_authenticated():
"""
Authenticated users should not be allowed to delete a team access for a
team to which they are not related.
"""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
access = factories.TeamAccessFactory()
response = APIClient().delete(
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 403
assert models.TeamAccess.objects.count() == 1
def test_api_team_accesses_delete_member():
"""
Authenticated users should not be allowed to delete a team access for a
team in which they are a simple member.
"""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
team = factories.TeamFactory(users=[(user, "member")])
access = factories.TeamAccessFactory(team=team)
assert models.TeamAccess.objects.count() == 2
assert models.TeamAccess.objects.filter(user=access.user).exists()
response = APIClient().delete(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 403
assert models.TeamAccess.objects.count() == 2
def test_api_team_accesses_delete_administrators():
"""
Users who are administrators in a team should be allowed to delete an access
from the team provided it is not ownership.
"""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
team = factories.TeamFactory(users=[(user, "administrator")])
access = factories.TeamAccessFactory(
team=team, role=random.choice(["member", "administrator"])
)
assert models.TeamAccess.objects.count() == 2
assert models.TeamAccess.objects.filter(user=access.user).exists()
response = APIClient().delete(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 204
assert models.TeamAccess.objects.count() == 1
def test_api_team_accesses_delete_owners_except_owners():
"""
Users should be able to delete the team access of another user
for a team of which they are owner provided it is not an owner access.
"""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
team = factories.TeamFactory(users=[(user, "owner")])
access = factories.TeamAccessFactory(
team=team, role=random.choice(["member", "administrator"])
)
assert models.TeamAccess.objects.count() == 2
assert models.TeamAccess.objects.filter(user=access.user).exists()
response = APIClient().delete(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 204
assert models.TeamAccess.objects.count() == 1
def test_api_team_accesses_delete_owners_for_owners():
"""
Users should not be allowed to delete the team access of another owner
even for a team in which they are direct owner.
"""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
team = factories.TeamFactory(users=[(user, "owner")])
access = factories.TeamAccessFactory(team=team, role="owner")
assert models.TeamAccess.objects.count() == 2
assert models.TeamAccess.objects.filter(user=access.user).exists()
response = APIClient().delete(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 403
assert models.TeamAccess.objects.count() == 2
def test_api_team_accesses_delete_owners_last_owner():
"""
It should not be possible to delete the last owner access from a team
"""
identity = factories.IdentityFactory()
user = identity.user
jwt_token = OIDCToken.for_user(user)
team = factories.TeamFactory()
access = factories.TeamAccessFactory(team=team, user=user, role="owner")
assert models.TeamAccess.objects.count() == 1
response = APIClient().delete(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 403
assert models.TeamAccess.objects.count() == 1

View File

@@ -0,0 +1,791 @@
"""
Test template accesses API endpoints for users in publish's core app.
"""
import random
from uuid import uuid4
import pytest
from rest_framework.test import APIClient
from core import factories, models
from core.api import serializers
from .utils import OIDCToken
pytestmark = pytest.mark.django_db
def test_api_template_accesses_list_anonymous():
"""Anonymous users should not be allowed to list template accesses."""
template = factories.TemplateFactory()
factories.TemplateAccessFactory.create_batch(2, template=template)
response = APIClient().get(f"/api/v1.0/templates/{template.id!s}/accesses/")
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_template_accesses_list_authenticated_unrelated():
"""
Authenticated users should not be allowed to list template accesses for a template
to which they are not related.
"""
user = factories.UserFactory()
jwt_token = OIDCToken.for_user(user)
template = factories.TemplateFactory()
factories.TemplateAccessFactory.create_batch(3, template=template)
# Accesses for other templates to which the user is related should not be listed either
other_access = factories.TemplateAccessFactory(user=user)
factories.TemplateAccessFactory(template=other_access.template)
response = APIClient().get(
f"/api/v1.0/templates/{template.id!s}/accesses/",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 200
assert response.json() == {
"count": 0,
"next": None,
"previous": None,
"results": [],
}
def test_api_template_accesses_list_authenticated_related():
"""
Authenticated users should be able to list template accesses for a template
to which they are related, whatever their role in the template.
"""
user = factories.UserFactory()
jwt_token = OIDCToken.for_user(user)
template = factories.TemplateFactory()
user_access = models.TemplateAccess.objects.create(
template=template, user=user
) # random role
access1, access2 = factories.TemplateAccessFactory.create_batch(
2, template=template
)
# Accesses for other templates to which the user is related should not be listed either
other_access = factories.TemplateAccessFactory(user=user)
factories.TemplateAccessFactory(template=other_access.template)
response = APIClient().get(
f"/api/v1.0/templates/{template.id!s}/accesses/",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 200
content = response.json()
assert len(content["results"]) == 3
assert sorted(content["results"], key=lambda x: x["id"]) == sorted(
[
{
"id": str(user_access.id),
"user": str(user.id),
"role": user_access.role,
"abilities": user_access.get_abilities(user),
},
{
"id": str(access1.id),
"user": str(access1.user.id),
"role": access1.role,
"abilities": access1.get_abilities(user),
},
{
"id": str(access2.id),
"user": str(access2.user.id),
"role": access2.role,
"abilities": access2.get_abilities(user),
},
],
key=lambda x: x["id"],
)
def test_api_template_accesses_retrieve_anonymous():
"""
Anonymous users should not be allowed to retrieve a template access.
"""
access = factories.TemplateAccessFactory()
response = APIClient().get(
f"/api/v1.0/templates/{access.template.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_template_accesses_retrieve_authenticated_unrelated():
"""
Authenticated users should not be allowed to retrieve a template access for
a template to which they are not related.
"""
user = factories.UserFactory()
jwt_token = OIDCToken.for_user(user)
template = factories.TemplateFactory()
access = factories.TemplateAccessFactory(template=template)
response = APIClient().get(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
# Accesses related to another template should be excluded even if the user is related to it
for access in [
factories.TemplateAccessFactory(),
factories.TemplateAccessFactory(user=user),
]:
response = APIClient().get(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
def test_api_template_accesses_retrieve_authenticated_related():
"""
A user who is related to a template should be allowed to retrieve the
associated template user accesses.
"""
user = factories.UserFactory()
jwt_token = OIDCToken.for_user(user)
template = factories.TemplateFactory(users=[user])
access = factories.TemplateAccessFactory(template=template)
response = APIClient().get(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 200
assert response.json() == {
"id": str(access.id),
"user": str(access.user.id),
"role": access.role,
"abilities": access.get_abilities(user),
}
def test_api_template_accesses_create_anonymous():
"""Anonymous users should not be allowed to create template accesses."""
user = factories.UserFactory()
template = factories.TemplateFactory()
response = APIClient().post(
f"/api/v1.0/templates/{template.id!s}/accesses/",
{
"user": str(user.id),
"template": str(template.id),
"role": random.choice(models.RoleChoices.choices)[0],
},
format="json",
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
assert models.TemplateAccess.objects.exists() is False
def test_api_template_accesses_create_authenticated_unrelated():
"""
Authenticated users should not be allowed to create template accesses for a template to
which they are not related.
"""
user = factories.UserFactory()
jwt_token = OIDCToken.for_user(user)
other_user = factories.UserFactory()
template = factories.TemplateFactory()
response = APIClient().post(
f"/api/v1.0/templates/{template.id!s}/accesses/",
{
"user": str(other_user.id),
},
format="json",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 403
assert not models.TemplateAccess.objects.filter(user=other_user).exists()
def test_api_template_accesses_create_authenticated_member():
"""Members of a template should not be allowed to create template accesses."""
user = factories.UserFactory()
jwt_token = OIDCToken.for_user(user)
template = factories.TemplateFactory(users=[(user, "member")])
other_user = factories.UserFactory()
api_client = APIClient()
for role in [role[0] for role in models.RoleChoices.choices]:
response = api_client.post(
f"/api/v1.0/templates/{template.id!s}/accesses/",
{
"user": str(other_user.id),
"role": role,
},
format="json",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 403
assert not models.TemplateAccess.objects.filter(user=other_user).exists()
def test_api_template_accesses_create_authenticated_administrator():
"""
Administrators of a template should be able to create template accesses
except for the "owner" role.
"""
user = factories.UserFactory()
jwt_token = OIDCToken.for_user(user)
template = factories.TemplateFactory(users=[(user, "administrator")])
other_user = factories.UserFactory()
api_client = APIClient()
# It should not be allowed to create an owner access
response = api_client.post(
f"/api/v1.0/templates/{template.id!s}/accesses/",
{
"user": str(other_user.id),
"role": "owner",
},
format="json",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 403
assert response.json() == {
"detail": "Only owners of a template can assign other users as owners."
}
# It should be allowed to create a lower access
role = random.choice(
[role[0] for role in models.RoleChoices.choices if role[0] != "owner"]
)
response = api_client.post(
f"/api/v1.0/templates/{template.id!s}/accesses/",
{
"user": str(other_user.id),
"role": role,
},
format="json",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 201
assert models.TemplateAccess.objects.filter(user=other_user).count() == 1
new_template_access = models.TemplateAccess.objects.filter(user=other_user).get()
assert response.json() == {
"abilities": new_template_access.get_abilities(user),
"id": str(new_template_access.id),
"role": role,
"user": str(other_user.id),
}
def test_api_template_accesses_create_authenticated_owner():
"""
Owners of a template should be able to create template accesses whatever the role.
"""
user = factories.UserFactory()
jwt_token = OIDCToken.for_user(user)
template = factories.TemplateFactory(users=[(user, "owner")])
other_user = factories.UserFactory()
role = random.choice([role[0] for role in models.RoleChoices.choices])
response = APIClient().post(
f"/api/v1.0/templates/{template.id!s}/accesses/",
{
"user": str(other_user.id),
"role": role,
},
format="json",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 201
assert models.TemplateAccess.objects.filter(user=other_user).count() == 1
new_template_access = models.TemplateAccess.objects.filter(user=other_user).get()
assert response.json() == {
"abilities": new_template_access.get_abilities(user),
"id": str(new_template_access.id),
"role": role,
"user": str(other_user.id),
}
def test_api_template_accesses_update_anonymous():
"""Anonymous users should not be allowed to update a template access."""
access = factories.TemplateAccessFactory()
old_values = serializers.TemplateAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.choices)[0],
}
api_client = APIClient()
for field, value in new_values.items():
response = api_client.put(
f"/api/v1.0/templates/{access.template.id!s}/accesses/{access.id!s}/",
{**old_values, field: value},
format="json",
)
assert response.status_code == 401
access.refresh_from_db()
updated_values = serializers.TemplateAccessSerializer(instance=access).data
assert updated_values == old_values
def test_api_template_accesses_update_authenticated_unrelated():
"""
Authenticated users should not be allowed to update a template access for a template to which
they are not related.
"""
user = factories.UserFactory()
jwt_token = OIDCToken.for_user(user)
access = factories.TemplateAccessFactory()
old_values = serializers.TemplateAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.choices)[0],
}
api_client = APIClient()
for field, value in new_values.items():
response = api_client.put(
f"/api/v1.0/templates/{access.template.id!s}/accesses/{access.id!s}/",
{**old_values, field: value},
format="json",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 403
access.refresh_from_db()
updated_values = serializers.TemplateAccessSerializer(instance=access).data
assert updated_values == old_values
def test_api_template_accesses_update_authenticated_member():
"""Members of a template should not be allowed to update its accesses."""
user = factories.UserFactory()
jwt_token = OIDCToken.for_user(user)
template = factories.TemplateFactory(users=[(user, "member")])
access = factories.TemplateAccessFactory(template=template)
old_values = serializers.TemplateAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.choices)[0],
}
api_client = APIClient()
for field, value in new_values.items():
response = api_client.put(
f"/api/v1.0/templates/{access.template.id!s}/accesses/{access.id!s}/",
{**old_values, field: value},
format="json",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 403
access.refresh_from_db()
updated_values = serializers.TemplateAccessSerializer(instance=access).data
assert updated_values == old_values
def test_api_template_accesses_update_administrator_except_owner():
"""
A user who is an administrator in a template should be allowed to update a user
access for this template, as long as they don't try to set the role to owner.
"""
user = factories.UserFactory()
jwt_token = OIDCToken.for_user(user)
template = factories.TemplateFactory(users=[(user, "administrator")])
access = factories.TemplateAccessFactory(
template=template,
role=random.choice(["administrator", "member"]),
)
old_values = serializers.TemplateAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": random.choice(["administrator", "member"]),
}
api_client = APIClient()
for field, value in new_values.items():
new_data = {**old_values, field: value}
response = api_client.put(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
data=new_data,
format="json",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
if (
new_data["role"] == old_values["role"]
): # we are not really updating the role
assert response.status_code == 403
else:
assert response.status_code == 200
access.refresh_from_db()
updated_values = serializers.TemplateAccessSerializer(instance=access).data
if field == "role":
assert updated_values == {**old_values, "role": new_values["role"]}
else:
assert updated_values == old_values
def test_api_template_accesses_update_administrator_from_owner():
"""
A user who is an administrator in a template, should not be allowed to update
the user access of an "owner" for this template.
"""
user = factories.UserFactory()
jwt_token = OIDCToken.for_user(user)
template = factories.TemplateFactory(users=[(user, "administrator")])
other_user = factories.UserFactory()
access = factories.TemplateAccessFactory(
template=template, user=other_user, role="owner"
)
old_values = serializers.TemplateAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.choices)[0],
}
api_client = APIClient()
for field, value in new_values.items():
response = api_client.put(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
data={**old_values, field: value},
format="json",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 403
access.refresh_from_db()
updated_values = serializers.TemplateAccessSerializer(instance=access).data
assert updated_values == old_values
def test_api_template_accesses_update_administrator_to_owner():
"""
A user who is an administrator in a template, should not be allowed to update
the user access of another user to grant template ownership.
"""
user = factories.UserFactory()
jwt_token = OIDCToken.for_user(user)
template = factories.TemplateFactory(users=[(user, "administrator")])
other_user = factories.UserFactory()
access = factories.TemplateAccessFactory(
template=template,
user=other_user,
role=random.choice(["administrator", "member"]),
)
old_values = serializers.TemplateAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": "owner",
}
api_client = APIClient()
for field, value in new_values.items():
new_data = {**old_values, field: value}
response = api_client.put(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
data=new_data,
format="json",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
# We are not allowed or not really updating the role
if field == "role" or new_data["role"] == old_values["role"]:
assert response.status_code == 403
else:
assert response.status_code == 200
access.refresh_from_db()
updated_values = serializers.TemplateAccessSerializer(instance=access).data
assert updated_values == old_values
def test_api_template_accesses_update_owner():
"""
A user who is an owner in a template should be allowed to update
a user access for this template whatever the role.
"""
user = factories.UserFactory()
jwt_token = OIDCToken.for_user(user)
template = factories.TemplateFactory(users=[(user, "owner")])
factories.UserFactory()
access = factories.TemplateAccessFactory(
template=template,
)
old_values = serializers.TemplateAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.choices)[0],
}
api_client = APIClient()
for field, value in new_values.items():
new_data = {**old_values, field: value}
response = api_client.put(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
data=new_data,
format="json",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
if (
new_data["role"] == old_values["role"]
): # we are not really updating the role
assert response.status_code == 403
else:
assert response.status_code == 200
access.refresh_from_db()
updated_values = serializers.TemplateAccessSerializer(instance=access).data
if field == "role":
assert updated_values == {**old_values, "role": new_values["role"]}
else:
assert updated_values == old_values
def test_api_template_accesses_update_owner_self():
"""
A user who is owner of a template should be allowed to update
their own user access provided there are other owners in the template.
"""
user = factories.UserFactory()
jwt_token = OIDCToken.for_user(user)
template = factories.TemplateFactory()
access = factories.TemplateAccessFactory(template=template, user=user, role="owner")
old_values = serializers.TemplateAccessSerializer(instance=access).data
new_role = random.choice(["administrator", "member"])
api_client = APIClient()
response = api_client.put(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
data={**old_values, "role": new_role},
format="json",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 403
access.refresh_from_db()
assert access.role == "owner"
# Add another owner and it should now work
factories.TemplateAccessFactory(template=template, role="owner")
response = api_client.put(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
data={**old_values, "role": new_role},
format="json",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 200
access.refresh_from_db()
assert access.role == new_role
# Delete
def test_api_template_accesses_delete_anonymous():
"""Anonymous users should not be allowed to destroy a template access."""
access = factories.TemplateAccessFactory()
response = APIClient().delete(
f"/api/v1.0/templates/{access.template.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 401
assert models.TemplateAccess.objects.count() == 1
def test_api_template_accesses_delete_authenticated():
"""
Authenticated users should not be allowed to delete a template access for a
template to which they are not related.
"""
user = factories.UserFactory()
jwt_token = OIDCToken.for_user(user)
access = factories.TemplateAccessFactory()
response = APIClient().delete(
f"/api/v1.0/templates/{access.template.id!s}/accesses/{access.id!s}/",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 403
assert models.TemplateAccess.objects.count() == 1
def test_api_template_accesses_delete_member():
"""
Authenticated users should not be allowed to delete a template access for a
template in which they are a simple member.
"""
user = factories.UserFactory()
jwt_token = OIDCToken.for_user(user)
template = factories.TemplateFactory(users=[(user, "member")])
access = factories.TemplateAccessFactory(template=template)
assert models.TemplateAccess.objects.count() == 2
assert models.TemplateAccess.objects.filter(user=access.user).exists()
response = APIClient().delete(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 403
assert models.TemplateAccess.objects.count() == 2
def test_api_template_accesses_delete_administrators_except_owners():
"""
Users who are administrators in a template should be allowed to delete an access
from the template provided it is not ownership.
"""
user = factories.UserFactory()
jwt_token = OIDCToken.for_user(user)
template = factories.TemplateFactory(users=[(user, "administrator")])
access = factories.TemplateAccessFactory(
template=template, role=random.choice(["member", "administrator"])
)
assert models.TemplateAccess.objects.count() == 2
assert models.TemplateAccess.objects.filter(user=access.user).exists()
response = APIClient().delete(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 204
assert models.TemplateAccess.objects.count() == 1
def test_api_template_accesses_delete_administrators_owners():
"""
Users who are administrators in a template should not be allowed to delete an ownership
access from the template.
"""
user = factories.UserFactory()
jwt_token = OIDCToken.for_user(user)
template = factories.TemplateFactory(users=[(user, "administrator")])
access = factories.TemplateAccessFactory(template=template, role="owner")
assert models.TemplateAccess.objects.count() == 2
assert models.TemplateAccess.objects.filter(user=access.user).exists()
response = APIClient().delete(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 403
assert models.TemplateAccess.objects.count() == 2
def test_api_template_accesses_delete_owners():
"""
Users should be able to delete the template access of another user
for a template of which they are owner.
"""
user = factories.UserFactory()
jwt_token = OIDCToken.for_user(user)
template = factories.TemplateFactory(users=[(user, "owner")])
access = factories.TemplateAccessFactory(
template=template,
)
assert models.TemplateAccess.objects.count() == 2
assert models.TemplateAccess.objects.filter(user=access.user).exists()
response = APIClient().delete(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 204
assert models.TemplateAccess.objects.count() == 1
def test_api_template_accesses_delete_owners_last_owner():
"""
It should not be possible to delete the last owner access from a template
"""
user = factories.UserFactory()
jwt_token = OIDCToken.for_user(user)
template = factories.TemplateFactory()
access = factories.TemplateAccessFactory(template=template, user=user, role="owner")
assert models.TemplateAccess.objects.count() == 1
response = APIClient().delete(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)
assert response.status_code == 403
assert models.TemplateAccess.objects.count() == 1

View File

@@ -25,8 +25,8 @@ def test_api_users_list_authenticated():
"""
Authenticated users should not be able to list users.
"""
identity = factories.IdentityFactory()
jwt_token = OIDCToken.for_user(identity.user)
user = factories.UserFactory()
jwt_token = OIDCToken.for_user(user)
factories.UserFactory.create_batch(2)
response = APIClient().get(
@@ -49,15 +49,9 @@ def test_api_users_retrieve_me_anonymous():
def test_api_users_retrieve_me_authenticated():
"""Authenticated users should be able to retrieve their own user via the "/users/me" path."""
identity = factories.IdentityFactory()
user = identity.user
user = factories.UserFactory()
jwt_token = OIDCToken.for_user(user)
# Define profile contact
contact = factories.ContactFactory(owner=user)
user.profile_contact = contact
user.save()
factories.UserFactory.create_batch(2)
response = APIClient().get(
"/api/v1.0/users/me/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}"
@@ -70,7 +64,6 @@ def test_api_users_retrieve_me_authenticated():
"timezone": str(user.timezone),
"is_device": False,
"is_staff": False,
"data": user.profile_contact.data,
}
@@ -91,8 +84,7 @@ 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.
"""
identity = factories.IdentityFactory()
user = identity.user
user = factories.UserFactory()
jwt_token = OIDCToken.for_user(user)
response = APIClient().get(
@@ -107,8 +99,8 @@ def test_api_users_retrieve_authenticated_other():
Authenticated users should be able to retrieve another user's detail view with
limited information.
"""
identity = factories.IdentityFactory()
jwt_token = OIDCToken.for_user(identity.user)
user = factories.UserFactory()
jwt_token = OIDCToken.for_user(user)
other_user = factories.UserFactory()
@@ -135,8 +127,7 @@ def test_api_users_create_anonymous():
def test_api_users_create_authenticated():
"""Authenticated users should not be able to create users via the API."""
identity = factories.IdentityFactory()
user = identity.user
user = factories.UserFactory()
jwt_token = OIDCToken.for_user(user)
response = APIClient().post(
@@ -182,8 +173,7 @@ def test_api_users_update_authenticated_self():
Authenticated users should be able to update their own user but only "language"
and "timezone" fields.
"""
identity = factories.IdentityFactory()
user = identity.user
user = factories.UserFactory()
jwt_token = OIDCToken.for_user(user)
old_user_values = dict(serializers.UserSerializer(instance=user).data)
@@ -210,8 +200,8 @@ def test_api_users_update_authenticated_self():
def test_api_users_update_authenticated_other():
"""Authenticated users should not be allowed to update other users."""
identity = factories.IdentityFactory()
jwt_token = OIDCToken.for_user(identity.user)
user = factories.UserFactory()
jwt_token = OIDCToken.for_user(user)
user = factories.UserFactory()
old_user_values = dict(serializers.UserSerializer(instance=user).data)
@@ -262,8 +252,7 @@ def test_api_users_patch_authenticated_self():
Authenticated users should be able to patch their own user but only "language"
and "timezone" fields.
"""
identity = factories.IdentityFactory()
user = identity.user
user = factories.UserFactory()
jwt_token = OIDCToken.for_user(user)
old_user_values = dict(serializers.UserSerializer(instance=user).data)
@@ -291,8 +280,8 @@ def test_api_users_patch_authenticated_self():
def test_api_users_patch_authenticated_other():
"""Authenticated users should not be allowed to patch other users."""
identity = factories.IdentityFactory()
jwt_token = OIDCToken.for_user(identity.user)
user = factories.UserFactory()
jwt_token = OIDCToken.for_user(user)
user = factories.UserFactory()
old_user_values = dict(serializers.UserSerializer(instance=user).data)
@@ -329,8 +318,8 @@ 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."""
factories.UserFactory.create_batch(2)
identity = factories.IdentityFactory()
jwt_token = OIDCToken.for_user(identity.user)
user = factories.UserFactory()
jwt_token = OIDCToken.for_user(user)
client = APIClient()
response = client.delete(
@@ -355,8 +344,8 @@ def test_api_users_delete_authenticated():
"""
Authenticated users should not be allowed to delete a user other than themselves.
"""
identity = factories.IdentityFactory()
jwt_token = OIDCToken.for_user(identity.user)
user = factories.UserFactory()
jwt_token = OIDCToken.for_user(user)
other_user = factories.UserFactory()
response = APIClient().delete(
@@ -369,11 +358,11 @@ def test_api_users_delete_authenticated():
def test_api_users_delete_self():
"""Authenticated users should not be able to delete their own user."""
identity = factories.IdentityFactory()
jwt_token = OIDCToken.for_user(identity.user)
user = factories.UserFactory()
jwt_token = OIDCToken.for_user(user)
response = APIClient().delete(
f"/api/v1.0/users/{identity.user.id!s}/",
f"/api/v1.0/users/{user.id!s}/",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
)

View File

@@ -1,183 +0,0 @@
"""
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_required():
"""The "email" field is required."""
user = factories.UserFactory()
with pytest.raises(ValidationError, match="This field cannot be null."):
models.Identity.objects.create(user=user, 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():
"""Identitys 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é"

View File

@@ -1,264 +0,0 @@
"""
Unit tests for the TeamAccess model
"""
from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import ValidationError
import pytest
from core import factories
pytestmark = pytest.mark.django_db
def test_models_team_accesses_str():
"""
The str representation should include user name, team full name and role.
"""
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) == "David Bowman is member in team admins"
def test_models_team_accesses_unique():
"""Team accesses should be unique for a given couple of user and team."""
access = factories.TeamAccessFactory()
with pytest.raises(
ValidationError,
match="Team/user relation with this User and Team already exists.",
):
factories.TeamAccessFactory(user=access.user, team=access.team)
# get_abilities
def test_models_team_access_get_abilities_anonymous():
"""Check abilities returned for an anonymous user."""
access = factories.TeamAccessFactory()
abilities = access.get_abilities(AnonymousUser())
assert abilities == {
"delete": False,
"get": False,
"patch": False,
"put": False,
"set_role_to": [],
}
def test_models_team_access_get_abilities_authenticated():
"""Check abilities returned for an authenticated user."""
access = factories.TeamAccessFactory()
user = factories.UserFactory()
abilities = access.get_abilities(user)
assert abilities == {
"delete": False,
"get": False,
"patch": False,
"put": False,
"set_role_to": [],
}
# - for owner
def test_models_team_access_get_abilities_for_owner_of_self_allowed():
"""
Check abilities of self access for the owner of a team when there is more than one user left.
"""
access = factories.TeamAccessFactory(role="owner")
factories.TeamAccessFactory(team=access.team, role="owner")
abilities = access.get_abilities(access.user)
assert abilities == {
"delete": True,
"get": True,
"patch": True,
"put": True,
"set_role_to": ["administrator", "member"],
}
def test_models_team_access_get_abilities_for_owner_of_self_last():
"""Check abilities of self access for the owner of a team when there is only one owner left."""
access = factories.TeamAccessFactory(role="owner")
abilities = access.get_abilities(access.user)
assert abilities == {
"delete": False,
"get": True,
"patch": False,
"put": False,
"set_role_to": [],
}
def test_models_team_access_get_abilities_for_owner_of_owner():
"""Check abilities of owner access for the owner of a team."""
access = factories.TeamAccessFactory(role="owner")
factories.TeamAccessFactory(team=access.team) # another one
user = factories.TeamAccessFactory(team=access.team, role="owner").user
abilities = access.get_abilities(user)
assert abilities == {
"delete": False,
"get": True,
"patch": False,
"put": False,
"set_role_to": [],
}
def test_models_team_access_get_abilities_for_owner_of_administrator():
"""Check abilities of administrator access for the owner of a team."""
access = factories.TeamAccessFactory(role="administrator")
factories.TeamAccessFactory(team=access.team) # another one
user = factories.TeamAccessFactory(team=access.team, role="owner").user
abilities = access.get_abilities(user)
assert abilities == {
"delete": True,
"get": True,
"patch": True,
"put": True,
"set_role_to": ["owner", "member"],
}
def test_models_team_access_get_abilities_for_owner_of_member():
"""Check abilities of member access for the owner of a team."""
access = factories.TeamAccessFactory(role="member")
factories.TeamAccessFactory(team=access.team) # another one
user = factories.TeamAccessFactory(team=access.team, role="owner").user
abilities = access.get_abilities(user)
assert abilities == {
"delete": True,
"get": True,
"patch": True,
"put": True,
"set_role_to": ["owner", "administrator"],
}
# - for administrator
def test_models_team_access_get_abilities_for_administrator_of_owner():
"""Check abilities of owner access for the administrator of a team."""
access = factories.TeamAccessFactory(role="owner")
factories.TeamAccessFactory(team=access.team) # another one
user = factories.TeamAccessFactory(team=access.team, role="administrator").user
abilities = access.get_abilities(user)
assert abilities == {
"delete": False,
"get": True,
"patch": False,
"put": False,
"set_role_to": [],
}
def test_models_team_access_get_abilities_for_administrator_of_administrator():
"""Check abilities of administrator access for the administrator of a team."""
access = factories.TeamAccessFactory(role="administrator")
factories.TeamAccessFactory(team=access.team) # another one
user = factories.TeamAccessFactory(team=access.team, role="administrator").user
abilities = access.get_abilities(user)
assert abilities == {
"delete": True,
"get": True,
"patch": True,
"put": True,
"set_role_to": ["member"],
}
def test_models_team_access_get_abilities_for_administrator_of_member():
"""Check abilities of member access for the administrator of a team."""
access = factories.TeamAccessFactory(role="member")
factories.TeamAccessFactory(team=access.team) # another one
user = factories.TeamAccessFactory(team=access.team, role="administrator").user
abilities = access.get_abilities(user)
assert abilities == {
"delete": True,
"get": True,
"patch": True,
"put": True,
"set_role_to": ["administrator"],
}
# - for member
def test_models_team_access_get_abilities_for_member_of_owner():
"""Check abilities of owner access for the member of a team."""
access = factories.TeamAccessFactory(role="owner")
factories.TeamAccessFactory(team=access.team) # another one
user = factories.TeamAccessFactory(team=access.team, role="member").user
abilities = access.get_abilities(user)
assert abilities == {
"delete": False,
"get": True,
"patch": False,
"put": False,
"set_role_to": [],
}
def test_models_team_access_get_abilities_for_member_of_administrator():
"""Check abilities of administrator access for the member of a team."""
access = factories.TeamAccessFactory(role="administrator")
factories.TeamAccessFactory(team=access.team) # another one
user = factories.TeamAccessFactory(team=access.team, role="member").user
abilities = access.get_abilities(user)
assert abilities == {
"delete": False,
"get": True,
"patch": False,
"put": False,
"set_role_to": [],
}
def test_models_team_access_get_abilities_for_member_of_member_user(
django_assert_num_queries
):
"""Check abilities of member access for the member of a team."""
access = factories.TeamAccessFactory(role="member")
factories.TeamAccessFactory(team=access.team) # another one
user = factories.TeamAccessFactory(team=access.team, role="member").user
with django_assert_num_queries(1):
abilities = access.get_abilities(user)
assert abilities == {
"delete": False,
"get": True,
"patch": False,
"put": False,
"set_role_to": [],
}
def test_models_team_access_get_abilities_preset_role(django_assert_num_queries):
"""No query is done if the role is preset, e.g., with a query annotation."""
access = factories.TeamAccessFactory(role="member")
user = factories.TeamAccessFactory(team=access.team, role="member").user
access.user_role = "member"
with django_assert_num_queries(0):
abilities = access.get_abilities(user)
assert abilities == {
"delete": False,
"get": True,
"patch": False,
"put": False,
"set_role_to": [],
}

View File

@@ -1,135 +0,0 @@
"""
Unit tests for the Team model
"""
from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import ValidationError
import pytest
from core import factories, models
pytestmark = pytest.mark.django_db
def test_models_teams_str():
"""The str representation should be the name of the team."""
team = factories.TeamFactory(name="admins")
assert str(team) == "admins"
def test_models_teams_id_unique():
"""The "id" field should be unique."""
team = factories.TeamFactory()
with pytest.raises(ValidationError, match="Team with this Id already exists."):
factories.TeamFactory(id=team.id)
def test_models_teams_name_null():
"""The "name" field should not be null."""
with pytest.raises(ValidationError, match="This field cannot be null."):
models.Team.objects.create(name=None)
def test_models_teams_name_empty():
"""The "name" field should not be empty."""
with pytest.raises(ValidationError, match="This field cannot be blank."):
models.Team.objects.create(name="")
def test_models_teams_name_max_length():
"""The "name" field should be 100 characters maximum."""
factories.TeamFactory(name="a " * 50)
with pytest.raises(
ValidationError,
match=r"Ensure this value has at most 100 characters \(it has 102\)\.",
):
factories.TeamFactory(name="a " * 51)
# get_abilities
def test_models_teams_get_abilities_anonymous():
"""Check abilities returned for an anonymous user."""
team = factories.TeamFactory()
abilities = team.get_abilities(AnonymousUser())
assert abilities == {
"delete": False,
"get": True,
"patch": False,
"put": False,
"manage_accesses": False,
}
def test_models_teams_get_abilities_authenticated():
"""Check abilities returned for an authenticated user."""
team = factories.TeamFactory()
abilities = team.get_abilities(factories.UserFactory())
assert abilities == {
"delete": False,
"get": True,
"patch": False,
"put": False,
"manage_accesses": False,
}
def test_models_teams_get_abilities_owner():
"""Check abilities returned for the owner of a team."""
user = factories.UserFactory()
access = factories.TeamAccessFactory(role="owner", user=user)
abilities = access.team.get_abilities(access.user)
assert abilities == {
"delete": True,
"get": True,
"patch": True,
"put": True,
"manage_accesses": True,
}
def test_models_teams_get_abilities_administrator():
"""Check abilities returned for the administrator of a team."""
access = factories.TeamAccessFactory(role="administrator")
abilities = access.team.get_abilities(access.user)
assert abilities == {
"delete": False,
"get": True,
"patch": True,
"put": True,
"manage_accesses": True,
}
def test_models_teams_get_abilities_member_user(django_assert_num_queries):
"""Check abilities returned for the member of a team."""
access = factories.TeamAccessFactory(role="member")
with django_assert_num_queries(1):
abilities = access.team.get_abilities(access.user)
assert abilities == {
"delete": False,
"get": True,
"patch": False,
"put": False,
"manage_accesses": False,
}
def test_models_teams_get_abilities_preset_role(django_assert_num_queries):
"""No query is done if the role is preset e.g. with query annotation."""
access = factories.TeamAccessFactory(role="member")
access.team.user_role = "member"
with django_assert_num_queries(0):
abilities = access.team.get_abilities(access.user)
assert abilities == {
"delete": False,
"get": True,
"patch": False,
"put": False,
"manage_accesses": False,
}

View File

@@ -0,0 +1,256 @@
"""
Unit tests for the TemplateAccess model
"""
from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import ValidationError
import pytest
from core import factories
pytestmark = pytest.mark.django_db
def test_models_template_accesses_str():
"""
The str representation should include user email, template title and role.
"""
user = factories.UserFactory(email="david.bowman@example.com")
access = factories.TemplateAccessFactory(
role="member",
user=user,
template__title="admins",
)
assert str(access) == "david.bowman@example.com is member in template admins"
def test_models_template_accesses_unique():
"""Template accesses should be unique for a given couple of user and template."""
access = factories.TemplateAccessFactory()
with pytest.raises(
ValidationError,
match="Template/user relation with this User and Template already exists.",
):
factories.TemplateAccessFactory(user=access.user, template=access.template)
# get_abilities
def test_models_template_access_get_abilities_anonymous():
"""Check abilities returned for an anonymous user."""
access = factories.TemplateAccessFactory()
abilities = access.get_abilities(AnonymousUser())
assert abilities == {
"destroy": False,
"retrieve": False,
"update": False,
"set_role_to": [],
}
def test_models_template_access_get_abilities_authenticated():
"""Check abilities returned for an authenticated user."""
access = factories.TemplateAccessFactory()
user = factories.UserFactory()
abilities = access.get_abilities(user)
assert abilities == {
"destroy": False,
"retrieve": False,
"update": False,
"set_role_to": [],
}
# - for owner
def test_models_template_access_get_abilities_for_owner_of_self_allowed():
"""
Check abilities of self access for the owner of a template when
there is more than one owner left.
"""
access = factories.TemplateAccessFactory(role="owner")
factories.TemplateAccessFactory(template=access.template, role="owner")
abilities = access.get_abilities(access.user)
assert abilities == {
"destroy": True,
"retrieve": True,
"update": True,
"set_role_to": ["administrator", "member"],
}
def test_models_template_access_get_abilities_for_owner_of_self_last():
"""
Check abilities of self access for the owner of a template when there is only one owner left.
"""
access = factories.TemplateAccessFactory(role="owner")
abilities = access.get_abilities(access.user)
assert abilities == {
"destroy": False,
"retrieve": True,
"update": False,
"set_role_to": [],
}
def test_models_template_access_get_abilities_for_owner_of_owner():
"""Check abilities of owner access for the owner of a template."""
access = factories.TemplateAccessFactory(role="owner")
factories.TemplateAccessFactory(template=access.template) # another one
user = factories.TemplateAccessFactory(template=access.template, role="owner").user
abilities = access.get_abilities(user)
assert abilities == {
"destroy": True,
"retrieve": True,
"update": True,
"set_role_to": ["administrator", "member"],
}
def test_models_template_access_get_abilities_for_owner_of_administrator():
"""Check abilities of administrator access for the owner of a template."""
access = factories.TemplateAccessFactory(role="administrator")
factories.TemplateAccessFactory(template=access.template) # another one
user = factories.TemplateAccessFactory(template=access.template, role="owner").user
abilities = access.get_abilities(user)
assert abilities == {
"destroy": True,
"retrieve": True,
"update": True,
"set_role_to": ["owner", "member"],
}
def test_models_template_access_get_abilities_for_owner_of_member():
"""Check abilities of member access for the owner of a template."""
access = factories.TemplateAccessFactory(role="member")
factories.TemplateAccessFactory(template=access.template) # another one
user = factories.TemplateAccessFactory(template=access.template, role="owner").user
abilities = access.get_abilities(user)
assert abilities == {
"destroy": True,
"retrieve": True,
"update": True,
"set_role_to": ["owner", "administrator"],
}
# - for administrator
def test_models_template_access_get_abilities_for_administrator_of_owner():
"""Check abilities of owner access for the administrator of a template."""
access = factories.TemplateAccessFactory(role="owner")
factories.TemplateAccessFactory(template=access.template) # another one
user = factories.TemplateAccessFactory(
template=access.template, role="administrator"
).user
abilities = access.get_abilities(user)
assert abilities == {
"destroy": False,
"retrieve": True,
"update": False,
"set_role_to": [],
}
def test_models_template_access_get_abilities_for_administrator_of_administrator():
"""Check abilities of administrator access for the administrator of a template."""
access = factories.TemplateAccessFactory(role="administrator")
factories.TemplateAccessFactory(template=access.template) # another one
user = factories.TemplateAccessFactory(
template=access.template, role="administrator"
).user
abilities = access.get_abilities(user)
assert abilities == {
"destroy": True,
"retrieve": True,
"update": True,
"set_role_to": ["member"],
}
def test_models_template_access_get_abilities_for_administrator_of_member():
"""Check abilities of member access for the administrator of a template."""
access = factories.TemplateAccessFactory(role="member")
factories.TemplateAccessFactory(template=access.template) # another one
user = factories.TemplateAccessFactory(
template=access.template, role="administrator"
).user
abilities = access.get_abilities(user)
assert abilities == {
"destroy": True,
"retrieve": True,
"update": True,
"set_role_to": ["administrator"],
}
# - for member
def test_models_template_access_get_abilities_for_member_of_owner():
"""Check abilities of owner access for the member of a template."""
access = factories.TemplateAccessFactory(role="owner")
factories.TemplateAccessFactory(template=access.template) # another one
user = factories.TemplateAccessFactory(template=access.template, role="member").user
abilities = access.get_abilities(user)
assert abilities == {
"destroy": False,
"retrieve": True,
"update": False,
"set_role_to": [],
}
def test_models_template_access_get_abilities_for_member_of_administrator():
"""Check abilities of administrator access for the member of a template."""
access = factories.TemplateAccessFactory(role="administrator")
factories.TemplateAccessFactory(template=access.template) # another one
user = factories.TemplateAccessFactory(template=access.template, role="member").user
abilities = access.get_abilities(user)
assert abilities == {
"destroy": False,
"retrieve": True,
"update": False,
"set_role_to": [],
}
def test_models_template_access_get_abilities_for_member_of_member_user(
django_assert_num_queries
):
"""Check abilities of member access for the member of a template."""
access = factories.TemplateAccessFactory(role="member")
factories.TemplateAccessFactory(template=access.template) # another one
user = factories.TemplateAccessFactory(template=access.template, role="member").user
with django_assert_num_queries(1):
abilities = access.get_abilities(user)
assert abilities == {
"destroy": False,
"retrieve": True,
"update": False,
"set_role_to": [],
}
def test_models_template_access_get_abilities_preset_role(django_assert_num_queries):
"""No query is done if the role is preset, e.g., with a query annotation."""
access = factories.TemplateAccessFactory(role="member")
user = factories.TemplateAccessFactory(template=access.template, role="member").user
access.user_role = "member"
with django_assert_num_queries(0):
abilities = access.get_abilities(user)
assert abilities == {
"destroy": False,
"retrieve": True,
"update": False,
"set_role_to": [],
}

View File

@@ -0,0 +1,161 @@
"""
Unit tests for the Template model
"""
from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import ValidationError
import pytest
from core import factories, models
pytestmark = pytest.mark.django_db
def test_models_templates_str():
"""The str representation should be the title of the template."""
template = factories.TemplateFactory(title="admins")
assert str(template) == "admins"
def test_models_templates_id_unique():
"""The "id" field should be unique."""
template = factories.TemplateFactory()
with pytest.raises(ValidationError, match="Template with this Id already exists."):
factories.TemplateFactory(id=template.id)
def test_models_templates_title_null():
"""The "title" field should not be null."""
with pytest.raises(ValidationError, match="This field cannot be null."):
models.Template.objects.create(title=None)
def test_models_templates_title_empty():
"""The "title" field should not be empty."""
with pytest.raises(ValidationError, match="This field cannot be blank."):
models.Template.objects.create(title="")
def test_models_templates_title_max_length():
"""The "title" field should be 100 characters maximum."""
factories.TemplateFactory(title="a" * 255)
with pytest.raises(
ValidationError,
match=r"Ensure this value has at most 255 characters \(it has 256\)\.",
):
factories.TemplateFactory(title="a" * 256)
# get_abilities
def test_models_templates_get_abilities_anonymous_public():
"""Check abilities returned for an anonymous user if the template is public."""
template = factories.TemplateFactory(is_public=True)
abilities = template.get_abilities(AnonymousUser())
assert abilities == {
"destroy": False,
"retrieve": True,
"update": False,
"manage_accesses": False,
"generate_document": True,
}
def test_models_templates_get_abilities_anonymous_not_public():
"""Check abilities returned for an anonymous user if the template is private."""
template = factories.TemplateFactory(is_public=False)
abilities = template.get_abilities(AnonymousUser())
assert abilities == {
"destroy": False,
"retrieve": False,
"update": False,
"manage_accesses": False,
"generate_document": False,
}
def test_models_templates_get_abilities_authenticated_public():
"""Check abilities returned for an authenticated user if the user is public."""
template = factories.TemplateFactory(is_public=True)
abilities = template.get_abilities(factories.UserFactory())
assert abilities == {
"destroy": False,
"retrieve": True,
"update": False,
"manage_accesses": False,
"generate_document": True,
}
def test_models_templates_get_abilities_authenticated_not_public():
"""Check abilities returned for an authenticated user if the template is private."""
template = factories.TemplateFactory(is_public=False)
abilities = template.get_abilities(factories.UserFactory())
assert abilities == {
"destroy": False,
"retrieve": False,
"update": False,
"manage_accesses": False,
"generate_document": False,
}
def test_models_templates_get_abilities_owner():
"""Check abilities returned for the owner of a template."""
user = factories.UserFactory()
access = factories.TemplateAccessFactory(role="owner", user=user)
abilities = access.template.get_abilities(access.user)
assert abilities == {
"destroy": True,
"retrieve": True,
"update": True,
"manage_accesses": True,
"generate_document": True,
}
def test_models_templates_get_abilities_administrator():
"""Check abilities returned for the administrator of a template."""
access = factories.TemplateAccessFactory(role="administrator")
abilities = access.template.get_abilities(access.user)
assert abilities == {
"destroy": False,
"retrieve": True,
"update": True,
"manage_accesses": True,
"generate_document": True,
}
def test_models_templates_get_abilities_member_user(django_assert_num_queries):
"""Check abilities returned for the member of a template."""
access = factories.TemplateAccessFactory(role="member")
with django_assert_num_queries(1):
abilities = access.template.get_abilities(access.user)
assert abilities == {
"destroy": False,
"retrieve": True,
"update": False,
"manage_accesses": False,
"generate_document": True,
}
def test_models_templates_get_abilities_preset_role(django_assert_num_queries):
"""No query is done if the role is preset e.g. with query annotation."""
access = factories.TemplateAccessFactory(role="member")
access.template.user_role = "member"
with django_assert_num_queries(0):
abilities = access.template.get_abilities(access.user)
assert abilities == {
"destroy": False,
"retrieve": True,
"update": False,
"manage_accesses": False,
"generate_document": True,
}

View File

@@ -7,19 +7,15 @@ from django.core.exceptions import ValidationError
import pytest
from core import factories, models
from core import factories
pytestmark = pytest.mark.django_db
def test_models_users_str():
"""The str representation should be the full name."""
"""The str representation should be the email."""
user = factories.UserFactory()
contact = factories.ContactFactory(full_name="david bowman", owner=user)
user.profile_contact = contact
user.save()
assert str(user) == "david bowman"
assert str(user) == user.email
def test_models_users_id_unique():
@@ -29,55 +25,21 @@ def test_models_users_id_unique():
factories.UserFactory(id=user.id)
def test_models_users_profile_not_owned():
"""A user cannot declare as profile a contact that not is owned."""
user = factories.UserFactory()
contact = factories.ContactFactory(base=None, owner=None)
user.profile_contact = contact
with pytest.raises(ValidationError) as excinfo:
user.save()
assert (
str(excinfo.value)
== "{'__all__': ['Users can only declare as profile a contact they own.']}"
)
def test_models_users_profile_owned_by_other():
"""A user cannot declare as profile a contact that is owned by another user."""
user = factories.UserFactory()
contact = factories.ContactFactory()
user.profile_contact = contact
with pytest.raises(ValidationError) as excinfo:
user.save()
assert (
str(excinfo.value)
== "{'__all__': ['Users can only declare as profile a contact they own.']}"
)
def test_models_users_send_mail_main_existing():
"""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)
"""The "email_user' method should send mail to the user's email address."""
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, ["dave@example.com"]
)
mock_send.assert_called_once_with("my subject", "my message", None, [user.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()
user = factories.UserFactory(email=None)
with pytest.raises(models.Identity.DoesNotExist) as excinfo:
with pytest.raises(ValueError) as excinfo:
user.email_user("my subject", "my message")
assert str(excinfo.value) == "Identity matching query does not exist."
assert str(excinfo.value) == "User has no email address."

View File

@@ -7,15 +7,9 @@ class OIDCToken(AccessToken):
@classmethod
def for_user(cls, user):
token = super().for_user(user)
identity = user.identities.filter(is_main=True).first()
token["first_name"] = (
user.profile_contact.short_name if user.profile_contact else "David"
)
token["last_name"] = (
" ".join(user.profile_contact.full_name.split()[1:])
if user.profile_contact
else "Bowman"
)
token["email"] = identity.email
"""Returns an authorization token for the given user for testing."""
token = cls()
token["sub"] = str(user.sub)
token["email"] = user.email
return token

View File

@@ -1,10 +1,36 @@
"""URL configuration for the core app."""
from django.urls import path
from django.conf import settings
from django.urls import include, path, re_path
from rest_framework.routers import DefaultRouter
from core.api import viewsets
# - Main endpoints
router = DefaultRouter()
router.register("templates", viewsets.TemplateViewSet, basename="templates")
router.register("users", viewsets.UserViewSet, basename="users")
# - Routes nested under a template
template_related_router = DefaultRouter()
template_related_router.register(
"accesses",
viewsets.TemplateAccessViewSet,
basename="template_accesses",
)
from core.views import generate_document, TemplatesApiView, GenerateDocumentAPIView
urlpatterns = [
path('generate-document/', generate_document, name='generate_document'),
path('api/generate-document/', GenerateDocumentAPIView.as_view(), name='generate-document'),
path('api/templates', TemplatesApiView.as_view()),
path(
f"api/{settings.API_VERSION}/",
include(
[
*router.urls,
re_path(
r"^templates/(?P<template_id>[0-9a-z-]*)/",
include(template_related_router.urls),
),
]
),
),
]

View File

@@ -1,79 +0,0 @@
from django.shortcuts import render, HttpResponse
from .forms import DocumentGenerationForm
from .models import Template
from rest_framework import status
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import serializers
from django.http import FileResponse
from io import BytesIO
def generate_document(request):
if request.method == 'POST':
form = DocumentGenerationForm(request.POST)
if form.is_valid():
# Get the selected template from the form
template = form.cleaned_data['template']
# Get the body content from the form
body = form.cleaned_data['body']
# Call the generate_document method
pdf_content = template.generate_document(body)
# Return the generated PDF as a response for download
response = HttpResponse(pdf_content, content_type='application/pdf')
response['Content-Disposition'] = f'attachment; filename={template.title}.pdf'
return response
else:
form = DocumentGenerationForm()
return render(request, 'core/generate_document.html', {'form': form})
class DocumentGenerationSerializer(serializers.Serializer):
body = serializers.CharField(label="Markdown Body")
template_id = serializers.UUIDField(format='hex_verbose')
class GenerateDocumentAPIView(APIView):
def post(self, request):
serializer = DocumentGenerationSerializer(data=request.data)
if not serializer.is_valid():
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
template_id = serializer.validated_data['template_id']
body = serializer.validated_data['body']
try:
template = Template.objects.get(pk=template_id)
except Template.DoesNotExist:
return Response("Template not found", status=status.HTTP_404_NOT_FOUND)
pdf_content = template.generate_document(body)
response = FileResponse(BytesIO(pdf_content), content_type='application/pdf')
response['Content-Disposition'] = f'attachment; filename={template.title}.pdf'
return response
class TemplateSerializer(serializers.ModelSerializer):
class Meta:
model = Template
fields = ['id', 'title']
class TemplatesApiView(APIView):
"""Wip."""
def get(self, request, *args, **kwargs):
"""Wip."""
templates = Template.objects.all()
serializer = TemplateSerializer(templates, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)

View File

@@ -1,22 +1,24 @@
"""Management user to create a superuser."""
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand
from django.core.exceptions import ValidationError
UserModel = get_user_model()
class Command(BaseCommand):
help = 'Create a superuser with an email and a password'
"""Management command to create a superuser from and email and password."""
help = "Create a superuser with an email and a password"
def add_arguments(self, parser):
"""Define required arguments "email" and "password"."""
parser.add_argument(
"--email",
help=(
"Email for the user."
),
help=("Email for the user."),
)
parser.add_argument(
"--password",
help='Password for the user.',
help="Password for the user.",
)
def handle(self, *args, **options):
@@ -24,13 +26,12 @@ class Command(BaseCommand):
Given an email and a password, create a superuser or upgrade the existing
user to superuser status.
"""
UserModel = get_user_model()
email = options.get('email')
email = options.get("email")
try:
user = UserModel.objects.get(email=email)
user = UserModel.objects.get(admin_email=email)
except UserModel.DoesNotExist:
user = UserModel(email=email)
message = 'Superuser created successfully.'
user = UserModel(admin_email=email)
message = "Superuser created successfully."
else:
if user.is_superuser and user.is_staff:
message = "Superuser already exists."
@@ -39,7 +40,7 @@ class Command(BaseCommand):
user.is_superuser = True
user.is_staff = True
user.set_password(options['password'])
user.set_password(options["password"])
user.save()
self.stdout.write(self.style.SUCCESS(message))

View File

@@ -1,23 +0,0 @@
from django.contrib.auth.management.commands.createsuperuser import Command as BaseCommand
from django.core.exceptions import ValidationError
class Command(BaseCommand):
help = 'Create a superuser without a username field'
def handle(self, *args, **options):
# Check if a superuser already exists
try:
self.UserModel._default_manager.db_manager(options['database']).get(
is_superuser=True,
)
except self.UserModel.DoesNotExist:
# If not, create a superuser without a username
email = options.get('email')
password = options.get('password')
self.UserModel._default_manager.db_manager(options['database']).create_superuser(
email=email,
password=password,
)
self.stdout.write(self.style.SUCCESS('Superuser created successfully.'))
except ValidationError as e:
self.stderr.write(self.style.ERROR(f'Error creating superuser: {", ".join(e.messages)}'))

View File

@@ -1,36 +0,0 @@
"""API URL Configuration"""
from django.conf import settings
from django.urls import include, path, re_path
from rest_framework.routers import DefaultRouter
from core.api import viewsets
# - Main endpoints
router = DefaultRouter()
router.register("contacts", viewsets.ContactViewSet, basename="contacts")
router.register("teams", viewsets.TeamViewSet, basename="teams")
router.register("users", viewsets.UserViewSet, basename="users")
# - Routes nested under a team
team_related_router = DefaultRouter()
team_related_router.register(
"accesses",
viewsets.TeamAccessViewSet,
basename="team_accesses",
)
urlpatterns = [
path(
f"api/{settings.API_VERSION}/",
include(
[
*router.urls,
re_path(
r"^teams/(?P<team_id>[0-9a-z-]*)/",
include(team_related_router.urls),
),
]
),
)
]

View File

@@ -83,7 +83,9 @@ class Base(Configuration):
environ_name="DB_ENGINE",
environ_prefix=None,
),
"NAME": values.Value("publish", environ_name="DB_NAME", environ_prefix=None),
"NAME": values.Value(
"publish", environ_name="DB_NAME", environ_prefix=None
),
"USER": values.Value("dinum", environ_name="DB_USER", environ_prefix=None),
"PASSWORD": values.Value(
"pass", environ_name="DB_PASSWORD", environ_prefix=None

View File

@@ -6,10 +6,15 @@ from django.contrib import admin
from django.contrib.staticfiles.urls import staticfiles_urlpatterns
from django.urls import include, path, re_path
from drf_spectacular.views import (
SpectacularJSONAPIView,
SpectacularRedocView,
SpectacularSwaggerView,
)
urlpatterns = [
path("admin/", admin.site.urls),
path("", include('core.urls')),
path("", include("core.urls")),
]
if settings.DEBUG:
@@ -18,3 +23,26 @@ if settings.DEBUG:
+ staticfiles_urlpatterns()
+ static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
)
if settings.USE_SWAGGER or settings.DEBUG:
urlpatterns += [
path(
f"{settings.API_VERSION}/swagger.json",
SpectacularJSONAPIView.as_view(
api_version=settings.API_VERSION,
urlconf="core.urls",
),
name="client-api-schema",
),
path(
f"{settings.API_VERSION}//swagger/",
SpectacularSwaggerView.as_view(url_name="client-api-schema"),
name="swagger-ui-schema",
),
re_path(
f"{settings.API_VERSION}//redoc/",
SpectacularRedocView.as_view(url_name="client-api-schema"),
name="redoc-schema",
),
]

View File

@@ -20,7 +20,7 @@ classifiers = [
"Programming Language :: Python :: 3.10",
]
description = "An application to print markdown to pdf from a set of managed templates."
keywords = ["Django", "Contacts", "Teams", "RBAC"]
keywords = ["Django", "Contacts", "Templates", "RBAC"]
license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.10"