Browse Source

Feat (Assinatura Digital): Como produto, quero poder remover a assinatura digital de um documento [AB#1345]

pull/3858/head
KemuelAlves 4 months ago
parent
commit
be68a7879c
  1. 18
      sapl/base/migrations/0065_add_permite_remover_assinatura_e_permissoes.py
  2. 10
      sapl/base/models.py
  3. 21
      sapl/materia/migrations/0096_add_permite_remover_assinatura_e_permissoes.py
  4. 8
      sapl/materia/models.py
  5. 11
      sapl/materia/views.py
  6. 34
      sapl/materia/views_assinatura.py
  7. 1
      sapl/templates/base/layouts.yaml
  8. 10
      sapl/templates/materia/documentoacessorio_detail.html
  9. 10
      sapl/templates/materia/materialegislativa_detail.html
  10. 135
      sapl/templates/materia/remover_assinatura_modal.html

18
sapl/base/migrations/0065_add_permite_remover_assinatura_e_permissoes.py

@ -0,0 +1,18 @@
# Generated by Django 2.2.28 on 2026-05-14 11:00
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('base', '0064_appconfig_revisao_setor_legislativo'),
]
operations = [
migrations.AddField(
model_name='appconfig',
name='permite_remover_assinatura',
field=models.BooleanField(choices=[(True, 'Sim'), (False, 'Não')], default=False, help_text='Quando habilitado, superusuários e usuários com a permissão "Pode remover assinatura digital" poderão remover assinaturas de matérias e documentos acessórios para permitir edições.', verbose_name='Permitir remoção de assinatura digital?'),
),
]

10
sapl/base/models.py

@ -238,6 +238,16 @@ class AppConfig(models.Model):
verbose_name=_('Nome utilizado na assinatura digital'),
choices=TIPO_NOME_ASSINATURA,
default='P')
permite_remover_assinatura = models.BooleanField(
verbose_name=_('Permitir remoção de assinatura digital?'),
choices=YES_NO_CHOICES,
default=False,
help_text=_(
'Quando habilitado, superusuários e usuários com a permissão '
'"Pode remover assinatura digital" poderão remover assinaturas '
'de matérias e documentos acessórios para permitir edições.'
)
)
# MÓDULO PAINEL
cronometro_discurso = models.DurationField(
verbose_name=_('Cronômetro do Discurso'),

21
sapl/materia/migrations/0096_add_permite_remover_assinatura_e_permissoes.py

@ -0,0 +1,21 @@
# Generated by Django 2.2.28 on 2026-05-14 11:00
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('materia', '0095_add_autoria_proposicao'),
]
operations = [
migrations.AlterModelOptions(
name='documentoacessorio',
options={'ordering': ('data', 'id'), 'permissions': (('can_remove_assinatura_doc', 'Pode remover assinatura digital de documento acessório'),), 'verbose_name': 'Documento Acessório', 'verbose_name_plural': 'Documentos Acessórios'},
),
migrations.AlterModelOptions(
name='materialegislativa',
options={'ordering': ['-ano', 'tipo', 'numero'], 'permissions': (('can_access_impressos', 'Can access impressos'), ('can_remove_assinatura', 'Pode remover assinatura digital')), 'verbose_name': 'Matéria Legislativa', 'verbose_name_plural': 'Matérias Legislativas'},
),
]

8
sapl/materia/models.py

@ -345,7 +345,10 @@ class MateriaLegislativa(models.Model):
verbose_name_plural = _('Matérias Legislativas')
unique_together = (("tipo", "numero", "ano"),)
ordering = ['-ano', 'tipo', 'numero']
permissions = (("can_access_impressos", "Can access impressos"),)
permissions = (
("can_access_impressos", "Can access impressos"),
("can_remove_assinatura", "Pode remover assinatura digital"),
)
def __str__(self):
return _('%(tipo)s%(numero)s de %(ano)s') % {
@ -634,6 +637,9 @@ class DocumentoAcessorio(models.Model):
verbose_name = _('Documento Acessório')
verbose_name_plural = _('Documentos Acessórios')
ordering = ('data', 'id')
permissions = (
("can_remove_assinatura_doc", "Pode remover assinatura digital de documento acessório"),
)
def __str__(self):
return _('%(tipo)s - %(nome)s de %(data)s por %(autor)s') % {

11
sapl/materia/views.py

@ -1996,13 +1996,16 @@ class DocumentoAcessorioCrud(MasterDetailCrud):
)
# Normaliza assinatura_info para lista (backward-compatible)
from sapl.materia.views_assinatura import _normalizar_assinatura_info
from sapl.materia.views_assinatura import _normalizar_assinatura_info, _pode_remover_assinatura
assinaturas = _normalizar_assinatura_info(self.object.assinatura_info)
context['assinaturas'] = assinaturas
context['ja_assinou'] = any(
a.get('signed_by') == u.username
for a in assinaturas
) if u.is_authenticated else False
context['pode_remover_assinatura'] = (
u.is_authenticated and _pode_remover_assinatura(u)
)
return context
@ -2529,13 +2532,17 @@ class MateriaLegislativaCrud(Crud):
)
# Normaliza assinatura_info para lista (backward-compatible)
from sapl.materia.views_assinatura import _normalizar_assinatura_info
from sapl.materia.views_assinatura import _normalizar_assinatura_info, _pode_remover_assinatura
assinaturas = _normalizar_assinatura_info(self.object.assinatura_info)
context['assinaturas'] = assinaturas
context['ja_assinou'] = any(
a.get('signed_by') == self.request.user.username
for a in assinaturas
) if self.request.user.is_authenticated else False
context['pode_remover_assinatura'] = (
self.request.user.is_authenticated and
_pode_remover_assinatura(self.request.user)
)
return context

