feat: implement can_tag permission for team mentions and restrict search visibility based on access control

This commit is contained in:
kdeterme 2026-09-09 13:47:32 +02:00
parent 19810d101c
commit 89794fa294
10 changed files with 125 additions and 18 deletions

View file

@ -532,7 +532,7 @@ class UserCompanyTeamAccessInline(admin.TabularInline):
model = UserCompanyTeamAccess
extra = 1
autocomplete_fields = ['team']
fields = ['team', 'can_view', 'can_edit']
fields = ['team', 'can_view', 'can_edit', 'can_tag']
verbose_name = "Accès équipe"
verbose_name_plural = "Accès aux équipes"

View file

@ -189,4 +189,5 @@ class Command(BaseCommand):
'company': ta.team.company.name,
'can_view': ta.can_view,
'can_edit': ta.can_edit,
'can_tag': ta.can_tag,
}

View file

@ -398,5 +398,6 @@ class Command(BaseCommand):
defaults={
'can_view': data.get('can_view', True),
'can_edit': data.get('can_edit', False),
'can_tag': data.get('can_tag', data.get('can_view', False)),
},
)

View file

@ -284,6 +284,7 @@ def copy_user_config(source_user_or_config: Any, target_user_or_config: Any, cop
team=ta.team,
can_view=ta.can_view,
can_edit=ta.can_edit,
can_tag=ta.can_tag,
)
# 9. Accès spécifiques aux assets

View file

@ -354,8 +354,8 @@ class ClaimDeclarationAttachmentAdmin(admin.ModelAdmin):
@admin.register(UserCompanyTeamAccess)
class UserCompanyTeamAccessAdmin(admin.ModelAdmin):
list_display = ('user_config_user', 'team', 'can_view', 'can_edit')
list_filter = ('can_view', 'can_edit', 'team__company')
list_display = ('user_config_user', 'team', 'can_view', 'can_edit', 'can_tag')
list_filter = ('can_view', 'can_edit', 'can_tag', 'team__company')
search_fields = ('user_config__user__username', 'user_config__user__first_name', 'user_config__user__last_name', 'team__name')
autocomplete_fields = ('team',)

View file

@ -0,0 +1,29 @@
# Generated by Django 6.0.7 on 2026-09-09 11:45
from django.db import migrations, models
def populate_can_tag(apps, schema_editor):
UserCompanyTeamAccess = apps.get_model('contracts', 'UserCompanyTeamAccess')
UserCompanyTeamAccess.objects.filter(can_view=True).update(can_tag=True)
def reverse_populate(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
('contracts', '0005_companymemberabsence'),
]
operations = [
migrations.AddField(
model_name='usercompanyteamaccess',
name='can_tag',
field=models.BooleanField(default=False, help_text="L'utilisateur peut tagger / mentionner cette équipe (@) dans les discussions.", verbose_name='Peut tagger'),
),
migrations.RunPython(populate_can_tag, reverse_populate),
]

View file

@ -275,6 +275,11 @@ class UserCompanyTeamAccess(models.Model):
verbose_name=_("Peut éditer"),
help_text=_("L'utilisateur peut créer/supprimer des équipes et des membres, et déplacer des membres entre équipes."),
)
can_tag = models.BooleanField(
default=False,
verbose_name=_("Peut tagger"),
help_text=_("L'utilisateur peut tagger / mentionner cette équipe (@) dans les discussions."),
)
def __str__(self):
return f"{self.user_config.user.username} → {self.team.name}"

View file

@ -176,20 +176,22 @@ class TeamPlanningAjaxViewsTests(TestCase):
self.assertEqual(CompanyMemberAbsence.objects.filter(member=self.member_alice, date=target_date).count(), 1)
def test_planning_duplicate_ajax(self):
# Assigner Alice le 07/09
set_member_team_schedule(self.member_alice, self.team_a.id, date(2026, 9, 7))
source = timezone.now().date() + timedelta(days=1)
target = timezone.now().date() + timedelta(days=2)
# Assigner Alice à source
set_member_team_schedule(self.member_alice, self.team_a.id, source)
url = reverse('contracts:team_planning_duplicate_ajax')
payload = {
'mode': 'day',
'source_date': '2026-09-07',
'target_date': '2026-09-08',
'source_date': source.isoformat(),
'target_date': target.isoformat(),
'copy_absences': False,
}
response = self.client.post(url, data=json.dumps(payload), content_type='application/json')
self.assertEqual(response.status_code, 200)
self.assertTrue(response.json()['success'])
self.assertEqual(get_member_team_for_date(self.member_alice, date(2026, 9, 8)), self.team_a)
self.assertEqual(get_member_team_for_date(self.member_alice, target), self.team_a)
def test_planning_duplicate_friday_to_monday_ajax(self):
"""Vérifie la duplication d'un vendredi vers le lundi suivant via l'endpoint AJAX."""

View file

