Browse Source

Feat(Materializacao): SAPL se autossupervisiona, laco renasce sem operador AB#1480

ADR-0015, fecha o incidente da IND 826/2026: em producao (fora de docker)
o laco dependia de alguem lembrar de subi-lo. Agora o laco grava batimento
no banco a cada tick (inclusive durante varredura longa) e um middleware
no processo web — o unico processo garantido, ja que o hub polla a API o
tempo todo — o ressuscita quando o batimento envelhece (checagem 1x/min
por worker, processo desgarrado, nunca custa um request).

Guarda de instancia unica: laco que encontra batimento fresco de outro
pid sai na hora, entao N workers ressuscitando juntos e inofensivo.
Deploy: mtime do comando mudou -> laco se encerra e renasce atualizado.
Painel mostra o batimento (vivo/pid/quando); supervisor vira redundancia
opcional (MATERIALIZACAO_AUTOSSUPERVISAO=False para quem gerir por fora).
Ponteiro do knowledge/ atualizado (ADR-0012).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pull/3858/head
kemuel 3 weeks ago
parent
commit
4ee9f34f82
  1. 2
      knowledge
  2. 82
      sapl/integracao_hub/management/commands/materializar_pdfs_para_assinatura.py
  3. 106
      sapl/integracao_hub/middleware.py
  4. 25
      sapl/integracao_hub/migrations/0007_batimento_laco.py
  5. 44
      sapl/integracao_hub/models.py
  6. 9
      sapl/integracao_hub/painel.py
  7. 22
      sapl/integracao_hub/templates/integracao_hub/painel_materializacao.html
  8. 161
      sapl/integracao_hub/tests/test_autossupervisao.py
  9. 10
      sapl/settings.py

2
knowledge

@ -1 +1 @@
Subproject commit 3ea2f52ec2f3484e5634dc98aeb5050230117f89 Subproject commit 665a13d886cbac5ba96c50cb217cffc55a7fcb81

82
sapl/integracao_hub/management/commands/materializar_pdfs_para_assinatura.py