34
sapl/materia/views_assinatura.py

@ -28,6 +28,23 @@ from sapl.utils import build_onlyoffice_url
logger = logging.getLogger(__name__)
def _pode_remover_assinatura(user):
"""
Verifica se o usuário tem permissão para remover assinaturas digitais.
Regras (qualquer uma é suficiente):
1. É superusuário, OU
2. A funcionalidade está habilitada em AppConfig E o usuário possui a
permissão granular 'materia.can_remove_assinatura'.
"""
if user.is_superuser:
return True
from sapl.base.models import AppConfig
permite = AppConfig.attr('permite_remover_assinatura')
if permite and user.has_perm('materia.can_remove_assinatura'):
return True
return False
def _normalizar_assinatura_info(info):
"""Converte assinatura_info legado (dict) para lista de dicts."""
if info is None:
@ -1215,12 +1232,13 @@ def materia_verificar_assinatura(request, pk):
def materia_remover_assinatura(request, pk):
"""
Remove a assinatura digital da matéria.
Apenas superusuários podem executar esta ação.
Requer: superusuário OU funcionalidade habilitada em AppConfig
E permissão 'materia.can_remove_assinatura'.
"""
if not request.user.is_superuser:
if not _pode_remover_assinatura(request.user):
return JsonResponse({
'success': False,
'error': 'Apenas administradores podem remover assinaturas.'
'error': 'Você não tem permissão para remover assinaturas digitais.'
}, status=403)
materia = get_object_or_404(MateriaLegislativa, pk=pk)
@ -1641,12 +1659,16 @@ def docacessorio_verificar_assinatura(request, pk):
def docacessorio_remover_assinatura(request, pk):
"""
Remove a assinatura digital do documento acessório.
Apenas superusuários podem executar esta ação.
Requer: superusuário OU funcionalidade habilitada em AppConfig
E permissão 'materia.can_remove_assinatura_doc'.
"""
if not request.user.is_superuser:
if not (request.user.is_superuser or (
AppConfig.attr('permite_remover_assinatura') and
request.user.has_perm('materia.can_remove_assinatura_doc')
)):
return JsonResponse({
'success': False,
'error': 'Apenas administradores podem remover assinaturas.'
'error': 'Você não tem permissão para remover assinaturas digitais.'
}, status=403)
docacessorio = get_object_or_404(DocumentoAcessorio, pk=pk)

1
sapl/templates/base/layouts.yaml

@ -48,6 +48,7 @@ AppConfig:
{% trans 'Módulo Assinatura Digital' %}:
- assinatura_nome
- permite_remover_assinatura
{% trans 'Módulo Painel' %}:
- cronometro_discurso cronometro_aparte

10
sapl/templates/materia/documentoacessorio_detail.html

@ -78,6 +78,13 @@
<i class="fa fa-qrcode"></i> {% trans "Verificar Autenticidade" %}
</a>
{% endif %}
{% if pode_remover_assinatura %}
<button type="button" class="btn btn-sm btn-danger btn-remover-assinatura"
data-url="{% url 'sapl.materia:docacessorio_remover_assinatura' object.pk %}"
data-nome="{{ object }}">
<i class="fa fa-trash"></i> {% trans "Remover Assinatura" %}
</button>
{% endif %}
</div>
</div>
</div>
@ -98,4 +105,7 @@
});
</script>
{% endif %}
{% if pode_remover_assinatura and object.pdf_assinado %}
{% include "materia/remover_assinatura_modal.html" %}
{% endif %}
{% endblock extra_js %}

10
sapl/templates/materia/materialegislativa_detail.html

@ -230,6 +230,13 @@
<i class="fa fa-qrcode"></i> {% trans "Verificar Autenticidade" %}
</a>
{% endif %}
{% if pode_remover_assinatura %}
<button type="button" class="btn btn-sm btn-danger btn-remover-assinatura"
data-url="{% url 'sapl.materia:materia_remover_assinatura' object.pk %}"
data-nome="{{ object }}">
<i class="fa fa-trash"></i> {% trans "Remover Assinatura" %}
</button>
{% endif %}
</div>
</div>
</div>
@ -250,4 +257,7 @@
});
</script>
{% endif %}
{% if pode_remover_assinatura and object.pdf_assinado %}
{% include "materia/remover_assinatura_modal.html" %}
{% endif %}
{% endblock extra_js %}

135
sapl/templates/materia/remover_assinatura_modal.html

@ -0,0 +1,135 @@
{% load i18n %}
<!-- Modal de Confirmação: Remover Assinatura Digital -->
<div class="modal fade" id="removerAssinaturaModal" tabindex="-1" role="dialog"
aria-labelledby="removerAssinaturaModalLabel" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header bg-danger text-white">
<h5 class="modal-title" id="removerAssinaturaModalLabel">
<i class="fa fa-exclamation-triangle"></i>
{% trans "Remover Assinatura Digital" %}
</h5>
<button type="button" class="close text-white" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<div id="remover-assinatura-confirmacao">
<div class="alert alert-warning">
<i class="fa fa-exclamation-triangle"></i>
<strong>{% trans "Atenção: esta ação não pode ser desfeita." %}</strong>
</div>
<p>
{% trans "Você está prestes a remover a assinatura digital do documento:" %}
</p>
<p class="font-weight-bold" id="remover-assinatura-nome-doc"></p>
<p>
{% trans "A remoção da assinatura digital apagará o PDF assinado e todos os metadados da assinatura. O documento voltará ao estado não assinado e poderá ser editado novamente." %}
</p>
<p class="text-danger">
<i class="fa fa-info-circle"></i>
{% trans "Esta operação fica registrada no log do sistema." %}
</p>
</div>
<div id="remover-assinatura-processando" style="display:none;" class="text-center py-3">
<i class="fa fa-spinner fa-spin fa-2x text-danger mb-2"></i>
<p>{% trans "Removendo assinatura..." %}</p>
</div>
<div id="remover-assinatura-sucesso" style="display:none;">
<div class="alert alert-success">
<i class="fa fa-check-circle"></i>
{% trans "Assinatura removida com sucesso. A página será recarregada." %}
</div>
</div>
<div id="remover-assinatura-erro" style="display:none;">
<div class="alert alert-danger">
<i class="fa fa-times-circle"></i>
<strong>{% trans "Erro ao remover assinatura:" %}</strong>
<p id="remover-assinatura-mensagem-erro" class="mb-0 mt-1"></p>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal" id="btn-cancelar-remocao">
<i class="fa fa-times"></i> {% trans "Cancelar" %}
</button>
<button type="button" class="btn btn-danger" id="btn-confirmar-remocao">
<i class="fa fa-trash"></i> {% trans "Confirmar Remoção" %}
</button>
</div>
</div>
</div>
</div>
<script>
(function () {
'use strict';
var urlRemocao = null;
// Abre o modal ao clicar em qualquer botão .btn-remover-assinatura
document.addEventListener('click', function (e) {
var btn = e.target.closest('.btn-remover-assinatura');
if (!btn) return;
urlRemocao = btn.dataset.url;
var nome = btn.dataset.nome || '';
// Reset modal
document.getElementById('remover-assinatura-confirmacao').style.display = 'block';
document.getElementById('remover-assinatura-processando').style.display = 'none';
document.getElementById('remover-assinatura-sucesso').style.display = 'none';
document.getElementById('remover-assinatura-erro').style.display = 'none';
document.getElementById('remover-assinatura-nome-doc').textContent = nome;
document.getElementById('btn-confirmar-remocao').style.display = 'inline-block';
document.getElementById('btn-cancelar-remocao').textContent = 'Cancelar';
$('#removerAssinaturaModal').modal('show');
});
document.getElementById('btn-confirmar-remocao').addEventListener('click', function () {
if (!urlRemocao) return;
// Obter CSRF token
var csrfToken = null;
var cookieMatch = document.cookie.match(/csrftoken=([^;]+)/);
if (cookieMatch) csrfToken = cookieMatch[1];
// UI: processando
document.getElementById('remover-assinatura-confirmacao').style.display = 'none';
document.getElementById('remover-assinatura-processando').style.display = 'block';
document.getElementById('btn-confirmar-remocao').style.display = 'none';
fetch(urlRemocao, {
method: 'POST',
headers: {
'X-CSRFToken': csrfToken,
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(function (response) { return response.json(); })
.then(function (data) {
document.getElementById('remover-assinatura-processando').style.display = 'none';
if (data.success) {
document.getElementById('remover-assinatura-sucesso').style.display = 'block';
document.getElementById('btn-cancelar-remocao').textContent = 'Fechar';
setTimeout(function () { location.reload(); }, 1500);
} else {
document.getElementById('remover-assinatura-erro').style.display = 'block';
document.getElementById('remover-assinatura-mensagem-erro').textContent = data.error || 'Erro desconhecido.';
document.getElementById('btn-cancelar-remocao').textContent = 'Fechar';
}
})
.catch(function (err) {
document.getElementById('remover-assinatura-processando').style.display = 'none';
document.getElementById('remover-assinatura-erro').style.display = 'block';
document.getElementById('remover-assinatura-mensagem-erro').textContent = 'Erro de comunicação: ' + err.message;
document.getElementById('btn-cancelar-remocao').textContent = 'Fechar';
});
});
}());
</script>
Loading…
Cancel
Save