feat: improve contract selection with descriptions, enhanced visibility filtering, and user access controls
This commit is contained in:
parent
8b4c854416
commit
016ce202b4
9 changed files with 170 additions and 21 deletions
|
|
@ -344,6 +344,14 @@ class Contract(models.Model):
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"Contrat {self.contract_number} - {self.company.name}"
|
return f"Contrat {self.contract_number} - {self.company.name}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def display_name(self):
|
||||||
|
desc = (self.description or '').strip()
|
||||||
|
if desc:
|
||||||
|
truncated = desc[:45] + ('…' if len(desc) > 45 else '')
|
||||||
|
return f"{self.contract_number} ({truncated})"
|
||||||
|
return self.contract_number
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['contract_number']
|
ordering = ['contract_number']
|
||||||
|
|
|
||||||
|
|
@ -195,3 +195,40 @@ class TeamViewsAjaxTests(TestCase):
|
||||||
member = CompanyMember.objects.get(user=target_user)
|
member = CompanyMember.objects.get(user=target_user)
|
||||||
self.assertEqual(member.name, 'New Target Member')
|
self.assertEqual(member.name, 'New Target Member')
|
||||||
self.assertIn(team_b, member.teams.all())
|
self.assertIn(team_b, member.teams.all())
|
||||||
|
|
||||||
|
|
||||||
|
class ContractDisplayNameTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
from datetime import date
|
||||||
|
from contracts.models import Contract, Company
|
||||||
|
self.company = Company.objects.create(name="Company Tests")
|
||||||
|
self.contract_no_desc = Contract.objects.create(
|
||||||
|
company=self.company,
|
||||||
|
contract_number="CTR-001",
|
||||||
|
start_date=date(2025, 1, 1),
|
||||||
|
end_date=date(2026, 1, 1)
|
||||||
|
)
|
||||||
|
self.contract_short_desc = Contract.objects.create(
|
||||||
|
company=self.company,
|
||||||
|
contract_number="CTR-002",
|
||||||
|
description="Entretien espaces verts",
|
||||||
|
start_date=date(2025, 1, 1),
|
||||||
|
end_date=date(2026, 1, 1)
|
||||||
|
)
|
||||||
|
self.contract_long_desc = Contract.objects.create(
|
||||||
|
company=self.company,
|
||||||
|
contract_number="CTR-003",
|
||||||
|
description="Travaux et maintenance préventive et corrective de la signalisation lumineuse tricolore",
|
||||||
|
start_date=date(2025, 1, 1),
|
||||||
|
end_date=date(2026, 1, 1)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_display_name_no_description(self):
|
||||||
|
self.assertEqual(self.contract_no_desc.display_name, "CTR-001")
|
||||||
|
|
||||||
|
def test_display_name_short_description(self):
|
||||||
|
self.assertEqual(self.contract_short_desc.display_name, "CTR-002 (Entretien espaces verts)")
|
||||||
|
|
||||||
|
def test_display_name_long_description_truncated(self):
|
||||||
|
self.assertTrue(self.contract_long_desc.display_name.startswith("CTR-003 (Travaux et maintenance préventive et correct"))
|
||||||
|
self.assertTrue(self.contract_long_desc.display_name.endswith("…)"))
|
||||||
|
|
|
||||||
|
|
@ -87,9 +87,13 @@
|
||||||
contracts.forEach(contract => {
|
contracts.forEach(contract => {
|
||||||
const option = document.createElement('option');
|
const option = document.createElement('option');
|
||||||
option.value = contract.id;
|
option.value = contract.id;
|
||||||
option.textContent = contract.contract_number;
|
option.textContent = contract.label || contract.contract_number;
|
||||||
contractSelect.appendChild(option);
|
contractSelect.appendChild(option);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (contracts.length === 1) {
|
||||||
|
contractSelect.value = String(contracts[0].id);
|
||||||
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading contracts:', error);
|
console.error('Error loading contracts:', error);
|
||||||
|
|
|
||||||
|
|
@ -213,9 +213,12 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||||
contracts.forEach(contract => {
|
contracts.forEach(contract => {
|
||||||
const opt = document.createElement("option");
|
const opt = document.createElement("option");
|
||||||
opt.value = contract.id;
|
opt.value = contract.id;
|
||||||
opt.textContent = contract.contract_number;
|
opt.textContent = contract.label || contract.contract_number;
|
||||||
contractSelect.appendChild(opt);
|
contractSelect.appendChild(opt);
|
||||||
});
|
});
|
||||||
|
if (contracts.length === 1) {
|
||||||
|
contractSelect.value = String(contracts[0].id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
|
|
|
||||||
|
|
@ -125,12 +125,14 @@
|
||||||
contracts.forEach((contract) => {
|
contracts.forEach((contract) => {
|
||||||
const option = document.createElement('option');
|
const option = document.createElement('option');
|
||||||
option.value = String(contract.id);
|
option.value = String(contract.id);
|
||||||
option.textContent = contract.contract_number;
|
option.textContent = contract.label || contract.contract_number;
|
||||||
contractSelect.appendChild(option);
|
contractSelect.appendChild(option);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (selectedContractId) {
|
if (selectedContractId) {
|
||||||
contractSelect.value = String(selectedContractId);
|
contractSelect.value = String(selectedContractId);
|
||||||
|
} else if (contracts.length === 1) {
|
||||||
|
contractSelect.value = String(contracts[0].id);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Quick edit contract loading error:', error);
|
console.error('Quick edit contract loading error:', error);
|
||||||
|
|
|
||||||
|
|
@ -330,7 +330,7 @@
|
||||||
<option value="">-</option>
|
<option value="">-</option>
|
||||||
{% for contract in contracts %}
|
{% for contract in contracts %}
|
||||||
<option value="{{ contract.id }}" {% if contract.id == intervention.contract.id %}selected{% endif %}>
|
<option value="{{ contract.id }}" {% if contract.id == intervention.contract.id %}selected{% endif %}>
|
||||||
{{ contract.contract_number }}
|
{{ contract.contract_number }}{% if contract.description %} ({{ contract.description|truncatechars:45 }}){% endif %}
|
||||||
</option>
|
</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
|
|
@ -403,14 +403,14 @@
|
||||||
<button class="btn btn-sm border-0" style="cursor: default;"><i class="bi bi-file-earmark-text"></i></button>
|
<button class="btn btn-sm border-0" style="cursor: default;"><i class="bi bi-file-earmark-text"></i></button>
|
||||||
<span class="label mx-1">{% translate "Contrat" %}:</span>
|
<span class="label mx-1">{% translate "Contrat" %}:</span>
|
||||||
<div class="field-display">
|
<div class="field-display">
|
||||||
<span class="field-value" id="contractDisplay">{{ intervention.contract.contract_number|default:'-' }}</span>
|
<span class="field-value" id="contractDisplay">{% if intervention.contract %}{{ intervention.contract.contract_number }}{% if intervention.contract.description %} ({{ intervention.contract.description|truncatechars:45 }}){% endif %}{% else %}-{% endif %}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="field-edit d-none" style="flex: 1;">
|
<div class="field-edit d-none" style="flex: 1;">
|
||||||
<select class="form-select form-select-sm">
|
<select class="form-select form-select-sm">
|
||||||
<option value="">-</option>
|
<option value="">-</option>
|
||||||
{% for contract in contracts %}
|
{% for contract in contracts %}
|
||||||
<option value="{{ contract.id }}" {% if contract.id == intervention.contract.id %}selected{% endif %}>
|
<option value="{{ contract.id }}" {% if contract.id == intervention.contract.id %}selected{% endif %}>
|
||||||
{{ contract.contract_number }}
|
{{ contract.contract_number }}{% if contract.description %} ({{ contract.description|truncatechars:45 }}){% endif %}
|
||||||
</option>
|
</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
|
|
|
||||||
|
|
@ -530,6 +530,77 @@ class DefaultContractTests(TestCase):
|
||||||
self.assertEqual(itv.assigned_provider, self.company)
|
self.assertEqual(itv.assigned_provider, self.company)
|
||||||
|
|
||||||
|
|
||||||
|
class GetContractsByProviderTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
from datetime import date
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from common.models import UserConfig, UserContractAccess, Role
|
||||||
|
from contracts.models import Contract, Company
|
||||||
|
|
||||||
|
self.User = get_user_model()
|
||||||
|
self.manager_role, _ = Role.objects.get_or_create(name='manager')
|
||||||
|
|
||||||
|
self.company = Company.objects.create(name="Provider A")
|
||||||
|
self.contract1 = Contract.objects.create(
|
||||||
|
contract_number="C-001",
|
||||||
|
description="Short desc",
|
||||||
|
company=self.company,
|
||||||
|
start_date=date(2025, 1, 1),
|
||||||
|
end_date=date(2030, 1, 1)
|
||||||
|
)
|
||||||
|
self.contract2 = Contract.objects.create(
|
||||||
|
contract_number="C-002",
|
||||||
|
description="Another desc",
|
||||||
|
company=self.company,
|
||||||
|
start_date=date(2025, 1, 1),
|
||||||
|
end_date=date(2030, 1, 1)
|
||||||
|
)
|
||||||
|
|
||||||
|
# User limited to contracts
|
||||||
|
self.limited_user = self.User.objects.create_user(username='limited-user', password='pwd')
|
||||||
|
self.limited_config = UserConfig.objects.create(
|
||||||
|
user=self.limited_user,
|
||||||
|
is_intern=True,
|
||||||
|
limit_interventions_to_contracts=True,
|
||||||
|
)
|
||||||
|
self.limited_config.roles.add(self.manager_role)
|
||||||
|
# Give access only to contract1
|
||||||
|
UserContractAccess.objects.create(user_config=self.limited_config, contract=self.contract1, can_view_interventions=True)
|
||||||
|
|
||||||
|
# Unrestricted internal user
|
||||||
|
self.unlimited_user = self.User.objects.create_user(username='unlimited-user', password='pwd')
|
||||||
|
self.unlimited_config = UserConfig.objects.create(
|
||||||
|
user=self.unlimited_user,
|
||||||
|
is_intern=True,
|
||||||
|
limit_interventions_to_contracts=False,
|
||||||
|
)
|
||||||
|
self.unlimited_config.roles.add(self.manager_role)
|
||||||
|
|
||||||
|
def test_limited_user_only_gets_single_accessible_contract(self):
|
||||||
|
self.client.login(username='limited-user', password='pwd')
|
||||||
|
url = reverse('interventions:get_contracts_by_provider')
|
||||||
|
response = self.client.get(f"{url}?provider_id={self.company.id}")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
data = response.json()
|
||||||
|
self.assertEqual(len(data), 1)
|
||||||
|
self.assertEqual(data[0]['id'], self.contract1.id)
|
||||||
|
self.assertEqual(data[0]['contract_number'], "C-001")
|
||||||
|
self.assertEqual(data[0]['description'], "Short desc")
|
||||||
|
self.assertEqual(data[0]['label'], "C-001 (Short desc)")
|
||||||
|
|
||||||
|
def test_unlimited_user_gets_all_contracts(self):
|
||||||
|
self.client.login(username='unlimited-user', password='pwd')
|
||||||
|
url = reverse('interventions:get_contracts_by_provider')
|
||||||
|
response = self.client.get(f"{url}?provider_id={self.company.id}")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
data = response.json()
|
||||||
|
self.assertEqual(len(data), 2)
|
||||||
|
contract_numbers = [c['contract_number'] for c in data]
|
||||||
|
self.assertIn("C-001", contract_numbers)
|
||||||
|
self.assertIn("C-002", contract_numbers)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -230,8 +230,8 @@ def intervention_detail(request, intervention_id):
|
||||||
contract_id = intervention.order.contract.id if intervention.order else None
|
contract_id = intervention.order.contract.id if intervention.order else None
|
||||||
|
|
||||||
# Limiter les providers et contracts selon les permissions de l'utilisateur
|
# Limiter les providers et contracts selon les permissions de l'utilisateur
|
||||||
if user_config.is_intern:
|
if user_config.is_intern and not user_config.limit_interventions_to_contracts:
|
||||||
# Utilisateurs internes : accès à tous les prestataires et contrats
|
# Utilisateurs internes non limités aux contrats : accès à tous les prestataires et contrats
|
||||||
providers = Company.objects.all()
|
providers = Company.objects.all()
|
||||||
if intervention.thematic:
|
if intervention.thematic:
|
||||||
providers = providers.filter(contracts__thematics=intervention.thematic).distinct()
|
providers = providers.filter(contracts__thematics=intervention.thematic).distinct()
|
||||||
|
|
@ -240,9 +240,9 @@ def intervention_detail(request, intervention_id):
|
||||||
# Filtrer par thématique si l'intervention en a une
|
# Filtrer par thématique si l'intervention en a une
|
||||||
if intervention.thematic:
|
if intervention.thematic:
|
||||||
contracts_qs = contracts_qs.filter(thematics=intervention.thematic)
|
contracts_qs = contracts_qs.filter(thematics=intervention.thematic)
|
||||||
contracts = contracts_qs.distinct().values("id", "contract_number")
|
contracts = contracts_qs.distinct()
|
||||||
else:
|
else:
|
||||||
# Utilisateurs externes (external_managers) : limiter aux contrats accessibles
|
# Utilisateurs limités aux contrats (externes ou avec limit_interventions_to_contracts=True)
|
||||||
accessible_contracts_ids = UserContractAccess.objects.filter(
|
accessible_contracts_ids = UserContractAccess.objects.filter(
|
||||||
user_config=user_config,
|
user_config=user_config,
|
||||||
can_view_interventions=True
|
can_view_interventions=True
|
||||||
|
|
@ -264,7 +264,7 @@ def intervention_detail(request, intervention_id):
|
||||||
else:
|
else:
|
||||||
contracts_qs = accessible_contracts_qs
|
contracts_qs = accessible_contracts_qs
|
||||||
|
|
||||||
contracts = contracts_qs.distinct().values("id", "contract_number")
|
contracts = contracts_qs.distinct()
|
||||||
|
|
||||||
projects = Project.objects.filter(thematics=intervention.thematic) if intervention.thematic else None
|
projects = Project.objects.filter(thematics=intervention.thematic) if intervention.thematic else None
|
||||||
project_interv = ProjectIntervention.objects.filter(intervention=intervention).first()
|
project_interv = ProjectIntervention.objects.filter(intervention=intervention).first()
|
||||||
|
|
@ -2572,7 +2572,16 @@ def update_intervention(request, intervention_id):
|
||||||
# Handle notes separately (special case)
|
# Handle notes separately (special case)
|
||||||
notes_data = data.pop('notes', None)
|
notes_data = data.pop('notes', None)
|
||||||
|
|
||||||
for field, new_value in data.items():
|
# Sort fields to process assigned_provider before contract
|
||||||
|
def _field_priority(item):
|
||||||
|
f_name = item[0]
|
||||||
|
if f_name == 'assigned_provider':
|
||||||
|
return 0
|
||||||
|
if f_name == 'contract':
|
||||||
|
return 1
|
||||||
|
return 2
|
||||||
|
|
||||||
|
for field, new_value in sorted(data.items(), key=_field_priority):
|
||||||
if field not in allowed_update_fields:
|
if field not in allowed_update_fields:
|
||||||
errors.append(_('You do not have permission to update field: %(field)s') % {'field': field})
|
errors.append(_('You do not have permission to update field: %(field)s') % {'field': field})
|
||||||
permission_denied = True
|
permission_denied = True
|
||||||
|
|
@ -2724,16 +2733,16 @@ def get_contracts_by_provider(request):
|
||||||
return JsonResponse({"error": _("Missing provider_id")}, status=400)
|
return JsonResponse({"error": _("Missing provider_id")}, status=400)
|
||||||
|
|
||||||
user_config = get_object_or_404(UserConfig, user=request.user)
|
user_config = get_object_or_404(UserConfig, user=request.user)
|
||||||
if not user_config.roles.filter(name__in=['admin', 'manager', 'operator', 'external_manager', 'controller']).exists():
|
if not user_config.roles.filter(name__in=['admin', 'manager', 'operator', 'external_manager', 'controller', 'top_manager']).exists() and not request.user.is_superuser:
|
||||||
return JsonResponse({"error": "Permission denied"}, status=403)
|
return JsonResponse({"error": "Permission denied"}, status=403)
|
||||||
|
|
||||||
if user_config.roles.filter(name__in=['external_manager']).exists():
|
if user_config.limit_interventions_to_contracts or not user_config.is_intern:
|
||||||
accessible_contracts_ids = UserContractAccess.objects.filter(
|
accessible_contracts_ids = UserContractAccess.objects.filter(
|
||||||
user_config=user_config,
|
user_config=user_config,
|
||||||
contract__company_id=provider_id,
|
contract__company_id=provider_id,
|
||||||
can_view_interventions=True
|
can_view_interventions=True
|
||||||
).values_list('contract__id', flat=True)
|
).values_list('contract__id', flat=True)
|
||||||
contracts = Contract.objects.filter(id__in=accessible_contracts_ids)
|
contracts = Contract.objects.filter(id__in=accessible_contracts_ids, company_id=provider_id)
|
||||||
else:
|
else:
|
||||||
contracts = Contract.objects.filter(company_id=provider_id)
|
contracts = Contract.objects.filter(company_id=provider_id)
|
||||||
|
|
||||||
|
|
@ -2741,9 +2750,24 @@ def get_contracts_by_provider(request):
|
||||||
if thematic_id:
|
if thematic_id:
|
||||||
contracts = contracts.filter(thematics__id=thematic_id)
|
contracts = contracts.filter(thematics__id=thematic_id)
|
||||||
|
|
||||||
contracts = contracts.values("id", "contract_number")
|
contracts = contracts.distinct().order_by("contract_number")
|
||||||
|
|
||||||
return JsonResponse(list(contracts), safe=False)
|
contract_data = []
|
||||||
|
for c in contracts:
|
||||||
|
desc = (c.description or '').strip()
|
||||||
|
if desc:
|
||||||
|
truncated = desc[:45] + ('…' if len(desc) > 45 else '')
|
||||||
|
label = f"{c.contract_number} ({truncated})"
|
||||||
|
else:
|
||||||
|
label = c.contract_number
|
||||||
|
contract_data.append({
|
||||||
|
"id": c.id,
|
||||||
|
"contract_number": c.contract_number,
|
||||||
|
"description": desc,
|
||||||
|
"label": label,
|
||||||
|
})
|
||||||
|
|
||||||
|
return JsonResponse(contract_data, safe=False)
|
||||||
|
|
||||||
|
|
||||||
def add_custom_operation(request, intervention_id):
|
def add_custom_operation(request, intervention_id):
|
||||||
|
|
|
||||||
|
|
@ -2431,8 +2431,8 @@ def _update_intervention_field(intervention, field, new_value, user_config=None)
|
||||||
|
|
||||||
company = Company.objects.filter(id=new_value).first()
|
company = Company.objects.filter(id=new_value).first()
|
||||||
if company and intervention.assigned_provider != company:
|
if company and intervention.assigned_provider != company:
|
||||||
# Pour les external_managers, vérifier qu'ils ont accès à au moins un contrat de ce prestataire
|
# Vérifier l'accès aux contrats pour les utilisateurs limités aux contrats ou externes
|
||||||
if user_config and not user_config.is_intern:
|
if user_config and (user_config.limit_interventions_to_contracts or not user_config.is_intern):
|
||||||
has_access = UserContractAccess.objects.filter(
|
has_access = UserContractAccess.objects.filter(
|
||||||
user_config=user_config,
|
user_config=user_config,
|
||||||
contract__company=company,
|
contract__company=company,
|
||||||
|
|
@ -2455,8 +2455,8 @@ def _update_intervention_field(intervention, field, new_value, user_config=None)
|
||||||
|
|
||||||
contract = Contract.objects.filter(id=new_value).first()
|
contract = Contract.objects.filter(id=new_value).first()
|
||||||
if contract:
|
if contract:
|
||||||
# Pour les external_managers, vérifier qu'ils ont accès à ce contrat
|
# Vérifier l'accès au contrat pour les utilisateurs limités aux contrats ou externes
|
||||||
if user_config and not user_config.is_intern:
|
if user_config and (user_config.limit_interventions_to_contracts or not user_config.is_intern):
|
||||||
has_access = UserContractAccess.objects.filter(
|
has_access = UserContractAccess.objects.filter(
|
||||||
user_config=user_config,
|
user_config=user_config,
|
||||||
contract=contract,
|
contract=contract,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue