diff --git a/src/core/admin.py b/src/core/admin.py index ca17a5ae5ded567cd7ddc7515dd428db6915ee88..729f8bbaecd9b6301f3d29ecd1f40b7acd73728d 100644 --- a/src/core/admin.py +++ b/src/core/admin.py @@ -4,7 +4,7 @@ from typing import Any from django.conf import settings from django.contrib import admin from django.contrib.admin import FieldListFilter -from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.admin import GroupAdmin, UserAdmin from django.contrib.contenttypes.admin import GenericTabularInline from django.contrib.gis.admin import GISModelAdmin from django.db.models import F, QuerySet @@ -50,6 +50,8 @@ from .models import ( StaticPageNamespace, StaticPageRevision, TagItem, + Team, + TeamMember, UserBadge, UserCommunicationChannel, UserContact, @@ -312,6 +314,24 @@ class ConferenceTagAdmin(admin.ModelAdmin): search_fields = ['slug', 'description'] +class TeamMemberInline(admin.TabularInline): + model = TeamMember + extra = 1 + + +class TeamAdmin(GroupAdmin): + model = Team + inlines = [TeamMemberInline] + list_display = ['name', 'description', 'require_staff', 'member_count'] + list_display_links = ['name'] + list_filter = ['require_staff'] + readonly_fields = ['description_html'] + search_fields = ['name', 'description'] + + def member_count(self, obj): + return obj.members.count() + + class TagsInline(GenericTabularInline): model = TagItem ct_field = 'target_type' @@ -1175,6 +1195,7 @@ admin.site.register(DereferrerStats, DereferrerStatsAdmin) admin.site.register(ConferenceMember, ConferenceMemberAdmin) admin.site.register(ConferenceTag, ConferenceTagAdmin) admin.site.register(ConferenceTrack, ConferenceTrackAdmin) +admin.site.register(Team, TeamAdmin) admin.site.register(Assembly, AssemblyAdmin) admin.site.register(ActivityLogEntry, ActivityLogEntryAdmin) admin.site.register(BadgeCategory, BadgeCategoryAdmin) diff --git a/src/core/locale/de/LC_MESSAGES/django.po b/src/core/locale/de/LC_MESSAGES/django.po index 511972e98c88d8be5bc60d296080f0b4878a5418..870d86a831ee091e6698274138985c13bf04b322 100644 --- a/src/core/locale/de/LC_MESSAGES/django.po +++ b/src/core/locale/de/LC_MESSAGES/django.po @@ -1978,6 +1978,54 @@ msgstr "erklärender Begleittext des Tags" msgid "ConferenceTag__description" msgstr "Beschreibung" +msgid "TeamMember" +msgstr "Teammitglied" + +msgid "TeamMembers" +msgstr "Teammitglieder" + +msgid "TeamMember__can_manage" +msgstr "Verwalter*in" + +msgid "TeamMember__can_manage__help" +msgstr "Diese Person kann das Team verwalten, d.h. Beschreibung und Mitglieder anlegen, bearbeiten oder löschen." + +msgid "TeamMember__created" +msgstr "Beigetreten" + +msgid "TeamMember__updated" +msgstr "Aktualisiert" + +msgid "TeamMember__clean__cannot_remove_last_manager" +msgstr "Dieses Mitglied ist das letzte mit Verwaltungsrechten, sie können nicht entfernt werden." + +msgid "TeamMember__delete__cannot_delete_last_manager" +msgstr "Du kannst dieses Mitglied nicht entfernen, da es das letzte Mitglied mit Verwaltungsrechten ist." + +msgid "Team" +msgstr "" + +msgid "Teams" +msgstr "" + +msgid "Team__conference" +msgstr "Konferenz" + +msgid "Team__description" +msgstr "Beschreibung" + +msgid "Team__description__help" +msgstr "öffentliche Information (Markdown unterstützt)." + +msgid "Team__description_html" +msgstr "Das gerenderte HTML der Beschreibung" + +msgid "Team__require_staff" +msgstr "Nur für Konferenz-Team Mitglieder" + +msgid "Team__require_staff__help" +msgstr "Dieses Team ist nur für Konferenz-Team Mitglieder zugreifbar." + msgid "ConferenceMemberTicket__token_wrong_conference" msgstr "Das Ticket ist nicht für diese Konferenz." diff --git a/src/core/locale/en/LC_MESSAGES/django.po b/src/core/locale/en/LC_MESSAGES/django.po index 5029d87d03aa6c56f0ae627a436d83cec6ea297b..e720fec65ffe5f9657ee463fecb45684488fcee2 100644 --- a/src/core/locale/en/LC_MESSAGES/django.po +++ b/src/core/locale/en/LC_MESSAGES/django.po @@ -1976,6 +1976,54 @@ msgstr "additional explanation of this tag" msgid "ConferenceTag__description" msgstr "description" +msgid "TeamMember" +msgstr "Team member" + +msgid "TeamMembers" +msgstr "Team members" + +msgid "TeamMember__can_manage" +msgstr "Manager" + +msgid "TeamMember__can_manage__help" +msgstr "This person may manage the team, i.e. create/edit/delete description or members." + +msgid "TeamMember__created" +msgstr "joined" + +msgid "TeamMember__updated" +msgstr "updated" + +msgid "TeamMember__clean__cannot_remove_last_manager" +msgstr "You cannot remove this members rights, as they are the last manager of the team" + +msgid "TeamMember__delete__cannot_delete_last_manager" +msgstr "You cannot remove this member, as it is the last manager of the team" + +msgid "Team" +msgstr "" + +msgid "Teams" +msgstr "" + +msgid "Team__conference" +msgstr "Conference" + +msgid "Team__description" +msgstr "description" + +msgid "Team__description__help" +msgstr "public information (markdown supported)." + +msgid "Team__description_html" +msgstr "the html rendered from the description" + +msgid "Team__require_staff" +msgstr "require staff status" + +msgid "Team__require_staff__help" +msgstr "This team is only accessible to persons with the staff status." + msgid "ConferenceMemberTicket__token_wrong_conference" msgstr "This ticket is for another conference." diff --git a/src/core/migrations/0169_team_teammember.py b/src/core/migrations/0169_team_teammember.py new file mode 100644 index 0000000000000000000000000000000000000000..e0dc86a3bb537d9b0805c9249501ca7383a16fcf --- /dev/null +++ b/src/core/migrations/0169_team_teammember.py @@ -0,0 +1,173 @@ +# Generated by Django 5.1.3 on 2024-12-09 01:56 + +import core.fields +import django.contrib.auth.models +import django.db.models.deletion +import rules.contrib.models +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("auth", "0012_alter_user_first_name_max_length"), + ("core", "0168_alter_invitation_type"), + ] + + operations = [ + migrations.CreateModel( + name="Team", + fields=[ + ( + "group_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="auth.group", + ), + ), + ( + "uuid", + models.UUIDField(default=uuid.uuid4, editable=False, unique=True), + ), + ( + "description", + models.TextField( + blank=True, + help_text="Team__description__help", + verbose_name="Team__description", + ), + ), + ( + "description_de", + models.TextField( + blank=True, + help_text="Team__description__help", + null=True, + verbose_name="Team__description", + ), + ), + ( + "description_en", + models.TextField( + blank=True, + help_text="Team__description__help", + null=True, + verbose_name="Team__description", + ), + ), + ( + "description_html", + models.TextField( + blank=True, null=True, verbose_name="Team__description_html" + ), + ), + ( + "description_html_de", + models.TextField( + blank=True, null=True, verbose_name="Team__description_html" + ), + ), + ( + "description_html_en", + models.TextField( + blank=True, null=True, verbose_name="Team__description_html" + ), + ), + ( + "require_staff", + models.BooleanField( + default=False, + help_text="Team__require_staff__help", + verbose_name="Team__require_staff", + ), + ), + ( + "conference", + core.fields.ConferenceReference( + help_text="Conference__reference_help", + on_delete=django.db.models.deletion.CASCADE, + related_name="Teams", + to="core.conference", + verbose_name="Team__conference", + ), + ), + ], + options={ + "verbose_name": "Team", + "verbose_name_plural": "Teams", + }, + bases=(rules.contrib.models.RulesModelMixin, "auth.group"), + managers=[ + ("objects", django.contrib.auth.models.GroupManager()), + ], + ), + migrations.CreateModel( + name="TeamMember", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "can_manage", + models.BooleanField( + default=False, + help_text="TeamMember__can_manage__help", + verbose_name="TeamMember__can_manage", + ), + ), + ( + "created", + models.DateTimeField( + auto_now_add=True, verbose_name="TeamMember__created" + ), + ), + ( + "updated", + models.DateTimeField( + auto_now=True, verbose_name="TeamMember__updated" + ), + ), + ( + "team", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="members", + to="core.team", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="teams", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "TeamMember", + "verbose_name_plural": "TeamMembers", + }, + bases=(rules.contrib.models.RulesModelMixin, models.Model), + ), + migrations.AddIndex( + model_name="team", + index=models.Index(fields=["uuid"], name="core_team_uuid_4e6673_idx"), + ), + migrations.AlterUniqueTogether( + name="teammember", + unique_together={("user", "team")}, + ), + ] diff --git a/src/core/models/__init__.py b/src/core/models/__init__.py index 7a064b14cfccba59e12b85c6b39a9744697f57ea..d51918ddfe17d6872ba448b9996b28cc47088523 100644 --- a/src/core/models/__init__.py +++ b/src/core/models/__init__.py @@ -1,4 +1,5 @@ from core.models.invitation import Invitation +from core.models.teams import Team, TeamMember from .activitylog import ActivityLogChange, ActivityLogEntry from .assemblies import Assembly, AssemblyLink, AssemblyMember @@ -70,6 +71,8 @@ __all__ = [ 'StaticPageNamespace', 'StaticPageRevision', 'TagItem', + 'Team', + 'TeamMember', 'UserBadge', 'UserCommunicationChannel', 'UserContact', diff --git a/src/core/models/conference.py b/src/core/models/conference.py index 02c7c5393dc9d8c22720afd1f4cd164fc07b7675..b84c50cb0048857d7e9ad66f6cc09ba484b5936f 100644 --- a/src/core/models/conference.py +++ b/src/core/models/conference.py @@ -62,6 +62,7 @@ class ConferenceMember(models.Model): is_staff = models.BooleanField(default=False) has_ticket = models.BooleanField(default=False, help_text=_('ConferenceMember--has_ticket--help'), verbose_name=_('ConferenceMember--has_ticket')) + # TODO: Remove this after 38c3, it will be replaced with teams permission_groups = models.ManyToManyField(Group, blank=True, related_name='+') static_page_groups = pg_fields.ArrayField(models.CharField(max_length=50), blank=True, null=True) diff --git a/src/core/models/teams/__init__.py b/src/core/models/teams/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..4a4293f441aee1aac989bfd37b5cf93387d5dd26 --- /dev/null +++ b/src/core/models/teams/__init__.py @@ -0,0 +1,7 @@ +from core.models.teams.team_member import TeamMember +from core.models.teams.teams import Team + +__all__ = [ + 'Team', + 'TeamMember', +] diff --git a/src/core/models/teams/team_member.py b/src/core/models/teams/team_member.py new file mode 100644 index 0000000000000000000000000000000000000000..5523f45c72873bbebfaba0d7436e686875c5ee8c --- /dev/null +++ b/src/core/models/teams/team_member.py @@ -0,0 +1,73 @@ +from typing import TYPE_CHECKING, Any, TypeIs +from uuid import uuid4 + +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.translation import gettext_lazy as _ +from rules.contrib.models import RulesModel +from rules.predicates import is_superuser, predicate + +if TYPE_CHECKING: # pragma: no cover + from core.models import PlatformUser + + +class LastManagerError(ValidationError): + pass + + +@predicate +def has_invitation(user: 'PlatformUser', team_member: 'TeamMember | None' = None) -> bool: + if team_member is None: # pragma: no cover + return False + return user.is_authenticated and team_member.team.sent_invitations.filter(requested_id=user.id).exists() + + +@predicate +def is_associated_user(user: 'PlatformUser', team_member: 'TeamMember | None' = None) -> bool: + if team_member is None: # pragma: no cover + return False + return user.is_authenticated and team_member.user == user + + +@predicate +def is_team_manager(user: 'PlatformUser', team_member: 'TeamMember | None' = None) -> bool: + if team_member is None: # pragma: no cover + return False + return user.is_authenticated and team_member.team.members.filter(user=user, can_manage=True).exists() + + +class TeamMember(RulesModel): + class Meta: + rules_permissions = { + 'add': is_team_manager | is_superuser | has_invitation, + 'change': is_team_manager | is_superuser, + 'delete': is_team_manager | is_superuser | is_associated_user, + } + unique_together = (('user', 'team'),) + verbose_name = _('TeamMember') + verbose_name_plural = _('TeamMembers') + + id = models.UUIDField(primary_key=True, default=uuid4, editable=False) + user = models.ForeignKey('PlatformUser', related_name='teams', on_delete=models.CASCADE) + team = models.ForeignKey('Team', related_name='members', on_delete=models.CASCADE) + can_manage = models.BooleanField( + default=False, + verbose_name=_('TeamMember__can_manage'), + help_text=_('TeamMember__can_manage__help'), + ) + created = models.DateTimeField(auto_now_add=True, verbose_name=_('TeamMember__created')) + updated = models.DateTimeField(auto_now=True, verbose_name=_('TeamMember__updated')) + + @classmethod + def type_is(cls, obj: object) -> TypeIs['TeamMember']: + return isinstance(obj, cls) + + def clean(self) -> None: + if not self.can_manage and self.team.members.filter(can_manage=True).count() == 1: + raise LastManagerError(_('TeamMember__clean__cannot_remove_last_manager')) + return super().clean() + + def delete(self, using: Any = None, keep_parents: bool = False) -> tuple[int, dict[str, int]]: + if self.can_manage and self.team.members.filter(can_manage=True).count() == 1: + raise LastManagerError(_('TeamMember__delete__cannot_delete_last_manager')) + return super().delete(using, keep_parents) diff --git a/src/core/models/teams/teams.py b/src/core/models/teams/teams.py new file mode 100644 index 0000000000000000000000000000000000000000..09bcf763cf15155f767bf7cd24bd8d3007667ac0 --- /dev/null +++ b/src/core/models/teams/teams.py @@ -0,0 +1,103 @@ +from typing import TYPE_CHECKING, TypeIs +from uuid import uuid4 + +from django.contrib.auth.models import Group +from django.contrib.contenttypes.fields import GenericRelation +from django.db import models +from django.utils.translation import gettext_lazy as _ +from rules.contrib.models import RulesModelBase, RulesModelMixin +from rules.predicates import is_superuser, predicate + +from core.fields import ConferenceReference +from core.markdown import compile_translated_markdown_fields, store_relationships +from core.models.invitation import Invitation +from core.predicates import is_conference_staff + +if TYPE_CHECKING: # pragma: no cover + from core.models import PlatformUser + + +@predicate +def is_team_member(user: 'PlatformUser', team: 'Team | None' = None) -> bool: + if team is None: # pragma: no cover + return False + return user.is_authenticated and team.members.filter(user=user).exists() + + +@predicate +def is_team_manager(user: 'PlatformUser', team: 'Team | None' = None) -> bool: + if team is None: # pragma: no cover + return False + return user.is_authenticated and team.members.filter(user=user, can_manage=True).exists() + + +@predicate +def is_public_team(user: 'PlatformUser', team: 'Team | None' = None) -> bool: + # If team is None, we are checking for the permission to view the list of teams, everybody can see it. + if team is None: + return True + return not team.require_staff + + +class Team(RulesModelMixin, Group, metaclass=RulesModelBase): + class Meta: + rules_permissions = { + 'view': is_conference_staff | is_public_team, + 'view_details': is_team_member | is_superuser, + 'add': is_superuser, + 'change': is_team_manager | is_superuser, + 'change_permissions': is_superuser, + 'delete': is_superuser, + 'can_join': is_conference_staff | is_public_team, + } + indexes = [ + models.Index(fields=['uuid']), + ] + verbose_name = _('Team') + verbose_name_plural = _('Teams') + + uuid = models.UUIDField(unique=True, default=uuid4, editable=False) + conference = ConferenceReference( + related_name='Teams', + verbose_name=_('Team__conference'), + ) + description = models.TextField( + blank=True, + verbose_name=_('Team__description'), + help_text=_('Team__description__help'), + ) + description_html = models.TextField( + blank=True, + null=True, + verbose_name=_('Team__description_html'), + ) + # Is this ream only for staff members (e.g. assembly team)? + require_staff = models.BooleanField( + default=False, + verbose_name=_('Team__require_staff'), + help_text=_('Team__require_staff__help'), + ) + + sent_invitations = GenericRelation( + Invitation, + content_type_field='requester_type', + object_id_field='requester_id', + related_query_name='assemblies_inviters', + ) + received_invitations = GenericRelation( + Invitation, + content_type_field='requested_type', + object_id_field='requested_id', + related_query_name='assemblies_invited', + ) + + @classmethod + def type_is(cls, obj: object) -> TypeIs['Team']: + return isinstance(obj, cls) + + def save(self, *args, update_fields=None, **kwargs): + if update_fields is None or 'description' in update_fields: + render_results = compile_translated_markdown_fields(self, self.conference, 'description') + store_relationships(self.conference, self, render_results) + + return super().save(*args, update_fields=update_fields, **kwargs) diff --git a/src/core/translation.py b/src/core/translation.py index 50d03e2324dae84738131c4f73a41a914c425ed5..fff800551ecfd179321550e6a5234c14f80b33a0 100644 --- a/src/core/translation.py +++ b/src/core/translation.py @@ -2,7 +2,12 @@ from django.contrib import admin from modeltranslation.admin import TranslationAdmin from modeltranslation.translator import TranslationOptions, register -from core.admin import BadgeAdmin, BadgeCategoryAdmin, ProjectAdmin +from core.admin import ( + BadgeAdmin, + BadgeCategoryAdmin, + ProjectAdmin, + TeamAdmin, +) from core.models import ( Assembly, Badge, @@ -16,6 +21,7 @@ from core.models import ( MetaNavItem, Project, Room, + Team, ) @@ -92,6 +98,22 @@ class ConferenceMemberTranslationOptions(TranslationOptions): ) +@register(Team) +class TeamTranslationOptions(TranslationOptions): + fields = ( + 'description', + 'description_html', + ) + + +class TranslatedTeamAdmin(TeamAdmin, TranslationAdmin): + pass + + +admin.site.unregister(Team) +admin.site.register(Team, TranslatedTeamAdmin) + + @register(Room) class RoomTranslationOptions(TranslationOptions): fields = (