Skip to content
Merged
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
8 changes: 5 additions & 3 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,29 +6,31 @@ on:
- main
pull_request:
branches:
- '*'
- "*"

env:
COMPOSE_BAKE: true

jobs:

lint-back:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Run network setup
run: make create-docker-network
- name: Create env files
run: make create-env-files
- name: Run linting checks
run: make back-lint


test-back:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Run network setup
run: make create-docker-network
- name: Create env files
run: make create-env-files
- name: Setup data directories
Expand Down
4 changes: 3 additions & 1 deletion src/backend/core/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -265,8 +265,9 @@ class OperatorAdmin(admin.ModelAdmin):
readonly_fields = ("id", "computed_contribution", "created_at", "updated_at")

fieldsets = (
(None, {"fields": ("name", "url", "is_active", "config")}),
(None, {"fields": ("name", "url", "is_active")}),
(_("Financial Information"), {"fields": ("computed_contribution",)}),
(_("Configuration"), {"fields": ("config",)}),
(_("Metadata"), {"fields": ("created_at", "updated_at")}),
)
autocomplete_fields = ["users"]
Expand Down Expand Up @@ -396,6 +397,7 @@ class ServiceAdmin(admin.ModelAdmin):
"maturity",
"launch_date",
"is_active",
"required_services",
)
},
),
Expand Down
82 changes: 79 additions & 3 deletions src/backend/core/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -283,10 +283,11 @@ class OperatorSerializer(serializers.ModelSerializer):
"""Serialize operators."""

user_role = serializers.SerializerMethodField(read_only=True)
config = serializers.SerializerMethodField(read_only=True)

class Meta:
model = models.Operator
fields = ["id", "name", "url", "scope", "is_active", "user_role"]
fields = ["id", "name", "url", "scope", "is_active", "user_role", "config"]
read_only_fields = fields

def get_user_role(self, obj):
Expand All @@ -296,11 +297,21 @@ def get_user_role(self, obj):
return roles[0].role
return None

def get_config(self, obj):
"""
Get the configuration for the operator.
We don't expose all the configuration, because it may contain sensitive data.
"""
config = obj.config or {}
whitelist_keys = ["idps"]
return {key: config[key] for key in whitelist_keys if key in config}


class ServiceSerializer(serializers.ModelSerializer):
"""Serialize services."""

logo = serializers.CharField(source="get_logo_url", read_only=True)
config = serializers.SerializerMethodField(read_only=True)

class Meta:
model = models.Service
Expand All @@ -315,17 +326,66 @@ class Meta:
"is_active",
"created_at",
"logo",
"config",
]
read_only_fields = fields

def get_config(self, obj):
"""Get the configuration for the service."""
config = obj.config or {}
whitelist_keys = ["help_center_url"]
return {key: config[key] for key in whitelist_keys if key in config}


class ServiceSubscriptionSerializer(serializers.ModelSerializer):
"""Serialize service subscriptions."""

class Meta:
model = models.ServiceSubscription
fields = ["metadata", "created_at", "updated_at", "is_active"]
read_only_fields = ["metadata", "created_at", "updated_at"]
read_only_fields = ["created_at", "updated_at"]

def _validate_proconnect_subscription(self, instance, attrs):
"""
Validate ProConnect subscription data.
It is not possible to update the IDP for an active ProConnect subscription.
Deactivate the subscription first to change the IDP.
"""
if instance.service.type != "proconnect":
return

# Check if subscription is active (either already active or being set to active)
is_active = attrs.get("is_active", instance.is_active)
if not is_active:
return

if "metadata" not in attrs:
return

new_metadata = attrs["metadata"]
if not isinstance(new_metadata, dict) or not "idp_id" in new_metadata:
return

current_idp_id = instance.metadata.get("idp_id")
new_idp_id = new_metadata.get("idp_id")
if current_idp_id == new_idp_id:
return
raise serializers.ValidationError(
{
"metadata": (
"Cannot update idp_id for an active ProConnect subscription. "
"Deactivate the subscription first to change the IDP."
)
}
)

def validate(self, attrs):
"""Validate subscription data."""
instance = self.instance
if instance:
self._validate_proconnect_subscription(instance, attrs)

return attrs


class ServiceSubscriptionWithServiceSerializer(ServiceSubscriptionSerializer):
Expand All @@ -338,7 +398,7 @@ class ServiceSubscriptionWithServiceSerializer(ServiceSubscriptionSerializer):
class Meta:
model = models.ServiceSubscription
fields = ServiceSubscriptionSerializer.Meta.fields + ["service", "operator"]
read_only_fields = fields
read_only_fields = [field for field in fields if field != "metadata"]


class OrganizationSerializer(serializers.ModelSerializer):
Expand Down Expand Up @@ -373,6 +433,14 @@ class Meta:
]
read_only_fields = fields

def to_representation(self, instance):
"""Convert the representation to the desired format."""
data = super().to_representation(instance)
mail_domain, mail_domain_status = instance.get_mail_domain_status()
data["mail_domain"] = mail_domain
data["mail_domain_status"] = mail_domain_status
return data


class OrganizationServiceSerializer(ServiceSerializer):
"""Serialize services for an organization. It contains the subscription for the given organization."""
Expand Down Expand Up @@ -404,3 +472,11 @@ def get_operator_config(self, obj):
"externally_managed": configs[0].externally_managed,
}
return None

def to_representation(self, instance):
"""Convert the representation to the desired format."""
data = super().to_representation(instance)
if "organization" not in self.context:
raise ValueError("OrganizationServiceSerializer requires 'organization' in context")
data["can_activate"] = instance.can_activate(self.context["organization"])
return data
13 changes: 12 additions & 1 deletion src/backend/core/api/viewsets/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,9 +95,18 @@ def get_queryset(self):
)

def get_serializer_context(self):
"""Add operator_id to serializer context."""
"""Add operator_id and organization to serializer context."""
context = super().get_serializer_context()
context["operator_id"] = self.kwargs["operator_id"]
try:
organization = models.Organization.objects.get(
id=self.kwargs["organization_id"]
)
except models.Organization.DoesNotExist as err:
raise NotFound(
"Organization not found."
) from err
context["organization"] = organization
return context


Expand Down Expand Up @@ -191,11 +200,13 @@ def partial_update(self, request, *args, **kwargs):

# Create new subscription with provided data, using defaults for missing fields
is_active = serializer.validated_data.get("is_active", True)
metadata = serializer.validated_data.get("metadata", {})
subscription = models.ServiceSubscription.objects.create(
organization=organization,
service=service,
operator=operator,
is_active=is_active,
metadata=metadata,
)
return Response(
self.get_serializer(subscription).data, status=status.HTTP_201_CREATED
Expand Down
18 changes: 18 additions & 0 deletions src/backend/core/migrations/0009_service_required_services.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 5.2.6 on 2025-12-02 15:02

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('core', '0008_operator_config_and_more'),
]

operations = [
migrations.AddField(
model_name='service',
name='required_services',
field=models.ManyToManyField(help_text='Services that are required for this service to be activated', related_name='required_by', to='core.service', verbose_name='required services'),
),
]
Loading