Skip to content
Closed
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
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ repos:
entry: drf-lint
args: [--baseline, drf_lint_baseline.json]
language: python
files: 'serializers\.py$'
files: '(serializers\.py$|serializers/.*\.py$)'
additional_dependencies:
- mitol-drf-lint
- "setuptools<82"
9 changes: 9 additions & 0 deletions RELEASE.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
Release Notes
=============

Version 1.145.0
---------------

- Update program requirement box for Public Policy (#3457)
- Update the DRF linter config to look at serialiers in a serializer folder (#3458)
- Add API support for a B2B contract management dashboard (#3424)
- Fix issues with the program certificate audit courses test (#3460)
- feat: list filter b2b programs (#3456)

Version 1.144.5 (Released April 02, 2026)
---------------

Expand Down
32 changes: 32 additions & 0 deletions b2b/admin.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,37 @@
"""B2B model admin. Only for convenience; you should use the Wagtail interface instead."""

from collections.abc import Sequence

from django.contrib import admin

from b2b.models import (
ContractPage,
ContractProgramItem,
DiscountContractAttachmentRedemption,
OrganizationPage,
UserOrganization,
)
from courses.models import CourseRun


class UserOrganizationAdminInline(admin.TabularInline):
"""Inline that filters to just show organization admins"""

model = UserOrganization
extra = 0
verbose_name = "Organization Admin"

def get_queryset(self, request):
"""Filter the queryset to just users with Manager access."""

return super().get_queryset(request).filter(is_manager=True)


class ReadOnlyModelAdmin(admin.ModelAdmin):
"""Read-only admin for models."""

fields: Sequence[str] = []

def __init__(self, *args, **kwargs):
"""Set the readonly_fields to the fields if we can."""

Expand Down Expand Up @@ -171,3 +189,17 @@ class OrganizationPageAdmin(ReadOnlyModelAdmin):
"logo",
"sso_organization_id",
]

inlines = [
UserOrganizationAdminInline,
]


@admin.register(UserOrganization)
class UserOrganizationAdmin(admin.ModelAdmin):
"""Admin for user organization memberships."""

list_display = ["user", "organization", "is_manager", "keep_until_seen"]
list_filter = ["is_manager", "keep_until_seen", "organization"]
search_fields = ["user__email", "user__username", "organization__name"]
fields = ["user", "organization", "is_manager", "keep_until_seen"]
22 changes: 22 additions & 0 deletions b2b/migrations/0021_userorganization_is_manager.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Generated by Django 5.1.15 on 2026-03-20 15:50

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("b2b", "0020_add_google_sheets_fields"),
]

operations = [
migrations.AddField(
model_name="userorganization",
name="is_manager",
field=models.BooleanField(
default=False,
null=True,
blank=True,
help_text="If True, the user is a manager of this organization and can access organization dashboard features.",
),
),
]
25 changes: 25 additions & 0 deletions b2b/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -609,6 +609,12 @@ class UserOrganization(models.Model):
default=False,
help_text="If True, the user will be kept in the organization until the organization is seen in their SSO data.",
)
is_manager = models.BooleanField(
default=False,
null=True,
blank=True,
help_text="If True, the user is a manager of this organization and can access organization dashboard features.",
)

class Meta:
unique_together = ("user", "organization")
Expand All @@ -617,3 +623,22 @@ def __str__(self):
"""Return a reasonable representation of the object as a string."""

return f"UserOrganization: {self.user} in {self.organization}"


def is_organization_manager(user, org_id):
"""
Check if a user is a manager of the specified organization.

Args:
user: The user to check
org_id: The organization ID to check against

Returns:
bool: True if the user is a manager of the organization, False otherwise
"""
if not user or not user.is_authenticated:
return False

return UserOrganization.objects.filter(
user=user, organization_id=org_id, is_manager=True
).exists()
60 changes: 60 additions & 0 deletions b2b/permissions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""B2B permissions for organization manager dashboard."""

from rest_framework import permissions

from b2b.models import is_organization_manager


class IsOrganizationManager(permissions.BasePermission):
"""
Custom permission to only allow organization managers to access
their organization's data.
"""

def has_permission(self, request, view):
"""
Check if the user has permission to access the view.

The user must be authenticated and be a manager of the organization
specified in the URL parameters.
"""
if not request.user or not request.user.is_authenticated:
return False

if request.user and request.user.is_superuser:
return True

# Get org_id from URL kwargs
org_id = view.kwargs.get("parent_lookup_organization")
if not org_id:
return False

return is_organization_manager(request.user, org_id)

def has_object_permission(self, request, view, obj):
"""
Check if the user has permission to access a specific object.

For contract-related objects, ensure the contract belongs to
an organization that the user manages. Superusers are always allowed.
"""
org_id = view.kwargs.get("parent_lookup_organization")

if request.user and request.user.is_superuser:
return True

# Check if user is manager of the organization
if is_organization_manager(request.user, org_id):
# If the object has an organization relation, verify it matches
if hasattr(obj, "organization_id"):
return obj.organization_id == int(org_id)
elif hasattr(obj, "organization"):
return obj.organization.id == int(org_id)
elif hasattr(obj, "b2b_contract"):
# For course runs and enrollments
return obj.b2b_contract.organization_id == int(org_id)
elif hasattr(obj, "run") and hasattr(obj.run, "b2b_contract"):
# For enrollments via course run
return obj.run.b2b_contract.organization_id == int(org_id)

return False
57 changes: 35 additions & 22 deletions b2b/serializers/v0/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,55 +9,68 @@
from main.serializers import RichTextSerializer


class ContractPageSerializer(serializers.ModelSerializer):
"""
Serializer for the ContractPage model.
"""
class BaseContractPageSerializer(serializers.ModelSerializer):
"""Simplified serializer for the ContractPage model."""

membership_type = serializers.CharField()
programs = serializers.SerializerMethodField()
welcome_message_extra = RichTextSerializer(
help_text=ContractPage._meta.get_field("welcome_message_extra").help_text, # noqa: SLF001, not private https://docs.djangoproject.com/en/5.0/ref/models/meta/
read_only=True,
)

@extend_schema_field(serializers.ListField(child=serializers.IntegerField()))
def get_programs(self, instance):
"""Get the ordered list of program IDs for this contract"""
return list(instance.programs.values_list("id", flat=True))

class Meta:
model = ContractPage
fields = [
"id",
"name",
"description",
"welcome_message",
"welcome_message_extra",
"integration_type",
"membership_type",
"organization",
"contract_start",
"contract_end",
"active",
"slug",
"organization",
"programs",
]
read_only_fields = [
"id",
"name",
"description",
"welcome_message",
"welcome_message_extra",
"integration_type",
"membership_type",
"organization",
"contract_start",
"contract_end",
"active",
"slug",
"organization",
]


class ContractPageSerializer(BaseContractPageSerializer):
"""
Serializer for the ContractPage model.
"""

programs = serializers.SerializerMethodField()
welcome_message_extra = RichTextSerializer(
help_text=ContractPage._meta.get_field("welcome_message_extra").help_text, # noqa: SLF001, not private https://docs.djangoproject.com/en/5.0/ref/models/meta/
read_only=True,
)

@extend_schema_field(serializers.ListField(child=serializers.IntegerField()))
def get_programs(self, instance):
"""Get the ordered list of program IDs for this contract"""
return list(instance.programs.values_list("id", flat=True))

class Meta:
model = ContractPage
fields = [
*BaseContractPageSerializer.Meta.fields,
"welcome_message",
"welcome_message_extra",
"integration_type",
"programs",
]
read_only_fields = [
*BaseContractPageSerializer.Meta.read_only_fields,
"welcome_message",
"welcome_message_extra",
"integration_type",
"programs",
]

Expand Down
Loading
Loading