Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨(backend) add ServiceProvider #522

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ and this project adheres to
### Added

- ✨(teams) allow team management for team admins/owners #509
- ✨(backend) add ServiceProvider #522

## [1.5.0] - 2024-11-14

Expand Down
22 changes: 11 additions & 11 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Django People

# ---- base image to inherit from ----
FROM python:3.12.6-alpine3.20 as base
FROM python:3.12.6-alpine3.20 AS base

# Upgrade pip to its latest release to speed up dependencies installation
RUN python -m pip install --upgrade pip setuptools
Expand All @@ -11,7 +11,7 @@ RUN apk update && \
apk upgrade

### ---- Front-end dependencies image ----
FROM node:20 as frontend-deps
FROM node:20 AS frontend-deps

WORKDIR /deps

Expand All @@ -24,7 +24,7 @@ COPY ./src/frontend/packages/eslint-config-people/package.json ./packages/eslint
RUN yarn --frozen-lockfile

### ---- Front-end builder dev image ----
FROM node:20 as frontend-builder-dev
FROM node:20 AS frontend-builder-dev

WORKDIR /builder

Expand All @@ -34,12 +34,12 @@ COPY ./src/frontend .
WORKDIR ./apps/desk

### ---- Front-end builder image ----
FROM frontend-builder-dev as frontend-builder
FROM frontend-builder-dev AS frontend-builder

RUN yarn build

# ---- Front-end image ----
FROM nginxinc/nginx-unprivileged:1.26-alpine as frontend-production
FROM nginxinc/nginx-unprivileged:1.26-alpine AS frontend-production

# Un-privileged user running the application
ARG DOCKER_USER
Expand All @@ -60,7 +60,7 @@ CMD ["nginx", "-g", "daemon off;"]


# ---- Back-end builder image ----
FROM base as back-builder
FROM base AS back-builder

WORKDIR /builder

Expand All @@ -72,7 +72,7 @@ RUN mkdir /install && \


# ---- mails ----
FROM node:20 as mail-builder
FROM node:20 AS mail-builder

COPY ./src/mail /mail/app

Expand All @@ -83,7 +83,7 @@ RUN yarn install --frozen-lockfile && \


# ---- static link collector ----
FROM base as link-collector
FROM base AS link-collector
ARG PEOPLE_STATIC_ROOT=/data/static

# Install libpangocairo & rdfind
Expand All @@ -108,7 +108,7 @@ RUN DJANGO_CONFIGURATION=Build DJANGO_JWT_PRIVATE_SIGNING_KEY=Dummy \
RUN rdfind -makesymlinks true -followsymlinks true -makeresultsfile false ${PEOPLE_STATIC_ROOT}

# ---- Core application image ----
FROM base as core
FROM base AS core

ENV PYTHONUNBUFFERED=1

Expand Down Expand Up @@ -143,7 +143,7 @@ WORKDIR /app
ENTRYPOINT [ "/usr/local/bin/entrypoint" ]

# ---- Development image ----
FROM core as backend-development
FROM core AS backend-development

# Switch back to the root user to install development dependencies
USER root:root
Expand All @@ -169,7 +169,7 @@ ENV DB_HOST=postgresql \
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]

# ---- Production image ----
FROM core as backend-production
FROM core AS backend-production

ARG PEOPLE_STATIC_ROOT=/data/static

Expand Down
36 changes: 34 additions & 2 deletions src/backend/core/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,11 +108,20 @@ def get_user(self, obj):
get_user.short_description = _("User")


class TeamServiceProviderInline(admin.TabularInline):
"""Inline admin class for service providers."""

can_delete = False
model = models.Team.service_providers.through
extra = 0


@admin.register(models.Team)
class TeamAdmin(admin.ModelAdmin):
"""Team admin interface declaration."""

inlines = (TeamAccessInline, TeamWebhookInline)
inlines = (TeamAccessInline, TeamWebhookInline, TeamServiceProviderInline)
exclude = ("service_providers",) # Handled by the inline
list_display = (
"name",
"created_at",
Expand Down Expand Up @@ -188,6 +197,14 @@ class ContactAdmin(admin.ModelAdmin):
)


class OrganizationServiceProviderInline(admin.TabularInline):
"""Inline admin class for service providers."""

can_delete = False
model = models.Organization.service_providers.through
extra = 0


@admin.register(models.Organization)
class OrganizationAdmin(admin.ModelAdmin):
"""Admin interface for organizations."""
Expand All @@ -198,7 +215,8 @@ class OrganizationAdmin(admin.ModelAdmin):
"updated_at",
)
search_fields = ("name",)
inlines = (OrganizationAccessInline,)
inlines = (OrganizationAccessInline, OrganizationServiceProviderInline)
exclude = ("service_providers",) # Handled by the inline


@admin.register(models.OrganizationAccess)
Expand All @@ -213,3 +231,17 @@ class OrganizationAccessAdmin(admin.ModelAdmin):
"created_at",
"updated_at",
)


@admin.register(models.ServiceProvider)
class ServiceProviderAdmin(admin.ModelAdmin):
"""Admin interface for service providers."""

list_display = (
"name",
"audience_id",
"created_at",
"updated_at",
)
search_fields = ("name", "audience_id")
readonly_fields = ("created_at", "updated_at")
21 changes: 21 additions & 0 deletions src/backend/core/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from timezone_field.rest_framework import TimeZoneSerializerField

from core import models
from core.models import ServiceProvider


class ContactSerializer(serializers.ModelSerializer):
Expand Down Expand Up @@ -205,6 +206,9 @@ class TeamSerializer(serializers.ModelSerializer):
"""Serialize teams."""

abilities = serializers.SerializerMethodField(read_only=True)
service_providers = serializers.PrimaryKeyRelatedField(
queryset=ServiceProvider.objects.all(), many=True, required=False
)

class Meta:
model = models.Team
Expand All @@ -215,6 +219,7 @@ class Meta:
"abilities",
"created_at",
"updated_at",
"service_providers",
]
read_only_fields = [
"id",
Expand All @@ -226,6 +231,13 @@ class Meta:

def create(self, validated_data):
"""Create a new team with organization enforcement."""
# When called as a resource server, we enforce the team service provider
if sp_audience := self.context.get("from_service_provider_audience", None):
service_providers, _created = models.ServiceProvider.objects.get_or_create(
audience_id=sp_audience
)
validated_data["service_providers"] = [service_providers]

# Note: this is not the purpose of this API to check the user has an organization
return super().create(
validated_data=validated_data
Expand Down Expand Up @@ -273,3 +285,12 @@ def validate(self, attrs):
attrs["team_id"] = team_id
attrs["issuer"] = user
return attrs


class ServiceProviderSerializer(serializers.ModelSerializer):
"""Serialize service providers."""

class Meta:
model = models.ServiceProvider
fields = ["id", "audience_id", "name"]
read_only_fields = ["id", "audience_id"]
83 changes: 80 additions & 3 deletions src/backend/core/api/viewsets.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from django.conf import settings
from django.contrib.postgres.search import TrigramSimilarity
from django.db.models import Func, Max, OuterRef, Q, Subquery, Value
from django.db.models import Func, Max, OuterRef, Prefetch, Q, Subquery, Value
from django.db.models.functions import Coalesce

from rest_framework import (
Expand All @@ -20,6 +20,7 @@

from core import models

from ..resource_server.mixins import ResourceServerMixin
from . import permissions, serializers

SIMILARITY_THRESHOLD = 0.04
Expand Down Expand Up @@ -240,6 +241,7 @@ def get_me(self, request):


class TeamViewSet(
ResourceServerMixin,
mixins.CreateModelMixin,
mixins.DestroyModelMixin,
mixins.ListModelMixin,
Expand All @@ -262,8 +264,36 @@ def get_queryset(self):
user_role_query = models.TeamAccess.objects.filter(
user=self.request.user, team=OuterRef("pk")
).values("role")[:1]
return models.Team.objects.filter(accesses__user=self.request.user).annotate(
user_role=Subquery(user_role_query)

service_provider_audience = self._get_service_provider_audience()
if service_provider_audience:
# Restrict displayed service providers when used as a resource server
service_provider_prefetch = Prefetch(
"service_providers",
queryset=models.ServiceProvider.objects.filter(
audience_id=self._get_service_provider_audience()
),
)

# Restrict results to the Service Provider's teams when used as a resource server
service_provider_filters = {
"service_providers__audience_id": service_provider_audience
}

else:
service_provider_prefetch = Prefetch(
"service_providers",
queryset=models.ServiceProvider.objects.all(),
)
service_provider_filters = {}

return (
models.Team.objects.prefetch_related("accesses", service_provider_prefetch)
.filter(
accesses__user=self.request.user,
**service_provider_filters,
)
.annotate(user_role=Subquery(user_role_query))
)

def perform_create(self, serializer):
Expand Down Expand Up @@ -510,3 +540,50 @@ def get(self, request):
dict_settings[setting] = getattr(settings, setting)

return response.Response(dict_settings)


class ServiceProviderFilter(filters.BaseFilterBackend):
"""
Filter service providers by audience.
"""

def filter_queryset(self, request, queryset, view):
"""
Filter service providers by audience.
"""
if name := request.GET.get("name"):
queryset = queryset.filter(name__icontains=name)
if audience_id := request.GET.get("audience_id"):
queryset = queryset.filter(audience_id=audience_id)
return queryset


class ServiceProviderViewSet(
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
viewsets.GenericViewSet,
):
"""
API ViewSet for all interactions with service providers.

GET /api/v1.0/service-providers/
Return a list of service providers.

GET /api/v1.0/service-providers/<service_provider_id>/
Return a service provider.
"""

permission_classes = [permissions.IsAuthenticated]
queryset = models.ServiceProvider.objects.all()
serializer_class = serializers.ServiceProviderSerializer
throttle_classes = [BurstRateThrottle, SustainedRateThrottle]
pagination_class = Pagination
filter_backends = [filters.OrderingFilter, ServiceProviderFilter]
ordering = ["name"]
ordering_fields = ["name", "created_at"]

def get_queryset(self):
"""Filter the queryset to limit results to user's organization."""
queryset = super().get_queryset()
queryset = queryset.filter(organizations__id=self.request.user.organization_id)
return queryset
31 changes: 31 additions & 0 deletions src/backend/core/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,13 @@ def users(self, create, extracted, **kwargs):
else:
TeamAccessFactory(team=self, user=user_entry[0], role=user_entry[1])

@factory.post_generation
def service_providers(self, create, extracted, **kwargs):
"""Add service providers to team from a given list of service providers."""
if not create or not extracted:
return
self.service_providers.set(extracted)


class TeamAccessFactory(factory.django.DjangoModelFactory):
"""Create fake team user accesses for testing."""
Expand Down Expand Up @@ -212,3 +219,27 @@ class Meta:
email = factory.Faker("email")
role = factory.fuzzy.FuzzyChoice([role[0] for role in models.RoleChoices.choices])
issuer = factory.SubFactory(UserFactory)


class ServiceProviderFactory(factory.django.DjangoModelFactory):
"""A factory to create service providers for testing purposes."""

class Meta:
model = models.ServiceProvider
skip_postgeneration_save = True

audience_id = factory.Faker("uuid4")

@factory.post_generation
def teams(self, create, extracted, **kwargs):
"""Add teams to service provider from a given list."""
if not create or not extracted:
return
self.teams.set(extracted)

@factory.post_generation
def organizations(self, create, extracted, **kwargs):
"""Add organization to service provider from a given list."""
if not create or not extracted:
return
self.organizations.set(extracted)
4 changes: 2 additions & 2 deletions src/backend/core/migrations/0002_add_organization_and_more.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ class Migration(migrations.Migration):
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created at')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated at')),
('name', models.CharField(max_length=100, verbose_name='name')),
('registration_id_list', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=128), blank=True, default=list, size=None, validators=[core.models.validate_unique_registration_id], verbose_name='registration ID list')),
('domain_list', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=256), blank=True, default=list, size=None, validators=[core.models.validate_unique_domain], verbose_name='domain list')),
('registration_id_list', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=128), blank=True, default=list, size=None, verbose_name='registration ID list')),
('domain_list', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=256), blank=True, default=list, size=None, verbose_name='domain list')),
],
options={
'verbose_name': 'organization',
Expand Down
Loading
Loading