diff --git a/loko/common/admin.py b/loko/common/admin.py index 14c4b80..2e1d043 100644 --- a/loko/common/admin.py +++ b/loko/common/admin.py @@ -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" diff --git a/loko/common/management/commands/export_user_configs.py b/loko/common/management/commands/export_user_configs.py index 1519168..6be640f 100644 --- a/loko/common/management/commands/export_user_configs.py +++ b/loko/common/management/commands/export_user_configs.py @@ -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, } diff --git a/loko/common/management/commands/import_user_configs.py b/loko/common/management/commands/import_user_configs.py index 9810d6f..de02f3f 100644 --- a/loko/common/management/commands/import_user_configs.py +++ b/loko/common/management/commands/import_user_configs.py @@ -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)), }, ) diff --git a/loko/common/user_services.py b/loko/common/user_services.py index e472cbd..73b95fd 100644 --- a/loko/common/user_services.py +++ b/loko/common/user_services.py @@ -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 diff --git a/loko/contracts/admin.py b/loko/contracts/admin.py index 9e881d1..144a361 100644 --- a/loko/contracts/admin.py +++ b/loko/contracts/admin.py @@ -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',) diff --git a/loko/contracts/migrations/0006_usercompanyteamaccess_can_tag.py b/loko/contracts/migrations/0006_usercompanyteamaccess_can_tag.py new file mode 100644 index 0000000..760fad9 --- /dev/null +++ b/loko/contracts/migrations/0006_usercompanyteamaccess_can_tag.py @@ -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), + ] + diff --git a/loko/contracts/models.py b/loko/contracts/models.py index 687946b..d007917 100644 --- a/loko/contracts/models.py +++ b/loko/contracts/models.py @@ -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}" diff --git a/loko/contracts/test_planning.py b/loko/contracts/test_planning.py index 9ed0a8d..b2bc96e 100644 --- a/loko/contracts/test_planning.py +++ b/loko/contracts/test_planning.py @@ -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.""" diff --git a/loko/notifications/tests.py b/loko/notifications/tests.py index 02f3def..e138da9 100644 --- a/loko/notifications/tests.py +++ b/loko/notifications/tests.py @@ -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 diff --git a/loko/notifications/views.py b/loko/notifications/views.py index de07ebe..80b983f 100644 --- a/loko/notifications/views.py +++ b/loko/notifications/views.py @@ -706,18 +706,37 @@ def api_mention_search(request): # Teams (CompanyTeam) from contracts.models import CompanyTeam - company_team_qs = CompanyTeam.objects.filter( - Q(name__icontains=q) | Q(company__name__icontains=q) - ).select_related('company').order_by('name')[:15] + + 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) - 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: - if t.pk not in existing_team_ids: - results.append({'type': 'team', 'id': t.pk, 'display': t.name}) + # 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: + if t.pk not in existing_team_ids: + results.append({'type': 'team', 'id': t.pk, 'display': t.name}) return JsonResponse({'results': results})