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
44 changes: 12 additions & 32 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,38 +52,7 @@ jobs:
run: |
celery -A main worker -B -l INFO &
sleep 10
env:
CELERY_TASK_ALWAYS_EAGER: 'True'
CELERY_BROKER_URL: redis://localhost:6379/4
CELERY_RESULT_BACKEND: redis://localhost:6379/4
SECRET_KEY: local_unsafe_key # pragma: allowlist secret
MITX_ONLINE_BASE_URL: http://localhost:8013
MAILGUN_SENDER_DOMAIN: other.fake.site
MAILGUN_KEY: fake_mailgun_key
MITX_ONLINE_ADMIN_EMAIL: example@localhost
OPENEDX_API_CLIENT_ID: fake_client_id
OPENEDX_API_CLIENT_SECRET: fake_client_secret # pragma: allowlist secret

- name: Django system checks
run: uv run ./manage.py check --fail-level WARNING
env:
CELERY_TASK_ALWAYS_EAGER: 'True'
CELERY_BROKER_URL: redis://localhost:6379/4
CELERY_RESULT_BACKEND: redis://localhost:6379/4
SECRET_KEY: local_unsafe_key # pragma: allowlist secret
MITX_ONLINE_BASE_URL: http://localhost:8013
MAILGUN_SENDER_DOMAIN: other.fake.site
MAILGUN_KEY: fake_mailgun_key
MITX_ONLINE_ADMIN_EMAIL: example@localhost
OPENEDX_API_CLIENT_ID: fake_client_id
OPENEDX_API_CLIENT_SECRET: fake_client_secret # pragma: allowlist secret

- name: Tests
run: |
export MEDIA_ROOT="$(mktemp -d)"
cp scripts/test/data/webpack-stats/* webpack-stats/
./scripts/test/python_tests.sh
env:
env: &django-env-vars
DEBUG: False
NODE_ENV: 'production'
CELERY_TASK_ALWAYS_EAGER: 'True'
Expand All @@ -104,6 +73,17 @@ jobs:
OPENEDX_API_CLIENT_SECRET: fake_client_secret # pragma: allowlist secret
SECRET_KEY: local_unsafe_key # pragma: allowlist secret

- name: Django system checks
run: uv run ./manage.py check --fail-level WARNING
env: *django-env-vars

- name: Tests
run: |
export MEDIA_ROOT="$(mktemp -d)"
cp scripts/test/data/webpack-stats/* webpack-stats/
./scripts/test/python_tests.sh
env: *django-env-vars

javascript-tests:
runs-on: ubuntu-24.04
steps:
Expand Down
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"
11 changes: 11 additions & 0 deletions RELEASE.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
Release Notes
=============

Version 1.145.1
---------------

- Fix github actions django env vars (#3463)
- Move call for a content type back into the viewset; fix some n+1 errors (#3465)
- 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.",
),
),
]
46 changes: 46 additions & 0 deletions b2b/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.http import Http404
from django.utils.functional import cached_property
from django.utils.text import slugify
from mitol.common.models import TimestampedModel
from mitol.common.utils import now_in_utc
Expand Down Expand Up @@ -195,6 +196,16 @@ def __str__(self):

return f"{self.name} <{self.org_key}>"

@cached_property
def active_contracts(self):
"""Returns the active contracts for the organization."""

return (
self._active_contracts
if hasattr(self, "_active_contracts")
else self.contracts.filter(active=True).all()
)

class Meta:
"""Meta options for the OrganizationPage."""

Expand Down Expand Up @@ -341,6 +352,16 @@ def programs(self):
"contract_memberships__sort_order"
)

@cached_property
def contract_program_ids(self):
"""Return the contract's programs in the proper order."""

return (
self._contract_program_ids
if hasattr(self, "_contract_program_ids")
else self.contract_programs.order_by("sort_order").all()
)

content_panels = [
FieldPanel("name"),
MultiFieldPanel(
Expand Down Expand Up @@ -609,6 +630,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 +644,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
Loading
Loading