@ -10,7 +10,8 @@ from django.core.management.base import BaseCommand, CommandError
from django.db import IntegrityError, transaction from django.db import IntegrityError, transaction
from django.utils import timezone from django.utils import timezone
from sapl.integracao_hub.models import (DocumentoParaAssinatura, from sapl.integracao_hub.models import (BatimentoLaco,
DocumentoParaAssinatura,
MateriaComFalhaMaterializacao, MateriaComFalhaMaterializacao,
MateriaParaMaterializar, MateriaParaMaterializar,
PassadaMaterializacao) PassadaMaterializacao)
@ -177,6 +178,31 @@ class Command(BaseCommand):
if intervalo <= 0: if intervalo <= 0:
self._passada_registrada(somente_novos, disparada_por) self._passada_registrada(somente_novos, disparada_por)
return return
# Instância única do LAÇO (ADR 0015): batimento fresco de outro
# processo = já existe laço vivo, este sai. O lock de passada impedia
# conversão dupla; isto impede dois laços residentes disputando — o
# cenário que a autossupervisão cria quando dois workers do gunicorn
# decidem ressuscitar o laço no mesmo segundo.
batimento = BatimentoLaco.objects.first()
if batimento and batimento.fresco and batimento.pid != os.getpid():
self.stdout.write(
'materializar_pdfs: ja existe laco vivo (pid %s, batimento '
'%s) — este processo sai' % (batimento.pid, batimento.visto_em))
return
self._batendo = True
self._tick = tick
self._iniciado_em = timezone.now()
# A versao do codigo em disco no momento da subida. Quando um deploy
# troca o arquivo, o laco RESIDENTE continuaria rodando o codigo velho
# para sempre — entao ele se encerra e deixa a autossupervisao (ou o
# supervisor) ressuscita-lo ja atualizado.
try:
self._versao_codigo = os.path.getmtime(__file__)
except OSError:
self._versao_codigo = None
self.stdout.write( self.stdout.write(
'materializar_pdfs: laco — varredura completa a cada %ss, fila de ' 'materializar_pdfs: laco — varredura completa a cada %ss, fila de '
'prioridade a cada %ss (Ctrl-C para sair)' % (intervalo, tick)) 'prioridade a cada %ss (Ctrl-C para sair)' % (intervalo, tick))
@ -185,6 +211,15 @@ class Command(BaseCommand):
# so um tick de folga. # so um tick de folga.
proxima_varredura = 0.0 proxima_varredura = 0.0
while True: while True:
self._bater()
if self._codigo_mudou_no_disco():
self.stdout.write(
'materializar_pdfs: codigo novo no disco — laco sai para '
'renascer atualizado (autossupervisao/supervisor sobem '
'outro)')
logger.info(
'materializar_pdfs: laco encerrado por deploy detectado')
return
try: try:
if time.monotonic() >= proxima_varredura: if time.monotonic() >= proxima_varredura:
try: try:
@ -202,6 +237,50 @@ class Command(BaseCommand):
logger.exception('materializar_pdfs: passada falhou: %s', exc) logger.exception('materializar_pdfs: passada falhou: %s', exc)
time.sleep(tick) time.sleep(tick)
# Estado do modo laço (ADR 0015). Fora do laço (passada manual, botão da
# tela, --fila) nada disso é tocado: `_batendo` False faz `_bater` ser um
# no-op e o batimento continua contando só a vida do laço residente.
_batendo = False
_tick = 15
_iniciado_em = None
_versao_codigo = None
_ultimo_batimento_monotonic = 0.0
def _bater(self):
"""Grava o batimento (singleton pk=1), no máximo a cada 10s.
Chamado a cada iteração do laço E a cada matéria da varredura: uma
varredura de uma hora sem batimento pareceria laço morto e faria a
autossupervisão subir um segundo processo.
"""
if not self._batendo:
return
agora = time.monotonic()
if (self._ultimo_batimento_monotonic
and agora - self._ultimo_batimento_monotonic < 10):
return
self._ultimo_batimento_monotonic = agora
try:
BatimentoLaco.objects.update_or_create(pk=1, defaults={
'visto_em': timezone.now(),
'iniciado_em': self._iniciado_em,
'pid': os.getpid(),
'tick_segundos': self._tick})
except Exception as exc: # noqa — batimento é sinal vital, não trabalho:
# falhar em gravá-lo não pode derrubar a materialização em si.
logger.warning('materializar_pdfs: falha ao gravar batimento: %s',
exc)
def _codigo_mudou_no_disco(self):
"""Deploy trocou o arquivo do comando? Então este laço está defasado."""
if self._versao_codigo is None:
return False
try:
return os.path.getmtime(__file__) != self._versao_codigo
except OSError:
# Arquivo sumiu do caminho = deploy em curso: melhor renascer.
return True
def _fechar_orfas(self): def _fechar_orfas(self):
"""Libera o lock de passada cujo processo morreu sem fechar a linha. """Libera o lock de passada cujo processo morreu sem fechar a linha.
@ -323,6 +402,7 @@ class Command(BaseCommand):
gerados = retificados = pulados = falhas = adiados = 0 gerados = retificados = pulados = falhas = adiados = 0
motivos = {} motivos = {}
for materia, marca in pares: for materia, marca in pares:
self._bater() # varredura longa não pode parecer laço morto
try: try:
resultado, motivo = self._materializar(materia, somente_novos) resultado, motivo = self._materializar(materia, somente_novos)
except Exception as exc: # noqa — uma matéria não trava as demais (§5.1) except Exception as exc: # noqa — uma matéria não trava as demais (§5.1)

106
sapl/integracao_hub/middleware.py

@ -0,0 +1,106 @@
"""Autossupervisão do laço de materialização (ADR 0015).
O problema que este módulo mata: o SAPL de produção roda fora de docker, e o
laço de materialização dependia de alguém LEMBRAR de subi-lo (nohup, supervisor,
cron todos exigem um ato manual que falhou uma vez, IND 826/2026). O único
processo cuja existência é garantida é o próprio web: o hub bate na API de
integração o tempo inteiro, e cada request é um marca-passo de graça.
Então: o laço grava um batimento no banco a cada tick (`BatimentoLaco`); este
middleware, no máximo uma vez por minuto por worker, confere o batimento e
se ele envelheceu ou nunca existiu ressuscita o laço como processo separado e
desgarrado (mesmo padrão do botão "Rodar agora" do painel: `start_new_session`,
processo sobrevive ao reload do worker).
Corrida entre N workers ressuscitando ao mesmo tempo: inofensiva por
construção o laço que sobe e encontra batimento fresco de outro pid SAI na
hora (guarda de instância única no próprio comando), e o lock de passada
impedia conversão dupla de qualquer forma.
Desligável por `MATERIALIZACAO_AUTOSSUPERVISAO=False` (ambiente onde o laço é
gerido por supervisor/systemd e não se quer o respawn automático).
"""
import logging
import os
import subprocess
import sys
import time
from django.conf import settings
from django.core.exceptions import MiddlewareNotUsed
logger = logging.getLogger(__name__)
# No máximo uma consulta ao batimento por minuto POR WORKER: o custo por
# request no caminho quente é uma comparação de monotonic.
INTERVALO_VERIFICACAO_SEGUNDOS = 60
_ultima_verificacao = 0.0
def laco_precisa_subir():
"""Batimento ausente ou envelhecido = não há laço vivo."""
from sapl.integracao_hub.models import BatimentoLaco
batimento = BatimentoLaco.objects.first()
return batimento is None or not batimento.fresco
def subir_laco():
"""Sobe o laço como processo separado e desgarrado (padrão do painel).
Intervalo/tick vêm das mesmas variáveis de ambiente do start.sh do
container uma configuração , seja quem for que suba o laço.
"""
from sapl.integracao_hub.painel import _raiz_do_projeto
intervalo = os.environ.get('MATERIALIZACAO_INTERVALO_SEGUNDOS', '300')
tick = os.environ.get('MATERIALIZACAO_TICK_SEGUNDOS', '15')
comando = [
sys.executable, '-m', 'django',
'materializar_pdfs_para_assinatura',
'--intervalo', intervalo,
'--tick', tick,
]
ambiente = os.environ.copy()
ambiente.setdefault('DJANGO_SETTINGS_MODULE', 'sapl.settings')
subprocess.Popen(
comando,
cwd=_raiz_do_projeto(),
env=ambiente,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True)
logger.warning(
'autossupervisao: laco de materializacao sem batimento — novo laco '
'disparado (intervalo=%ss, tick=%ss)', intervalo, tick)
class AutossupervisaoMaterializacao:
def __init__(self, get_response):
# Em teste, spawnar processo residente por efeito colateral de request
# seria vazamento; quem testa as peças chama as funções deste módulo.
if 'pytest' in sys.modules:
raise MiddlewareNotUsed('autossupervisao desligada sob pytest')
if not getattr(settings, 'MATERIALIZACAO_AUTOSSUPERVISAO', True):
raise MiddlewareNotUsed('MATERIALIZACAO_AUTOSSUPERVISAO=False')
self.get_response = get_response
def __call__(self, request):
self._garantir_laco()
return self.get_response(request)
def _garantir_laco(self):
global _ultima_verificacao
agora = time.monotonic()
if _ultima_verificacao and agora - _ultima_verificacao < \
INTERVALO_VERIFICACAO_SEGUNDOS:
return
_ultima_verificacao = agora
try:
if laco_precisa_subir():
subir_laco()
except Exception as exc: # noqa — a autossupervisão NUNCA pode custar
# um request: banco sem migração, tabela ausente, falha de spawn —
# tudo vira log e o SAPL segue servindo.
logger.warning('autossupervisao: verificacao falhou (%s) — '
'seguira tentando a cada %ss', exc,
INTERVALO_VERIFICACAO_SEGUNDOS)

25
sapl/integracao_hub/migrations/0007_batimento_laco.py

@ -0,0 +1,25 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('integracao_hub', '0006_fila_prioridade_materializacao'),
]
operations = [
migrations.CreateModel(
name='BatimentoLaco',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('visto_em', models.DateTimeField(verbose_name='Último batimento')),
('iniciado_em', models.DateTimeField(verbose_name='Laço iniciado em')),
('pid', models.IntegerField(verbose_name='PID do processo do laço')),
('tick_segundos', models.IntegerField(default=15, help_text='Gravado pelo próprio laço: é a referência para decidir se o batimento envelheceu.', verbose_name='Tick configurado (s)')),
],
options={
'verbose_name': 'Batimento do Laço de Materialização',
'verbose_name_plural': 'Batimentos do Laço de Materialização',
},
),
]

44
sapl/integracao_hub/models.py

@ -363,3 +363,47 @@ class MateriaParaMaterializar(models.Model):
def __str__(self): def __str__(self):
return 'Matéria %s na fila desde %s' % (self.materia_id, return 'Matéria %s na fila desde %s' % (self.materia_id,
self.marcada_em) self.marcada_em)
class BatimentoLaco(models.Model):
"""Batimento do laço de materialização — singleton (pk=1), ADR 0015.
O laço grava aqui a cada tick (e durante varreduras longas, a cada poucos
segundos). É o que permite a autossupervisão: o middleware do processo web
o único processo cuja existência é garantida, porque o hub bate na API o
tempo todo este batimento e, se ele envelhecer, ressuscita o laço.
Ninguém precisa lembrar de subir nada: nem supervisor, nem cron, nem Léo.
Também é o guarda de instância única: um laço que sobe e encontra
batimento fresco de OUTRO processo sai imediatamente o lock de passada
impedia conversão dupla; este impede dois laços vivos disputando.
"""
visto_em = models.DateTimeField(
verbose_name=_('Último batimento'))
iniciado_em = models.DateTimeField(
verbose_name=_('Laço iniciado em'))
pid = models.IntegerField(
verbose_name=_('PID do processo do laço'))
tick_segundos = models.IntegerField(
default=15,
verbose_name=_('Tick configurado (s)'),
help_text=_('Gravado pelo próprio laço: é a referência para decidir '
'se o batimento envelheceu.'))
class Meta:
verbose_name = _('Batimento do Laço de Materialização')
verbose_name_plural = _('Batimentos do Laço de Materialização')
def __str__(self):
return 'Laço pid %s, batimento %s' % (self.pid, self.visto_em)
@property
def fresco(self):
"""Vivo = bateu há menos de 3 ticks (piso de 60s para folga de carga)."""
from django.utils import timezone
janela = max(3 * (self.tick_segundos or 15), 60)
return (timezone.now() - self.visto_em).total_seconds() < janela

9
sapl/integracao_hub/painel.py

@ -41,8 +41,9 @@ from django.http import HttpResponseRedirect
from django.urls import reverse from django.urls import reverse
from django.views.generic import TemplateView, View from django.views.generic import TemplateView, View
from .models import (DocumentoParaAssinatura, MateriaComFalhaMaterializacao, from .models import (BatimentoLaco, DocumentoParaAssinatura,
MateriaParaMaterializar, PassadaMaterializacao) MateriaComFalhaMaterializacao, MateriaParaMaterializar,
PassadaMaterializacao)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -108,6 +109,10 @@ class PainelMaterializacaoView(PermissionRequiredMixin, TemplateView):
# Fila de prioridade (ADR 0014): matérias marcadas pelo evento # Fila de prioridade (ADR 0014): matérias marcadas pelo evento
# aguardando o tick. Cresce e não esvazia = o laço não está de pé. # aguardando o tick. Cresce e não esvazia = o laço não está de pé.
'na_fila': MateriaParaMaterializar.objects.count(), 'na_fila': MateriaParaMaterializar.objects.count(),
# Batimento do laço (ADR 0015): a resposta de "está rodando?" sem
# shell. Morto aqui + fila crescendo = autossupervisão também
# falhou (aí sim é caso de shell).
'batimento': BatimentoLaco.objects.first(),
# A configuração que decide o sucesso da conversão fica na tela # A configuração que decide o sucesso da conversão fica na tela
# porque foi exatamente ela a causa do incidente de 25/08 — e lê-la # porque foi exatamente ela a causa do incidente de 25/08 — e lê-la
# exigia shell. # exigia shell.

22
sapl/integracao_hub/templates/integracao_hub/painel_materializacao.html

@ -43,6 +43,28 @@
</div> </div>
{% endif %} {% endif %}
<p>
{% if batimento %}
{% if batimento.fresco %}
<span class="text-success">
<strong>Laço automático vivo</strong> (pid {{ batimento.pid }},
último batimento {{ batimento.visto_em }}).
</span>
{% else %}
<span class="text-danger">
<strong>Laço automático SEM batimento</strong> desde
{{ batimento.visto_em }} — a autossupervisão vai ressuscitá-lo no
próximo acesso ao sistema; se esta mensagem persistir, o respawn
está falhando (ver sapl.log).
</span>
{% endif %}
{% else %}
<span class="text-warning">
<strong>Laço automático nunca bateu</strong> — sobe sozinho no
primeiro acesso após o deploy desta versão.
</span>
{% endif %}
</p>
<p> <p>
<strong>{{ alvos_materializados }}</strong> documento(s) já materializado(s) <strong>{{ alvos_materializados }}</strong> documento(s) já materializado(s)
e disponível(is) para assinatura. e disponível(is) para assinatura.

161
sapl/integracao_hub/tests/test_autossupervisao.py

@ -0,0 +1,161 @@
"""Autossupervisão do laço de materialização (ADR 0015).
O contrato: o laço bate no banco a cada tick; o processo web ressuscita o laço
quando o batimento envelhece; dois laços nunca residem juntos; e um deploy faz
o laço se encerrar para renascer com o código novo.
"""
import os
import pytest
from django.core.management import call_command
from django.utils import timezone
from sapl.integracao_hub.middleware import laco_precisa_subir, subir_laco
from sapl.integracao_hub.models import BatimentoLaco, PassadaMaterializacao
CMD = ('sapl.integracao_hub.management.commands'
'.materializar_pdfs_para_assinatura')
@pytest.fixture(autouse=True)
def base_url_configurada(settings):
settings.SAPL_INTERNAL_URL = 'http://sapl-interno:8000'
settings.SITE_URL = ''
def batimento(segundos_atras=0, pid=None):
agora = timezone.now()
return BatimentoLaco.objects.create(
pk=1,
visto_em=agora - timezone.timedelta(seconds=segundos_atras),
iniciado_em=agora,
pid=pid if pid is not None else os.getpid() + 1,
tick_segundos=15)
# ---------------------------------------------------------------------------
# O laço bate — inclusive é este batimento que o painel exibe
# ---------------------------------------------------------------------------
@pytest.mark.django_db(transaction=False)
def test_modo_laco_grava_batimento(db, monkeypatch):
def sleep_que_interrompe(segundos):
raise KeyboardInterrupt
monkeypatch.setattr(CMD + '.time.sleep', sleep_que_interrompe)
with pytest.raises(KeyboardInterrupt):
call_command('materializar_pdfs_para_assinatura', intervalo=30)
vivo = BatimentoLaco.objects.get(pk=1)
assert vivo.pid == os.getpid()
assert vivo.fresco
assert vivo.tick_segundos == 15
@pytest.mark.django_db(transaction=False)
def test_passada_avulsa_nao_bate(db):
"""O batimento mede a vida do LAÇO; passada manual não é laço."""
call_command('materializar_pdfs_para_assinatura')
assert not BatimentoLaco.objects.exists()
# ---------------------------------------------------------------------------
# Instância única: dois laços nunca residem juntos
# ---------------------------------------------------------------------------
@pytest.mark.django_db(transaction=False)
def test_segundo_laco_sai_quando_ha_batimento_fresco_de_outro_pid(db):
batimento(segundos_atras=5)
# Sem monkeypatch de sleep: se o guarda falhar, o laço entraria em loop
# infinito — o retorno imediato É a asserção.
call_command('materializar_pdfs_para_assinatura', intervalo=30)
assert not PassadaMaterializacao.objects.exists()
@pytest.mark.django_db(transaction=False)
def test_laco_assume_quando_batimento_esta_velho(db, monkeypatch):
batimento(segundos_atras=600)
def sleep_que_interrompe(segundos):
raise KeyboardInterrupt
monkeypatch.setattr(CMD + '.time.sleep', sleep_que_interrompe)
with pytest.raises(KeyboardInterrupt):
call_command('materializar_pdfs_para_assinatura', intervalo=30)
assert BatimentoLaco.objects.get(pk=1).pid == os.getpid()
# ---------------------------------------------------------------------------
# Deploy: laço defasado se encerra para renascer com o código novo
# ---------------------------------------------------------------------------
@pytest.mark.django_db(transaction=False)
def test_laco_sai_quando_o_codigo_muda_no_disco(db, monkeypatch):
getmtime_real = os.path.getmtime
contagem = {'n': 0}
def getmtime_que_avanca(caminho):
# Só o arquivo do comando "muda de versão"; o resto do mundo segue
# normal — getmtime é global e outros códigos o usam no meio do teste.
if str(caminho).endswith('materializar_pdfs_para_assinatura.py'):
contagem['n'] += 1
return float(contagem['n'])
return getmtime_real(caminho)
monkeypatch.setattr(CMD + '.os.path.getmtime', getmtime_que_avanca)
# Retorna sozinho (sem KeyboardInterrupt): a saída limpa É a asserção.
call_command('materializar_pdfs_para_assinatura', intervalo=30)
# ---------------------------------------------------------------------------
# O lado do processo web: decidir e ressuscitar
# ---------------------------------------------------------------------------
@pytest.mark.django_db(transaction=False)
def test_precisa_subir_sem_batimento_ou_com_batimento_velho(db):
assert laco_precisa_subir() # nunca bateu
b = batimento(segundos_atras=600)
assert laco_precisa_subir() # bateu ha 10min com tick de 15s
b.visto_em = timezone.now()
b.save()
assert not laco_precisa_subir() # fresco
@pytest.mark.django_db(transaction=False)
def test_subir_laco_dispara_processo_desgarrado(db, monkeypatch):
chamadas = []
def popen_falso(comando, **kwargs):
chamadas.append((comando, kwargs))
monkeypatch.setattr(
'sapl.integracao_hub.middleware.subprocess.Popen', popen_falso)
monkeypatch.setenv('MATERIALIZACAO_INTERVALO_SEGUNDOS', '120')
monkeypatch.setenv('MATERIALIZACAO_TICK_SEGUNDOS', '5')
subir_laco()
comando, kwargs = chamadas[0]
assert 'materializar_pdfs_para_assinatura' in comando
assert comando[comando.index('--intervalo') + 1] == '120'
assert comando[comando.index('--tick') + 1] == '5'
# Desgarrado do worker: sobrevive ao reload do gunicorn.
assert kwargs['start_new_session'] is True
def test_middleware_nao_liga_sob_pytest():
"""Spawnar laço residente por efeito de request de teste seria vazamento."""
from django.core.exceptions import MiddlewareNotUsed
from sapl.integracao_hub.middleware import AutossupervisaoMaterializacao
with pytest.raises(MiddlewareNotUsed):
AutossupervisaoMaterializacao(lambda request: None)

10
sapl/settings.py

@ -156,6 +156,10 @@ MIDDLEWARE = [
'waffle.middleware.WaffleMiddleware', 'waffle.middleware.WaffleMiddleware',
'sapl.middleware.CheckWeakPasswordMiddleware', 'sapl.middleware.CheckWeakPasswordMiddleware',
'django_prometheus.middleware.PrometheusAfterMiddleware', 'django_prometheus.middleware.PrometheusAfterMiddleware',
# Autossupervisão da materialização (ADR 0015): cada request confere (no
# máximo 1x/min) se o laço do PDF-alvo está vivo e o ressuscita se não —
# o SAPL de produção roda fora de docker e nada mais garante esse processo.
'sapl.integracao_hub.middleware.AutossupervisaoMaterializacao',
] ]
if DEBUG and 'runserver' in sys.argv: if DEBUG and 'runserver' in sys.argv:
INSTALLED_APPS += ('debug_toolbar',) INSTALLED_APPS += ('debug_toolbar',)
@ -165,6 +169,12 @@ if DEBUG and 'runserver' in sys.argv:
SITE_URL = config('SITE_URL', cast=str, default='') SITE_URL = config('SITE_URL', cast=str, default='')
# Desligue APENAS onde o laço de materialização é gerido por fora (supervisor/
# systemd) e o respawn automático atrapalharia. O padrão ligado é o que
# garante a materialização sem depender de memória de operador (ADR 0015).
MATERIALIZACAO_AUTOSSUPERVISAO = config(
'MATERIALIZACAO_AUTOSSUPERVISAO', cast=bool, default=True)
REST_FRAMEWORK = { REST_FRAMEWORK = {
"UNICODE_JSON": False, "UNICODE_JSON": False,
"DEFAULT_PARSER_CLASSES": ( "DEFAULT_PARSER_CLASSES": (

Loading…
Cancel
Save