@ -392,11 +392,14 @@ class WebPushTests(TestCase):
class TeamMentionTests(TestCase):
def setUp(self):
from contracts.models import Company, CompanyTeam, CompanyMember
from contracts.models import Company, CompanyTeam, CompanyMember, UserCompanyTeamAccess
from common.models import UserConfig
self.company = Company.objects.create(name="Bruxelles Mobilité")
self.team = CompanyTeam.objects.create(name="BM - Régie - Team 1", company=self.company)
self.author = User.objects.create_user(username='author_user', password='pass')
self.author_config = UserConfig.objects.create(user=self.author, is_intern=True)
self.team_member_user = User.objects.create_user(username='team_user', password='pass')
self.other_user = User.objects.create_user(username='other_user', password='pass')
@ -426,6 +429,52 @@ class TeamMentionTests(TestCase):
team_results = [r for r in results if r['type'] == 'team']
self.assertTrue(any(r['id'] == self.team.pk for r in team_results))
def test_external_user_can_only_search_teams_with_can_tag_true(self):
from contracts.models import Company, CompanyTeam, UserCompanyTeamAccess
from common.models import UserConfig
provider_co = Company.objects.create(name="Société Prestataire")
team_own_allowed = CompanyTeam.objects.create(name="Prestataire - Team Alpha", company=provider_co)
team_own_forbidden = CompanyTeam.objects.create(name="Prestataire - Team Beta", company=provider_co)
team_internal = CompanyTeam.objects.create(name="BM - Régie - Team 2", company=self.company)
ext_user = User.objects.create_user(username='ext_user', password='pass')
ext_config = UserConfig.objects.create(user=ext_user, is_intern=False, company=provider_co)
# Autoriser explicitement team_own_allowed et team_internal avec can_tag=True
UserCompanyTeamAccess.objects.create(user_config=ext_config, team=team_own_allowed, can_tag=True, can_view=True)
UserCompanyTeamAccess.objects.create(user_config=ext_config, team=team_internal, can_tag=True, can_view=True)
# team_own_forbidden a can_tag=False même si c'est la même société
UserCompanyTeamAccess.objects.create(user_config=ext_config, team=team_own_forbidden, can_tag=False, can_view=True)
self.client.force_login(ext_user)
# Recherche de toutes les équipes prestataires
url = reverse('notifications:api_mention_search') + '?q=Prestataire'
res = self.client.get(url)
self.assertEqual(res.status_code, 200)
team_ids = [r['id'] for r in res.json().get('results', []) if r['type'] == 'team']
self.assertIn(team_own_allowed.pk, team_ids)
self.assertNotIn(team_own_forbidden.pk, team_ids)
# Recherche de l'équipe interne autorisée
url = reverse('notifications:api_mention_search') + '?q=Régie'
res = self.client.get(url)
self.assertEqual(res.status_code, 200)
team_ids = [r['id'] for r in res.json().get('results', []) if r['type'] == 'team']
self.assertIn(team_internal.pk, team_ids)
# L'équipe self.team (BM - Régie - Team 1) n'a pas d'accès configuré, donc elle ne doit PAS apparaître
self.assertNotIn(self.team.pk, team_ids)
def test_external_user_without_config_sees_no_teams(self):
user_no_cfg = User.objects.create_user(username='user_no_cfg', password='pass')
self.client.force_login(user_no_cfg)
url = reverse('notifications:api_mention_search') + '?q=Régie'
res = self.client.get(url)
self.assertEqual(res.status_code, 200)
team_results = [r for r in res.json().get('results', []) if r['type'] == 'team']
self.assertEqual(team_results, [])
def test_team_mention_notifies_company_team_members_and_adds_participants(self):
from notifications.models import Discussion, NotificationRecipient
from notifications.services import post_message

View file

@ -706,13 +706,32 @@ def api_mention_search(request):
# Teams (CompanyTeam)
from contracts.models import CompanyTeam
user_config = getattr(request.user, 'config', None)
is_internal_user = bool(
request.user.is_superuser or (user_config and getattr(user_config, 'is_intern', False))
)
if is_internal_user:
company_team_qs = CompanyTeam.objects.filter(
Q(name__icontains=q) | Q(company__name__icontains=q)
).select_related('company').order_by('name')[:15]
else:
if user_config:
company_team_qs = CompanyTeam.objects.filter(
user_accesses__user_config=user_config,
user_accesses__can_tag=True,
).filter(
Q(name__icontains=q) | Q(company__name__icontains=q)
).select_related('company').distinct().order_by('name')[:15]
else:
company_team_qs = CompanyTeam.objects.none()
for t in company_team_qs:
results.append({'type': 'team', 'id': t.pk, 'display': t.name})
# Legacy notifications.models.Team (si existant)
# Legacy notifications.models.Team (si existant, réservé aux utilisateurs internes)
if is_internal_user:
existing_team_ids = {r['id'] for r in results if r['type'] == 'team'}
notif_team_qs = Team.objects.filter(name__icontains=q).order_by('name')[:5]
for t in notif_team_qs: