feat: implement can_tag permission for team mentions and restrict search visibility based on access control
This commit is contained in:
parent
19810d101c
commit
89794fa294
10 changed files with 125 additions and 18 deletions
|
|
@ -532,7 +532,7 @@ class UserCompanyTeamAccessInline(admin.TabularInline):
|
||||||
model = UserCompanyTeamAccess
|
model = UserCompanyTeamAccess
|
||||||
extra = 1
|
extra = 1
|
||||||
autocomplete_fields = ['team']
|
autocomplete_fields = ['team']
|
||||||
fields = ['team', 'can_view', 'can_edit']
|
fields = ['team', 'can_view', 'can_edit', 'can_tag']
|
||||||
verbose_name = "Accès équipe"
|
verbose_name = "Accès équipe"
|
||||||
verbose_name_plural = "Accès aux équipes"
|
verbose_name_plural = "Accès aux équipes"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -189,4 +189,5 @@ class Command(BaseCommand):
|
||||||
'company': ta.team.company.name,
|
'company': ta.team.company.name,
|
||||||
'can_view': ta.can_view,
|
'can_view': ta.can_view,
|
||||||
'can_edit': ta.can_edit,
|
'can_edit': ta.can_edit,
|
||||||
|
'can_tag': ta.can_tag,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -398,5 +398,6 @@ class Command(BaseCommand):
|
||||||
defaults={
|
defaults={
|
||||||
'can_view': data.get('can_view', True),
|
'can_view': data.get('can_view', True),
|
||||||
'can_edit': data.get('can_edit', False),
|
'can_edit': data.get('can_edit', False),
|
||||||
|
'can_tag': data.get('can_tag', data.get('can_view', False)),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -284,6 +284,7 @@ def copy_user_config(source_user_or_config: Any, target_user_or_config: Any, cop
|
||||||
team=ta.team,
|
team=ta.team,
|
||||||
can_view=ta.can_view,
|
can_view=ta.can_view,
|
||||||
can_edit=ta.can_edit,
|
can_edit=ta.can_edit,
|
||||||
|
can_tag=ta.can_tag,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 9. Accès spécifiques aux assets
|
# 9. Accès spécifiques aux assets
|
||||||
|
|
|
||||||
|
|
@ -354,8 +354,8 @@ class ClaimDeclarationAttachmentAdmin(admin.ModelAdmin):
|
||||||
|
|
||||||
@admin.register(UserCompanyTeamAccess)
|
@admin.register(UserCompanyTeamAccess)
|
||||||
class UserCompanyTeamAccessAdmin(admin.ModelAdmin):
|
class UserCompanyTeamAccessAdmin(admin.ModelAdmin):
|
||||||
list_display = ('user_config_user', 'team', 'can_view', 'can_edit')
|
list_display = ('user_config_user', 'team', 'can_view', 'can_edit', 'can_tag')
|
||||||
list_filter = ('can_view', 'can_edit', 'team__company')
|
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')
|
search_fields = ('user_config__user__username', 'user_config__user__first_name', 'user_config__user__last_name', 'team__name')
|
||||||
autocomplete_fields = ('team',)
|
autocomplete_fields = ('team',)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
@ -275,6 +275,11 @@ class UserCompanyTeamAccess(models.Model):
|
||||||
verbose_name=_("Peut éditer"),
|
verbose_name=_("Peut éditer"),
|
||||||
help_text=_("L'utilisateur peut créer/supprimer des équipes et des membres, et déplacer des membres entre équipes."),
|
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):
|
def __str__(self):
|
||||||
return f"{self.user_config.user.username} → {self.team.name}"
|
return f"{self.user_config.user.username} → {self.team.name}"
|
||||||
|
|
|
||||||
|
|
@ -176,20 +176,22 @@ class TeamPlanningAjaxViewsTests(TestCase):
|
||||||
self.assertEqual(CompanyMemberAbsence.objects.filter(member=self.member_alice, date=target_date).count(), 1)
|
self.assertEqual(CompanyMemberAbsence.objects.filter(member=self.member_alice, date=target_date).count(), 1)
|
||||||
|
|
||||||
def test_planning_duplicate_ajax(self):
|
def test_planning_duplicate_ajax(self):
|
||||||
# Assigner Alice le 07/09
|
source = timezone.now().date() + timedelta(days=1)
|
||||||
set_member_team_schedule(self.member_alice, self.team_a.id, date(2026, 9, 7))
|
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')
|
url = reverse('contracts:team_planning_duplicate_ajax')
|
||||||
payload = {
|
payload = {
|
||||||
'mode': 'day',
|
'mode': 'day',
|
||||||
'source_date': '2026-09-07',
|
'source_date': source.isoformat(),
|
||||||
'target_date': '2026-09-08',
|
'target_date': target.isoformat(),
|
||||||
'copy_absences': False,
|
'copy_absences': False,
|
||||||
}
|
}
|
||||||
response = self.client.post(url, data=json.dumps(payload), content_type='application/json')
|
response = self.client.post(url, data=json.dumps(payload), content_type='application/json')
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertTrue(response.json()['success'])
|
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):
|
def test_planning_duplicate_friday_to_monday_ajax(self):
|
||||||
"""Vérifie la duplication d'un vendredi vers le lundi suivant via l'endpoint AJAX."""
|
"""Vérifie la duplication d'un vendredi vers le lundi suivant via l'endpoint AJAX."""
|
||||||
|
|
|
||||||
|
|
@ -392,11 +392,14 @@ class WebPushTests(TestCase):
|
||||||
|
|
||||||
class TeamMentionTests(TestCase):
|
class TeamMentionTests(TestCase):
|
||||||
def setUp(self):
|
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.company = Company.objects.create(name="Bruxelles Mobilité")
|
||||||
self.team = CompanyTeam.objects.create(name="BM - Régie - Team 1", company=self.company)
|
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 = 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.team_member_user = User.objects.create_user(username='team_user', password='pass')
|
||||||
self.other_user = User.objects.create_user(username='other_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']
|
team_results = [r for r in results if r['type'] == 'team']
|
||||||
self.assertTrue(any(r['id'] == self.team.pk for r in team_results))
|
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):
|
def test_team_mention_notifies_company_team_members_and_adds_participants(self):
|
||||||
from notifications.models import Discussion, NotificationRecipient
|
from notifications.models import Discussion, NotificationRecipient
|
||||||
from notifications.services import post_message
|
from notifications.services import post_message
|
||||||
|
|
|
||||||
|
|
@ -706,18 +706,37 @@ def api_mention_search(request):
|
||||||
|
|
||||||
# Teams (CompanyTeam)
|
# Teams (CompanyTeam)
|
||||||
from contracts.models import CompanyTeam
|
from contracts.models import CompanyTeam
|
||||||
company_team_qs = CompanyTeam.objects.filter(
|
|
||||||
Q(name__icontains=q) | Q(company__name__icontains=q)
|
user_config = getattr(request.user, 'config', None)
|
||||||
).select_related('company').order_by('name')[:15]
|
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:
|
for t in company_team_qs:
|
||||||
results.append({'type': 'team', 'id': t.pk, 'display': t.name})
|
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)
|
||||||
existing_team_ids = {r['id'] for r in results if r['type'] == 'team'}
|
if is_internal_user:
|
||||||
notif_team_qs = Team.objects.filter(name__icontains=q).order_by('name')[:5]
|
existing_team_ids = {r['id'] for r in results if r['type'] == 'team'}
|
||||||
for t in notif_team_qs:
|
notif_team_qs = Team.objects.filter(name__icontains=q).order_by('name')[:5]
|
||||||
if t.pk not in existing_team_ids:
|
for t in notif_team_qs:
|
||||||
results.append({'type': 'team', 'id': t.pk, 'display': t.name})
|
if t.pk not in existing_team_ids:
|
||||||
|
results.append({'type': 'team', 'id': t.pk, 'display': t.name})
|
||||||
|
|
||||||
return JsonResponse({'results': results})
|
return JsonResponse({'results': results})
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue