feat: implement document sharing authorization and visibility settings updates
This commit is contained in:
parent
22d0ad9928
commit
a098a66204
10 changed files with 320 additions and 61 deletions
|
|
@ -16,8 +16,14 @@ from .models import (
|
|||
DocumentValidation,
|
||||
DocumentVersion,
|
||||
ManagedDocument,
|
||||
VisibilityScope,
|
||||
)
|
||||
from .permissions import (
|
||||
filter_folders_for_user,
|
||||
user_can_change_visibility,
|
||||
)
|
||||
from common.models import Thematic
|
||||
from common.utils import get_short_name
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
|
@ -34,23 +40,42 @@ class TagField(forms.CharField):
|
|||
class DocumentUpdateForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = ManagedDocument
|
||||
fields = ["title", "description", "visibility", "thematics"]
|
||||
fields = ["title", "description", "visibility", "thematics", "folders"]
|
||||
widgets = {
|
||||
"title": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"description": forms.Textarea(attrs={"class": "form-control", "rows": 4}),
|
||||
"visibility": forms.Select(attrs={"class": "form-select"}),
|
||||
"thematics": forms.SelectMultiple(attrs={"class": "form-select"}),
|
||||
"folders": forms.SelectMultiple(attrs={"class": "form-select"}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.user = kwargs.pop("user", None)
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["title"].widget.attrs.setdefault("class", "form-control")
|
||||
self.fields["description"].widget.attrs.setdefault("class", "form-control")
|
||||
self.fields["visibility"].widget.attrs.setdefault("class", "form-select")
|
||||
self.fields["visibility"].required = False
|
||||
if "visibility" in self.fields:
|
||||
self.fields["visibility"].widget.attrs.setdefault("class", "form-select")
|
||||
self.fields["visibility"].required = False
|
||||
if self.user and not user_can_change_visibility(self.user):
|
||||
self.fields.pop("visibility", None)
|
||||
if "thematics" in self.fields:
|
||||
self.fields["thematics"].queryset = Thematic.objects.order_by("name_fr")
|
||||
self.fields["thematics"].required = False
|
||||
if "folders" in self.fields:
|
||||
self.fields["folders"].widget.attrs.setdefault("class", "form-select")
|
||||
self.fields["folders"].required = False
|
||||
if self.user:
|
||||
self.fields["folders"].queryset = filter_folders_for_user(
|
||||
DocumentFolder.objects.all(),
|
||||
self.user,
|
||||
required_permission=ManagedDocument.PERMISSION_VIEW,
|
||||
).order_by("name")
|
||||
|
||||
def clean_visibility(self):
|
||||
if self.user and not user_can_change_visibility(self.user):
|
||||
return self.instance.visibility
|
||||
return self.cleaned_data.get("visibility") or self.instance.visibility
|
||||
|
||||
|
||||
class ManagedDocumentForm(forms.ModelForm):
|
||||
|
|
@ -72,13 +97,16 @@ class ManagedDocumentForm(forms.ModelForm):
|
|||
|
||||
def __init__(self, *args, **kwargs):
|
||||
user = kwargs.pop("user", None)
|
||||
self.user = user
|
||||
initial_folder = kwargs.pop("initial_folder", None)
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["visibility"].required = False
|
||||
if "visibility" in self.fields:
|
||||
self.fields["visibility"].required = False
|
||||
if user and not user_can_change_visibility(user):
|
||||
self.fields.pop("visibility", None)
|
||||
|
||||
# Limiter les folders accessibles à l'utilisateur
|
||||
if user:
|
||||
from .permissions import filter_folders_for_user
|
||||
accessible_folders = filter_folders_for_user(
|
||||
DocumentFolder.objects.all(),
|
||||
user,
|
||||
|
|
@ -103,16 +131,11 @@ class ManagedDocumentForm(forms.ModelForm):
|
|||
tags_input = self.cleaned_data.pop("tags_input", [])
|
||||
document = super().save(commit=False)
|
||||
document.created_by = user
|
||||
if user and not user_can_change_visibility(user):
|
||||
document.visibility = VisibilityScope.INTERNAL
|
||||
if commit:
|
||||
document.save()
|
||||
self.save_m2m()
|
||||
# if tags_input:
|
||||
# for tag_name in tags_input:
|
||||
# tag, _ = DocumentTag.objects.get_or_create(
|
||||
# slug=slugify(tag_name),
|
||||
# defaults={"name_fr": tag_name, "name_nl": tag_name},
|
||||
# )
|
||||
# document.tags.add(tag)
|
||||
file = self.cleaned_data["file"]
|
||||
comment = self.cleaned_data.get("comment", "")
|
||||
version = DocumentVersion(
|
||||
|
|
@ -165,6 +188,7 @@ class DocumentShareForm(forms.ModelForm):
|
|||
if document:
|
||||
self.fields["user"].queryset = User.objects.exclude(document_shares__document=document)
|
||||
self.fields["user"].widget.attrs.setdefault("class", "form-select")
|
||||
self.fields["user"].label_from_instance = lambda obj: get_short_name(obj) or obj.username
|
||||
|
||||
def save(self, commit=True, creator=None, document=None):
|
||||
share = super().save(commit=False)
|
||||
|
|
@ -193,6 +217,7 @@ class DocumentFolderShareForm(forms.ModelForm):
|
|||
document_folder_shares__folder=folder
|
||||
)
|
||||
self.fields["user"].widget.attrs.setdefault("class", "form-select")
|
||||
self.fields["user"].label_from_instance = lambda obj: get_short_name(obj) or obj.username
|
||||
|
||||
def save(self, commit=True, creator=None, folder=None):
|
||||
share = super().save(commit=False)
|
||||
|
|
@ -366,6 +391,8 @@ class DocumentFolderForm(forms.ModelForm):
|
|||
super().__init__(*args, **kwargs)
|
||||
if "visibility" in self.fields:
|
||||
self.fields["visibility"].required = False
|
||||
if self.request_user and not user_can_change_visibility(self.request_user):
|
||||
self.fields.pop("visibility", None)
|
||||
|
||||
instance = getattr(self, "instance", None)
|
||||
if instance and instance.pk:
|
||||
|
|
@ -385,15 +412,20 @@ class DocumentFolderForm(forms.ModelForm):
|
|||
if not instance or not getattr(instance, "is_predefined", False):
|
||||
self.fields.pop("thematics", None)
|
||||
|
||||
self.fields["created_by"].label = _("Owner")
|
||||
self.fields["created_by"].required = False
|
||||
|
||||
if "created_by" in self.fields:
|
||||
self.fields["created_by"].label = _("Owner")
|
||||
self.fields["created_by"].required = False
|
||||
self.fields["created_by"].queryset = User.objects.order_by("username")
|
||||
self.fields["created_by"].label_from_instance = lambda obj: get_short_name(obj) or obj.username
|
||||
|
||||
if not self._can_edit_owner():
|
||||
self.fields.pop("created_by", None)
|
||||
|
||||
def clean_visibility(self):
|
||||
if self.request_user and not user_can_change_visibility(self.request_user):
|
||||
return self.instance.visibility if self.instance.pk else VisibilityScope.INTERNAL
|
||||
return self.cleaned_data.get("visibility") or (self.instance.visibility if self.instance.pk else VisibilityScope.INTERNAL)
|
||||
|
||||
def _can_edit_owner(self):
|
||||
user = self.request_user
|
||||
if user is None or not getattr(user, "is_authenticated", False):
|
||||
|
|
|
|||
|
|
@ -112,10 +112,10 @@ class DocumentTag(models.Model):
|
|||
|
||||
|
||||
class VisibilityScope(models.TextChoices):
|
||||
PRIVATE = "private", _("Private (Creator only)")
|
||||
RESTRICTED = "restricted", _("Shared with specific users")
|
||||
INTERNAL = "internal", _("Internal (Thematics & Contracts)")
|
||||
SCOPED = "scoped", _("All qualified users (Internal & External)")
|
||||
PRIVATE = "private", _("Privé (Créateur uniquement)")
|
||||
RESTRICTED = "restricted", _("Partagé avec certains utilisateurs")
|
||||
INTERNAL = "internal", _("Interne (sur base des thématiques et contrats)")
|
||||
SCOPED = "scoped", _("Interne et externe (sur base des thématiques et contrats)")
|
||||
|
||||
|
||||
class DocumentFolder(models.Model):
|
||||
|
|
|
|||
|
|
@ -247,6 +247,25 @@ def user_is_documents_admin(user) -> bool:
|
|||
return False
|
||||
|
||||
|
||||
def user_is_internal(user) -> bool:
|
||||
if user is None or not getattr(user, "is_authenticated", False):
|
||||
return False
|
||||
if getattr(user, "is_superuser", False) or user_is_documents_admin(user):
|
||||
return True
|
||||
config = getattr(user, "config", None)
|
||||
return bool(config and getattr(config, "is_intern", False))
|
||||
|
||||
|
||||
def user_can_share(user) -> bool:
|
||||
"""External users (even authors) cannot share documents or folders."""
|
||||
return user_is_internal(user)
|
||||
|
||||
|
||||
def user_can_change_visibility(user) -> bool:
|
||||
"""External users (even authors) cannot change visibility."""
|
||||
return user_is_internal(user)
|
||||
|
||||
|
||||
def user_is_internal_manager(user) -> bool:
|
||||
if user is None or not getattr(user, "is_authenticated", False):
|
||||
return False
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@
|
|||
{% block head %}
|
||||
{{ block.super }}
|
||||
<link rel="stylesheet" href="{% static 'common/jquery-ui.min.css' %}" />
|
||||
<link rel="stylesheet" href="{% static 'common/select2.min.css' %}" />
|
||||
<link rel="stylesheet" href="{% static 'common/select2-bootstrap-5-theme.min.css' %}" />
|
||||
<style>
|
||||
.ui-autocomplete {
|
||||
z-index: 2000 !important;
|
||||
|
|
@ -22,16 +24,16 @@
|
|||
<div class="d-flex align-items-center gap-2">
|
||||
<h2 class="mb-1">{{ document.title }}</h2>
|
||||
{% if document.visibility == "private" %}
|
||||
<span class="badge bg-dark" title="{% translate 'Private (Creator only)' %}"><i class="bi bi-lock-fill"></i> {% translate "Private" %}</span>
|
||||
<span class="badge bg-dark" title="{% translate 'Privé (Créateur uniquement)' %}"><i class="bi bi-lock-fill"></i> {% translate "Privé" %}</span>
|
||||
{% elif document.visibility == "restricted" %}
|
||||
<span class="badge bg-warning text-dark" title="{% translate 'Shared with specific users' %}"><i class="bi bi-people-fill"></i> {% translate "Shared" %}</span>
|
||||
<span class="badge bg-warning text-dark" title="{% translate 'Partagé avec certains utilisateurs' %}"><i class="bi bi-people-fill"></i> {% translate "Partagé" %}</span>
|
||||
{% elif document.visibility == "scoped" %}
|
||||
<span class="badge bg-success" title="{% translate 'All qualified users (Internal & External)' %}"><i class="bi bi-globe"></i> {% translate "All qualified" %}</span>
|
||||
<span class="badge bg-success" title="{% translate 'Interne et externe (sur base des thématiques et contrats)' %}"><i class="bi bi-globe"></i> {% translate "Interne & Externe" %}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-primary" title="{% translate 'Internal (Thematics & Contracts)' %}"><i class="bi bi-building"></i> {% translate "Internal" %}</span>
|
||||
<span class="badge bg-primary" title="{% translate 'Interne (sur base des thématiques et contrats)' %}"><i class="bi bi-building"></i> {% translate "Interne" %}</span>
|
||||
{% endif %}
|
||||
{% if can_edit_document %}
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary py-0 px-2" data-bs-toggle="modal" data-bs-target="#editDocumentModal" title="{% translate 'Modifier le titre et la description' %}">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary py-0 px-2" data-bs-toggle="modal" data-bs-target="#editDocumentModal" title="{% translate 'Modifier le document' %}">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
|
|
@ -39,11 +41,6 @@
|
|||
<p class="text-muted mb-0">{{ document.description|default:_("No description provided") }}</p>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
{% if can_edit_document %}
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#editDocumentModal">
|
||||
<i class="bi bi-pencil"></i> {% translate "Modifier" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if document.georeference_coords %}
|
||||
<a class="btn btn-outline-success" href="{% url 'documents:map_view' document.pk %}" target="_blank">
|
||||
<i class="bi bi-map"></i> {% translate "Afficher sur la carte" %}
|
||||
|
|
@ -130,7 +127,7 @@
|
|||
<form method="post" action="{% url 'documents:add_folder' document.pk %}">
|
||||
{% csrf_token %}
|
||||
<div class="input-group input-group-sm">
|
||||
<select name="folder_id" class="form-select" required>
|
||||
<select name="folder_id" id="id_add_folder_select" class="form-select" required>
|
||||
<option value="">-- {% translate "Select a folder" %} --</option>
|
||||
{% for f in available_folders %}
|
||||
<option value="{{ f.pk }}">{{ f.name }}</option>
|
||||
|
|
@ -432,6 +429,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{% if can_share_document %}
|
||||
<div class="col-lg-4">
|
||||
<div class="card mb-0">
|
||||
<div class="card-header"><h5 class="mb-0">{% translate "Shares" %}</h5></div>
|
||||
|
|
@ -458,7 +456,7 @@
|
|||
<ul class="list-group list-group-flush">
|
||||
{% for share in document.shares.all %}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<span>{{ share.user.get_full_name|default:share.user.username }} – {{ share.get_permission_display }}</span>
|
||||
<span>{{ share.user|short_name }} – {{ share.get_permission_display }}</span>
|
||||
<form method="post" action="{% url 'documents:delete_share' document.pk share.pk %}">
|
||||
{% csrf_token %}
|
||||
<button class="btn btn-sm btn-outline-danger" type="submit"><i class="bi bi-x"></i></button>
|
||||
|
|
@ -516,6 +514,7 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -745,13 +744,23 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||
<label for="id_doc_description" class="form-label fw-bold">{% translate "Description" %}</label>
|
||||
<textarea name="description" id="id_doc_description" class="form-control" rows="4" placeholder="{% translate 'Description facultative...' %}">{{ document.description }}</textarea>
|
||||
</div>
|
||||
{% if can_change_visibility %}
|
||||
<div class="mb-3">
|
||||
<label for="id_doc_visibility" class="form-label fw-bold">{% translate "Visibilité" %} <span class="text-danger">*</span></label>
|
||||
<select name="visibility" id="id_doc_visibility" class="form-select">
|
||||
<option value="private" {% if document.visibility == "private" %}selected{% endif %}>{% translate "Privé (Créateur uniquement)" %}</option>
|
||||
<option value="restricted" {% if document.visibility == "restricted" %}selected{% endif %}>{% translate "Partagé avec certains utilisateurs" %}</option>
|
||||
<option value="internal" {% if document.visibility == "internal" %}selected{% endif %}>{% translate "Interne (Thématiques & Contrats)" %}</option>
|
||||
<option value="scoped" {% if document.visibility == "scoped" %}selected{% endif %}>{% translate "Tous les acteurs qualifiés (Internes & Externes)" %}</option>
|
||||
<option value="internal" {% if document.visibility == "internal" %}selected{% endif %}>{% translate "Interne (sur base des thématiques et contrats)" %}</option>
|
||||
<option value="scoped" {% if document.visibility == "scoped" %}selected{% endif %}>{% translate "Interne et externe (sur base des thématiques et contrats)" %}</option>
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="mb-3">
|
||||
<label for="id_doc_folders" class="form-label fw-bold">{% translate "Répertoires" %}</label>
|
||||
<select name="folders" id="id_doc_folders" class="form-select" multiple>
|
||||
{% for f in all_accessible_folders %}
|
||||
<option value="{{ f.pk }}" {% if f in document.folders.all %}selected{% endif %}>{{ f.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -778,8 +787,30 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||
{% block script %}
|
||||
{{ block.super }}
|
||||
<script src="{% static 'common/jquery-ui.min.js' %}"></script>
|
||||
<script src="{% static 'common/select2.min.js' %}"></script>
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
// Select2 for document folders in modal
|
||||
$('#editDocumentModal').on('shown.bs.modal', function() {
|
||||
$('#id_doc_folders').select2({
|
||||
dropdownParent: $('#editDocumentModal'),
|
||||
theme: 'bootstrap-5',
|
||||
placeholder: "{% translate 'Rechercher des répertoires...' %}",
|
||||
allowClear: true,
|
||||
width: '100%'
|
||||
});
|
||||
});
|
||||
|
||||
// Select2 for add folder select in card
|
||||
if ($('#id_add_folder_select').length) {
|
||||
$('#id_add_folder_select').select2({
|
||||
theme: 'bootstrap-5',
|
||||
placeholder: "{% translate 'Sélectionner un répertoire...' %}",
|
||||
allowClear: true,
|
||||
width: '100%'
|
||||
});
|
||||
}
|
||||
|
||||
const $contentType = $('#id_content_type');
|
||||
const $objectId = $('#id_object_id');
|
||||
const $searchInput = $('#object_search_input');
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
{% extends "documents/base_documents.html" %}
|
||||
{% load i18n %}
|
||||
{% load i18n static %}
|
||||
|
||||
{% block head %}
|
||||
{{ block.super }}
|
||||
<link rel="stylesheet" href="{% static 'common/select2.min.css' %}" />
|
||||
<link rel="stylesheet" href="{% static 'common/select2-bootstrap-5-theme.min.css' %}" />
|
||||
{% endblock head %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-4">
|
||||
|
|
@ -26,6 +32,7 @@
|
|||
<div class="text-danger small">{{ form.description.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if form.visibility %}
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label" for="id_visibility">{% translate "Visibility" %}</label>
|
||||
|
|
@ -42,6 +49,15 @@
|
|||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="id_thematics">{% translate "Thematics" %}</label>
|
||||
{{ form.thematics }}
|
||||
{% if form.thematics.errors %}
|
||||
<div class="text-danger small">{{ form.thematics.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label" for="id_types">{% translate "Types" %}</label>
|
||||
|
|
@ -65,14 +81,6 @@
|
|||
<div class="text-danger small">{{ form.folders.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<!-- <div class="mb-3">
|
||||
<label class="form-label" for="id_tags_input">{% translate "New tags" %}</label>
|
||||
{{ form.tags_input }}
|
||||
{% if form.tags_input.errors %}
|
||||
<div class="text-danger small">{{ form.tags_input.errors }}</div>
|
||||
{% endif %}
|
||||
<div class="form-text">{{ form.tags_input.help_text }}</div>
|
||||
</div> -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="id_file">{% translate "File" %}</label>
|
||||
{{ form.file }}
|
||||
|
|
@ -95,3 +103,44 @@
|
|||
</form>
|
||||
</div>
|
||||
{% endblock content %}
|
||||
|
||||
{% block script %}
|
||||
{{ block.super }}
|
||||
<script src="{% static 'common/select2.min.js' %}"></script>
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
if ($('#id_folders').length) {
|
||||
$('#id_folders').select2({
|
||||
theme: 'bootstrap-5',
|
||||
placeholder: "{% translate 'Rechercher des répertoires...' %}",
|
||||
allowClear: true,
|
||||
width: '100%'
|
||||
});
|
||||
}
|
||||
if ($('#id_thematics').length) {
|
||||
$('#id_thematics').select2({
|
||||
theme: 'bootstrap-5',
|
||||
placeholder: "{% translate 'Sélectionner des thématiques...' %}",
|
||||
allowClear: true,
|
||||
width: '100%'
|
||||
});
|
||||
}
|
||||
if ($('#id_types').length) {
|
||||
$('#id_types').select2({
|
||||
theme: 'bootstrap-5',
|
||||
placeholder: "{% translate 'Sélectionner des types...' %}",
|
||||
allowClear: true,
|
||||
width: '100%'
|
||||
});
|
||||
}
|
||||
if ($('#id_tags').length) {
|
||||
$('#id_tags').select2({
|
||||
theme: 'bootstrap-5',
|
||||
placeholder: "{% translate 'Sélectionner des tags...' %}",
|
||||
allowClear: true,
|
||||
width: '100%'
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock script %}
|
||||
|
|
@ -117,7 +117,7 @@
|
|||
{% elif folder.visibility == "restricted" %}
|
||||
<span class="badge bg-warning text-dark ms-1" title="{% translate 'Partagé avec certains utilisateurs' %}"><i class="bi bi-people-fill"></i></span>
|
||||
{% elif folder.visibility == "scoped" %}
|
||||
<span class="badge bg-success ms-1" title="{% translate 'Tous les acteurs qualifiés' %}"><i class="bi bi-globe"></i></span>
|
||||
<span class="badge bg-success ms-1" title="{% translate 'Interne et externe (sur base des thématiques et contrats)' %}"><i class="bi bi-globe"></i></span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ folder.updated_at|date:"SHORT_DATETIME_FORMAT" }}</td>
|
||||
|
|
@ -137,7 +137,7 @@
|
|||
</td>
|
||||
<td><span class="text-muted">—</span></td>
|
||||
<td class="text-end">
|
||||
{% if folder.created_by_id == request.user.id or is_documents_admin %}
|
||||
{% if is_documents_admin or folder.created_by_id == request.user.id and request.user.config.is_intern %}
|
||||
<a class="btn btn-sm btn-outline-primary me-1" style="border-style: hidden;" href="{% url 'documents:folder_share' folder.pk %}" title="{% translate 'Manage sharing' %}">
|
||||
<i class="bi bi-share"></i>
|
||||
</a>
|
||||
|
|
@ -178,15 +178,19 @@
|
|||
<i class="bi bi-file-earmark-excel text-success"></i>
|
||||
{% elif ext4 == ".ppt" or ext5 == ".pptx" %}
|
||||
<i class="bi bi-file-earmark-ppt text-warning"></i>
|
||||
{% elif ext4 == ".zip" or ext4 == ".rar" or ext4 == ".7z" or ext5 == ".tar" or ext5 == ".gz" %}
|
||||
<i class="bi bi-file-earmark-zip text-secondary"></i>
|
||||
{% elif ext4 == ".dxf" or ext4 == ".dwg" or ext4 == ".ifc" %}
|
||||
<i class="bi bi-file-earmark-code text-info"></i>
|
||||
{% elif ext4 == ".jpg" or ext5 == ".jpeg" or ext4 == ".png" or ext4 == ".gif" %}
|
||||
<i class="bi bi-file-earmark-image text-info"></i>
|
||||
<i class="bi bi-file-earmark-image text-success"></i>
|
||||
{% else %}
|
||||
<i class="bi bi-file-earmark"></i>
|
||||
<i class="bi bi-file-earmark text-secondary"></i>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% endwith %}
|
||||
{% else %}
|
||||
<i class="bi bi-file-earmark"></i>
|
||||
<i class="bi bi-file-earmark text-secondary"></i>
|
||||
{% endif %}
|
||||
<a href="{{ document.get_absolute_url }}">{{ document.title }}{% if document.get_file_extension %} ({{ document.get_file_extension }}){% endif %}</a>
|
||||
{% if document.visibility == "private" %}
|
||||
|
|
@ -194,7 +198,7 @@
|
|||
{% elif document.visibility == "restricted" %}
|
||||
<span class="badge bg-warning text-dark ms-1" title="{% translate 'Partagé avec certains utilisateurs' %}"><i class="bi bi-people-fill"></i></span>
|
||||
{% elif document.visibility == "scoped" %}
|
||||
<span class="badge bg-success ms-1" title="{% translate 'Tous les acteurs qualifiés' %}"><i class="bi bi-globe"></i></span>
|
||||
<span class="badge bg-success ms-1" title="{% translate 'Interne et externe (sur base des thématiques et contrats)' %}"><i class="bi bi-globe"></i></span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ document.updated_at|date:"SHORT_DATETIME_FORMAT" }}</td>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
{% extends "documents/base_documents.html" %}
|
||||
{% load i18n %}
|
||||
{% load i18n static %}
|
||||
|
||||
{% block head %}
|
||||
{{ block.super }}
|
||||
<link rel="stylesheet" href="{% static 'common/select2.min.css' %}" />
|
||||
<link rel="stylesheet" href="{% static 'common/select2-bootstrap-5-theme.min.css' %}" />
|
||||
{% endblock head %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid py-4">
|
||||
|
|
@ -70,3 +76,28 @@
|
|||
</form>
|
||||
</div>
|
||||
{% endblock content %}
|
||||
|
||||
{% block script %}
|
||||
{{ block.super }}
|
||||
<script src="{% static 'common/select2.min.js' %}"></script>
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
if ($('#id_parent_folders').length) {
|
||||
$('#id_parent_folders').select2({
|
||||
theme: 'bootstrap-5',
|
||||
placeholder: "{% translate 'Rechercher des répertoires parents...' %}",
|
||||
allowClear: true,
|
||||
width: '100%'
|
||||
});
|
||||
}
|
||||
if ($('#id_thematics').length) {
|
||||
$('#id_thematics').select2({
|
||||
theme: 'bootstrap-5',
|
||||
placeholder: "{% translate 'Sélectionner des thématiques...' %}",
|
||||
allowClear: true,
|
||||
width: '100%'
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock script %}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
{% extends "documents/base_documents.html" %}
|
||||
{% load i18n %}
|
||||
{% load i18n short_name %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-4">
|
||||
|
|
@ -63,7 +63,7 @@
|
|||
<ul class="list-group list-group-flush">
|
||||
{% for share in shares %}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<span>{{ share.user.get_full_name|default:share.user.username }} – {{ share.get_permission_display }}</span>
|
||||
<span>{{ share.user|short_name }} – {{ share.get_permission_display }}</span>
|
||||
<form method="post" action="{% url 'documents:folder_share_delete' folder.pk share.pk %}" class="ms-2">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger">
|
||||
|
|
|
|||
|
|
@ -1007,4 +1007,71 @@ class DocumentVisibilityAndIndependenceTests(TestCase):
|
|||
folder_response = self.client.get(reverse("documents:list"), {"folder": folder.slug})
|
||||
self.assertEqual(folder_response.status_code, 404)
|
||||
|
||||
def test_external_user_restrictions(self):
|
||||
from .models import VisibilityScope
|
||||
from .permissions import user_can_share, user_can_change_visibility
|
||||
|
||||
# External user restrictions check
|
||||
self.assertFalse(user_can_share(self.external_user))
|
||||
self.assertFalse(user_can_change_visibility(self.external_user))
|
||||
self.assertTrue(user_can_share(self.internal_user))
|
||||
self.assertTrue(user_can_change_visibility(self.internal_user))
|
||||
|
||||
# External user upload automatically gets INTERNAL visibility
|
||||
from common.models import AppView
|
||||
doc_view, _ = AppView.objects.get_or_create(
|
||||
code="documents",
|
||||
defaults={"name_fr": "Documents", "name_nl": "Documenten", "url_name": "documents:list", "order": 1},
|
||||
)
|
||||
self.external_config.accessible_views.add(doc_view)
|
||||
self.client.force_login(self.external_user)
|
||||
import io
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
test_file = SimpleUploadedFile("test.txt", b"hello world")
|
||||
upload_response = self.client.post(
|
||||
reverse("documents:create"),
|
||||
{
|
||||
"title": "Uploaded By External",
|
||||
"file": test_file,
|
||||
"thematics": [self.thematic.pk],
|
||||
"visibility": VisibilityScope.SCOPED, # Even if attempting to pass scoped
|
||||
},
|
||||
)
|
||||
self.assertEqual(upload_response.status_code, 302)
|
||||
created_doc = ManagedDocument.objects.first()
|
||||
self.assertEqual(created_doc.created_by, self.external_user)
|
||||
self.assertEqual(created_doc.visibility, VisibilityScope.INTERNAL)
|
||||
|
||||
# External user cannot change visibility via edit view
|
||||
edit_response = self.client.post(
|
||||
reverse("documents:edit", kwargs={"pk": created_doc.pk}),
|
||||
{
|
||||
"title": "Renamed By External",
|
||||
"visibility": VisibilityScope.SCOPED,
|
||||
},
|
||||
)
|
||||
self.assertEqual(edit_response.status_code, 302)
|
||||
created_doc.refresh_from_db()
|
||||
self.assertEqual(created_doc.title, "Renamed By External")
|
||||
self.assertEqual(created_doc.visibility, VisibilityScope.INTERNAL) # Stays INTERNAL
|
||||
|
||||
# External user cannot share document
|
||||
share_response = self.client.post(
|
||||
reverse("documents:share", kwargs={"pk": created_doc.pk}),
|
||||
{
|
||||
"user": self.other_user.pk,
|
||||
"permission": ManagedDocument.PERMISSION_VIEW,
|
||||
},
|
||||
)
|
||||
self.assertEqual(share_response.status_code, 403)
|
||||
|
||||
# External user cannot create shareable link
|
||||
link_response = self.client.post(
|
||||
reverse("documents:access_link", kwargs={"pk": created_doc.pk}),
|
||||
{
|
||||
"permission": ManagedDocument.PERMISSION_VIEW,
|
||||
},
|
||||
)
|
||||
self.assertEqual(link_response.status_code, 403)
|
||||
|
||||
|
||||
|
|
@ -47,12 +47,16 @@ from .permissions import (
|
|||
filter_folders_for_user,
|
||||
get_user_visible_folder_ids,
|
||||
user_can_browse_folder,
|
||||
user_can_change_visibility,
|
||||
user_can_delete_document,
|
||||
user_can_share,
|
||||
user_has_document_permission,
|
||||
user_has_folder_permission,
|
||||
user_is_documents_admin,
|
||||
user_is_internal,
|
||||
user_is_internal_manager,
|
||||
)
|
||||
from common.utils import get_short_name
|
||||
|
||||
|
||||
class DocumentsAppAccessMixin:
|
||||
|
|
@ -207,13 +211,27 @@ def ensure_document_deletable(document, user):
|
|||
|
||||
|
||||
def ensure_folder_shareable(folder, user):
|
||||
if user_is_documents_admin(user):
|
||||
if not user_can_share(user):
|
||||
raise PermissionDenied
|
||||
if user_is_documents_admin(user) or getattr(user, "is_superuser", False):
|
||||
return
|
||||
if folder.created_by_id and folder.created_by_id == getattr(user, "pk", None):
|
||||
return
|
||||
raise PermissionDenied
|
||||
|
||||
|
||||
def ensure_document_shareable(document, user):
|
||||
if not user_can_share(user):
|
||||
raise PermissionDenied
|
||||
if user_is_documents_admin(user) or getattr(user, "is_superuser", False):
|
||||
return
|
||||
if user_has_document_permission(
|
||||
user, document, required_permission=ManagedDocument.PERMISSION_EDIT
|
||||
):
|
||||
return
|
||||
raise PermissionDenied
|
||||
|
||||
|
||||
def get_safe_next(request, candidate):
|
||||
if not candidate:
|
||||
return None
|
||||
|
|
@ -395,7 +413,7 @@ class DocumentListView(DocumentsAppAccessMixin, LoginRequiredMixin, ListView):
|
|||
"type": "folder",
|
||||
"object": folder,
|
||||
"updated_at": folder.updated_at,
|
||||
"updated_by": "/",
|
||||
"updated_by": get_short_name(folder.created_by) if folder.created_by else "/",
|
||||
"name_key": folder.name.casefold(),
|
||||
}
|
||||
)
|
||||
|
|
@ -407,7 +425,7 @@ class DocumentListView(DocumentsAppAccessMixin, LoginRequiredMixin, ListView):
|
|||
"object": document,
|
||||
"can_delete": user_can_delete_document(self.request.user, document),
|
||||
"updated_at": document.updated_at,
|
||||
"updated_by": document.latest_version.uploaded_by.get_short_name() if document.latest_version and document.latest_version.uploaded_by else "/",
|
||||
"updated_by": get_short_name(document.latest_version.uploaded_by) if document.latest_version and document.latest_version.uploaded_by else "/",
|
||||
"name_key": document.title.casefold(),
|
||||
}
|
||||
)
|
||||
|
|
@ -826,7 +844,7 @@ class DocumentUpdateView(DocumentsAppAccessMixin, LoginRequiredMixin, View):
|
|||
|
||||
def post(self, request, pk):
|
||||
document = self.get_document(pk, request.user)
|
||||
form = DocumentUpdateForm(request.POST, instance=document)
|
||||
form = DocumentUpdateForm(request.POST, instance=document, user=request.user)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Document updated successfully."))
|
||||
|
|
@ -1067,12 +1085,15 @@ class DocumentDetailView(DocumentsAppAccessMixin, LoginRequiredMixin, DetailView
|
|||
|
||||
is_admin = user_is_documents_admin(self.request.user)
|
||||
context["is_documents_admin"] = is_admin
|
||||
context["can_edit_document"] = is_admin or user_has_document_permission(
|
||||
can_edit = is_admin or user_has_document_permission(
|
||||
self.request.user,
|
||||
document,
|
||||
required_permission=ManagedDocument.PERMISSION_EDIT,
|
||||
)
|
||||
context["can_edit_document"] = can_edit
|
||||
context["can_delete_document"] = user_can_delete_document(self.request.user, document)
|
||||
context["can_share_document"] = user_can_share(self.request.user) and can_edit
|
||||
context["can_change_visibility"] = user_can_change_visibility(self.request.user)
|
||||
|
||||
# Available folders/tags for add forms (exclude already associated ones)
|
||||
existing_folder_ids = list(document.folders.values_list("pk", flat=True))
|
||||
|
|
@ -1080,15 +1101,16 @@ class DocumentDetailView(DocumentsAppAccessMixin, LoginRequiredMixin, DetailView
|
|||
DocumentFolder.objects.all(),
|
||||
self.request.user,
|
||||
required_permission=ManagedDocument.PERMISSION_VIEW,
|
||||
).exclude(pk__in=existing_folder_ids).order_by("name")
|
||||
context["available_folders"] = accessible_folders
|
||||
)
|
||||
context["available_folders"] = accessible_folders.exclude(pk__in=existing_folder_ids).order_by("name")
|
||||
context["all_accessible_folders"] = accessible_folders.order_by("name")
|
||||
|
||||
existing_tag_ids = list(document.tags.values_list("pk", flat=True))
|
||||
context["available_tags"] = DocumentTag.objects.exclude(
|
||||
pk__in=existing_tag_ids
|
||||
).order_by("name_fr")
|
||||
|
||||
context["edit_form"] = DocumentUpdateForm(instance=document)
|
||||
context["edit_form"] = DocumentUpdateForm(instance=document, user=self.request.user)
|
||||
|
||||
return context
|
||||
|
||||
|
|
@ -1175,6 +1197,7 @@ class AddVersionView(LoginRequiredMixin, View):
|
|||
class ShareDocumentView(LoginRequiredMixin, View):
|
||||
def post(self, request, pk):
|
||||
document = get_object_or_404(ManagedDocument, pk=pk)
|
||||
ensure_document_shareable(document, request.user)
|
||||
form = DocumentShareForm(request.POST, document=document)
|
||||
if form.is_valid():
|
||||
form.save(creator=request.user, document=document)
|
||||
|
|
@ -1187,6 +1210,7 @@ class ShareDocumentView(LoginRequiredMixin, View):
|
|||
class DeleteShareView(LoginRequiredMixin, View):
|
||||
def post(self, request, pk, share_id):
|
||||
document = get_object_or_404(ManagedDocument, pk=pk)
|
||||
ensure_document_shareable(document, request.user)
|
||||
share = get_object_or_404(document.shares, pk=share_id)
|
||||
share.delete()
|
||||
messages.success(request, _("Share removed."))
|
||||
|
|
@ -1255,6 +1279,7 @@ class DetachDocumentView(LoginRequiredMixin, View):
|
|||
class CreateAccessLinkView(LoginRequiredMixin, View):
|
||||
def post(self, request, pk):
|
||||
document = get_object_or_404(ManagedDocument, pk=pk)
|
||||
ensure_document_shareable(document, request.user)
|
||||
form = AccessLinkForm(request.POST)
|
||||
if form.is_valid():
|
||||
form.save(user=request.user, document=document)
|
||||
|
|
@ -1267,6 +1292,7 @@ class CreateAccessLinkView(LoginRequiredMixin, View):
|
|||
class RevokeAccessLinkView(LoginRequiredMixin, View):
|
||||
def post(self, request, pk, link_id):
|
||||
document = get_object_or_404(ManagedDocument, pk=pk)
|
||||
ensure_document_shareable(document, request.user)
|
||||
link = get_object_or_404(document.access_links, pk=link_id)
|
||||
link.delete()
|
||||
messages.success(request, _("Access link revoked."))
|
||||
|
|
|
|||
Loading…
Reference in a new issue