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
84 changes: 83 additions & 1 deletion apps/common/admin.py
Original file line number Diff line number Diff line change
@@ -1 +1,83 @@
# Register your models here.
import typing
from datetime import datetime

from django.contrib import admin
from django.db import models
from django.http import HttpRequest

from apps.common.models import UserResource

DjangoModel = typing.TypeVar("DjangoModel", bound=models.Model)


class UserResourceAdmin(admin.ModelAdmin):
@typing.override
def get_readonly_fields(self, *args, **kwargs):
readonly_fields = super().get_readonly_fields(*args, **kwargs) # type: ignore[reportAttributeAccessIssue]
return [
# To maintain order
*dict.fromkeys(
[
*readonly_fields,
"created_at",
"created_by",
"modified_at",
"modified_by",
],
),
]

@typing.override
def save_model(self, request, obj, form, change):
if not change:
obj.created_by = request.user
obj.modified_by = request.user
super().save_model(request, obj, form, change) # type: ignore[reportAttributeAccessIssue]

@typing.override
def save_formset(self, request, form, formset, change) -> None:
if not issubclass(formset.model, UserResource):
return super().save_formset(request, form, formset, change)
# https://docs.djangoproject.com/en/4.2/ref/contrib/admin/#django.contrib.admin.ModelAdmin.save_formset
instances = formset.save(commit=False)
for obj in formset.deleted_objects:
obj.delete()
for instance in instances:
# UserResource changes
if instance.pk is None:
instance.created_by = request.user
instance.modified_by = request.user
instance.save()
return None

@typing.override
def get_queryset(self, request: HttpRequest) -> models.QuerySet[DjangoModel]:
return super().get_queryset(request).select_related("created_by", "modified_by")


class ArchivableResourceAdmin(UserResourceAdmin, admin.ModelAdmin):
@typing.override
def get_readonly_fields(self, *args, **kwargs):
readonly_fields = super().get_readonly_fields(*args, **kwargs) # type: ignore[reportAttributeAccessIssue]
return [
*dict.fromkeys(
[
*readonly_fields,
"archived_by",
"archived_at",
],
),
]

@typing.override
def save_model(self, request, obj, form, change):
if not change:
obj.created_by = request.user
obj.modified_by = request.user
if obj.is_archived:
obj.archived_by = request.user
obj.archived_at = datetime.now()
else:
obj.archived_by = None
obj.archived_at = None
super().save_model(request, obj, form, change) # type: ignore[reportAttributeAccessIssue]
4 changes: 3 additions & 1 deletion apps/contributor/admin.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from django.contrib import admin
from djangoql.admin import DjangoQLSearchMixin

from apps.common.admin import ArchivableResourceAdmin

from .models import ContributorTeam, ContributorUser, ContributorUserGroup, ContributorUserGroupMembership


Expand Down Expand Up @@ -28,7 +30,7 @@ class ContributorUserInline(admin.TabularInline):


@admin.register(ContributorTeam)
class ContributorTeamAdmin(DjangoQLSearchMixin, admin.ModelAdmin):
class ContributorTeamAdmin(ArchivableResourceAdmin, DjangoQLSearchMixin, admin.ModelAdmin):
inlines = [ContributorUserInline]
list_display = (
"name",
Expand Down
10 changes: 10 additions & 0 deletions apps/contributor/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from ulid import ULID

from .models import (
ContributorTeam,
ContributorUser,
ContributorUserGroup,
ContributorUserGroupMembership,
Expand Down Expand Up @@ -46,10 +47,19 @@ class Meta:
model = ContributorUserGroupMembershipLog


class ContributorTeamFactory(DjangoModelFactory):
class Meta:
model = ContributorTeam

client_id = factory.LazyFunction(lambda: str(ULID()))
name = factory.Sequence(lambda n: f"Contributor User Team {n}")


# NOTE: Make sure to add type hints for each factory class defined below
# NOTE: This needs to be at the end of this file
if typing.TYPE_CHECKING:
ContributorUserFactory: type[DjangoModelFactory[ContributorUser]]
ContributorTeamFactory: type[DjangoModelFactory[ContributorTeam]]
ContributorUserGroupFactory: type[DjangoModelFactory[ContributorUserGroup]]
ContributorUserGroupMembershipFactory: type[DjangoModelFactory[ContributorUserGroupMembership]]
ContributorUserGroupMembershipLogFactory: type[DjangoModelFactory[ContributorUserGroupMembershipLog]]
10 changes: 10 additions & 0 deletions apps/contributor/models.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
# pyright: reportUninitializedInstanceVariable=false
import typing

from django.core.exceptions import ValidationError
from django.db import models
from django.utils.translation import gettext
from django_choices_field import IntegerChoicesField

from apps.common.models import ArchivableResource, UserResource
Expand Down Expand Up @@ -84,3 +86,11 @@ class ContributorTeam(ArchivableResource, UserResource): # type: ignore[reportI
@typing.override
def __str__(self):
return self.name

@typing.override
def clean(self):
super().clean()
if self.pk and self.is_archived and ContributorUser.objects.filter(team_id=self.pk).exists():
raise ValidationError(
{"is_archived": gettext("Cannot archive a team that still has team members.")},
)
50 changes: 50 additions & 0 deletions apps/contributor/tests/contributor_team_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import typing

import pytest # type: ignore[reportMissingImports]
from django.contrib.admin.sites import AdminSite
from django.core.exceptions import ValidationError

from apps.contributor.admin import ContributorTeamAdmin
from apps.contributor.factories import ContributorTeamFactory, ContributorUserFactory
from apps.contributor.models import ContributorTeam
from apps.user.factories import UserFactory
from apps.user.models import User
from main.tests import TestCase


class MockRequest:
def __init__(self, user: User):
self.user = user


class TestContributorTeam(TestCase):
@typing.override
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.user = UserFactory.create()
cls.user_resource_kwargs = dict(
created_by=cls.user,
modified_by=cls.user,
)
cls.site = AdminSite()
cls.admin = ContributorTeamAdmin(ContributorTeam, cls.site)
cls.contributor_team = ContributorTeamFactory.create(**cls.user_resource_kwargs)
cls.contributor_user = ContributorUserFactory.create(
user_id="test_id",
team=cls.contributor_team,
)

def test_cannot_archive_team_with_members(self):
self.contributor_team.is_archived = True
with pytest.raises(ValidationError):
self.contributor_team.clean()

def test_archive_team(self):
request = MockRequest(user=self.user)
self.force_login(request.user)
self.contributor_user.delete()
self.contributor_team.is_archived = True
self.admin.save_model(request, self.contributor_team, form=None, change=True) # type: ignore[reportArgumentType]
assert self.contributor_team.is_archived is True
assert self.contributor_team.archived_by == self.user
1 change: 1 addition & 0 deletions utils/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ def validate_imagery_url(url: str, *, support_quadkey: bool | None = True):


def validate_ulid(val: str):
# TODO: add suggestion for ULID value for local development (use settings.debug)
if val == "":
raise ValidationError(
gettext("Empty string is not a valid ULID value"),
Expand Down
Loading