feat: implement document sharing authorization and visibility settings updates

This commit is contained in:
kdeterme 2026-08-29 19:20:15 +02:00
parent 22d0ad9928
commit a098a66204
10 changed files with 320 additions and 61 deletions

View file

@ -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):

View file

@ -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):

View file

@ -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

View file

@ -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');

View file

@ -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 }}
@ -94,4 +102,45 @@
</div>
</form>
</div>
{% endblock content %}
{% 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 %}

View file

@ -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>

View file

@ -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">
@ -69,4 +75,29 @@
</div>
</form>
</div>
{% endblock content %}
{% 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 %}

View file

@ -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">

View file

@ -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)

View file

@ -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."))