diff --git a/docker/Dockerfile b/docker/Dockerfile
index 31e8f16..6a52141 100644
--- a/docker/Dockerfile
+++ b/docker/Dockerfile
@@ -93,12 +93,7 @@ RUN ln -s ${HOME}/etc/nginx/sites-available/sigi.vhost /etc/nginx/sites-enabled/
RUN mkdir -p /var/log/sigi
# schedule cron jobs
-RUN crontab -l | { cat; echo "* * * * * /usr/local/bin/python ${HOME}/manage.py runjobs minutely >> /var/log/sigi/cron.log 2>&1"; } | crontab -
-RUN crontab -l | { cat; echo "0 * * * * /usr/local/bin/python ${HOME}/manage.py runjobs hourly >> /var/log/sigi/cron.log 2>&1"; } | crontab -
-RUN crontab -l | { cat; echo "0 0 * * * /usr/local/bin/python ${HOME}/manage.py runjobs daily >> /var/log/sigi/cron.log 2>&1"; } | crontab -
-RUN crontab -l | { cat; echo "0 0 * * 0 /usr/local/bin/python ${HOME}/manage.py runjobs weekly >> /var/log/sigi/cron.log 2>&1"; } | crontab -
-RUN crontab -l | { cat; echo "0 0 1 * * /usr/local/bin/python ${HOME}/manage.py runjobs monthly >> /var/log/sigi/cron.log 2>&1"; } | crontab -
-RUN crontab -l | { cat; echo "0 4 * * * /usr/local/bin/python ${HOME}/manage.py runjob sincroniza_saberes >> /var/log/sigi/cron.log 2>&1"; } | crontab -
+RUN crontab -l | { cat; echo "* * * * * /usr/local/bin/python ${HOME}/manage.py runjob job_controller >> /var/log/sigi/cron.log 2>&1"; } | crontab -
EXPOSE 80/tcp 443/tcp
ENV DEBIAN_FRONTEND=teletype
diff --git a/requirements/requirements.txt b/requirements/requirements.txt
index b29a62e..022fd2c 100644
--- a/requirements/requirements.txt
+++ b/requirements/requirements.txt
@@ -1,3 +1,4 @@
+cron-converter==1.0.2
dnspython==2.3.0
docutils==0.20.1
gunicorn==20.1.0
diff --git a/sigi/apps/utils/admin.py b/sigi/apps/utils/admin.py
index ac8a685..0dcbf20 100644
--- a/sigi/apps/utils/admin.py
+++ b/sigi/apps/utils/admin.py
@@ -1,7 +1,44 @@
-from django.contrib import admin
-from sigi.apps.utils.models import SigiAlert
+from django.contrib import admin, messages
+from django.core.exceptions import PermissionDenied
+from django.http import HttpRequest
+from django.shortcuts import get_object_or_404, redirect
+from django.urls import reverse, path
+from django.utils import timezone
+from django.utils.formats import localize
+from django.utils.safestring import mark_safe
+from django.utils.translation import gettext as _
+from django_extensions.management.jobs import get_job, get_jobs
from tinymce.models import HTMLField
from tinymce.widgets import AdminTinyMCE
+from sigi.apps.utils.models import SigiAlert, Cronjob, JobSchedule
+
+
+class JobScheduleInline(admin.TabularInline):
+ model = JobSchedule
+ fields = ["status", "iniciar", "iniciado", "tempo_gasto", "get_runner"]
+ readonly_fields = [
+ "status",
+ "iniciar",
+ "iniciado",
+ "tempo_gasto",
+ "get_runner",
+ ]
+ can_delete = False
+ can_add = False
+ extra = 0
+
+ def has_add_permission(self, request, obj):
+ return False
+
+ @mark_safe
+ @admin.display(description=_("executar"))
+ def get_runner(self, sched):
+ if sched.status == JobSchedule.STATUS_AGENDADO:
+ url = reverse("admin:utils_jobschedule_runjob", args=[sched.id])
+ return (
+ f"play_arrow"
+ )
+ return ""
@admin.register(SigiAlert)
@@ -10,3 +47,161 @@ class SigiAlertAdmin(admin.ModelAdmin):
search_fields = ("titulo", "caminho")
formfield_overrides = {HTMLField: {"widget": AdminTinyMCE}}
list_filter = ("destinatarios",)
+
+
+@admin.register(Cronjob)
+class CronjobAdmin(admin.ModelAdmin):
+ list_display = (
+ "job_name",
+ "app_name",
+ "get_help",
+ "expressao_cron",
+ "get_schedule",
+ "get_runner",
+ )
+ fields = ["job_name", "app_name", "get_help", "expressao_cron"]
+ readonly_fields = ("job_name", "app_name", "get_help")
+ inlines = [JobScheduleInline]
+
+ def get_urls(self):
+ urls = super().get_urls()
+ model_info = (self.model._meta.app_label, self.model._meta.model_name)
+
+ my_urls = [
+ path(
+ "/runjob/",
+ self.admin_site.admin_view(self.run_job),
+ name="%s_%s_runjob" % model_info,
+ ),
+ ]
+ return my_urls + urls
+
+ @admin.display(description=_("descrição"))
+ def get_help(self, job):
+ try:
+ JobClass = get_job(job.app_name, job.job_name)
+ except KeyError:
+ return _(
+ f"A rotina de JOB {job.app_name}.{job.job_name} "
+ "não foi encontrada."
+ )
+ job_obj = JobClass()
+ return job_obj.help
+
+ @admin.display(description=_("agenda"))
+ def get_schedule(self, job):
+ sched = job.jobschedule_set.first()
+ if sched is None:
+ return _("Nenhum agendamento para este job")
+ if sched.status == JobSchedule.STATUS_AGENDADO:
+ return _(
+ "início agendado para "
+ f"{localize(timezone.localtime(sched.iniciar))}."
+ )
+ if sched.status == JobSchedule.STATUS_EXECUTANDO:
+ return _(
+ "em execução desde "
+ f"{localize(timezone.localtime(sched.iniciado))}"
+ )
+ return _(
+ f"executado em {localize(timezone.localtime(sched.iniciado))}, "
+ f"levando {sched.tempo_gasto} minutos para concluir"
+ )
+
+ @mark_safe
+ @admin.display(description=_("executar"))
+ def get_runner(self, job):
+ url = reverse("admin:utils_cronjob_runjob", args=[job.id])
+ return (
+ f"" "play_arrow"
+ )
+
+ def run_job(self, request, object_id):
+ cronjob = get_object_or_404(Cronjob, id=object_id)
+ sched = cronjob.next_schedule()
+ if sched.status != JobSchedule.STATUS_AGENDADO:
+ raise PermissionDenied(
+ _(
+ "Este agendamento não pode ser executado pois "
+ f"está com status {sched.get_status_display()}"
+ )
+ )
+ sched.run_job()
+ self.message_user(
+ request,
+ _("JOB executado!"),
+ messages.SUCCESS,
+ )
+ return redirect("admin:utils_jobschedule_change", object_id=sched.id)
+
+
+@admin.register(JobSchedule)
+class JobScheduleAdmin(admin.ModelAdmin):
+ list_display = [
+ "job",
+ "status",
+ "iniciar",
+ "iniciado",
+ "tempo_gasto",
+ "get_runner",
+ ]
+ fields = [
+ "job",
+ "status",
+ "iniciar",
+ "iniciado",
+ "tempo_gasto",
+ ]
+ readonly_fields = fields
+ list_filter = ("status", "job")
+ date_hierarchy = "iniciar"
+
+ def get_urls(self):
+ urls = super().get_urls()
+ model_info = (self.model._meta.app_label, self.model._meta.model_name)
+
+ my_urls = [
+ path(
+ "/runjob/",
+ self.admin_site.admin_view(self.run_job),
+ name="%s_%s_runjob" % model_info,
+ ),
+ ]
+ return my_urls + urls
+
+ def has_add_permission(self, request):
+ return False
+
+ def has_delete_permission(self, request, obj=None):
+ return False
+
+ def has_change_permission(self, request, obj=None):
+ return False
+
+ @mark_safe
+ @admin.display(description=_("executar"))
+ def get_runner(self, sched):
+ if sched.status == JobSchedule.STATUS_AGENDADO:
+ url = reverse("admin:utils_jobschedule_runjob", args=[sched.id])
+ return (
+ f""
+ "play_arrow"
+ )
+ return ""
+
+ def run_job(self, request, object_id):
+ sched = get_object_or_404(JobSchedule, id=object_id)
+ if sched.status != JobSchedule.STATUS_AGENDADO:
+ raise PermissionDenied(
+ _(
+ "Este agendamento não pode ser executado pois "
+ f"está com status {sched.get_status_display()}"
+ )
+ )
+ sched.run_job()
+ self.message_user(
+ request,
+ _("JOB executado!"),
+ messages.SUCCESS,
+ )
+ return redirect("admin:utils_jobschedule_change", object_id=object_id)
diff --git a/sigi/apps/utils/jobs/__init__.py b/sigi/apps/utils/jobs/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/sigi/apps/utils/jobs/daily/__init__.py b/sigi/apps/utils/jobs/daily/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/sigi/apps/utils/jobs/hourly/__init__.py b/sigi/apps/utils/jobs/hourly/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/sigi/apps/utils/jobs/job_controller.py b/sigi/apps/utils/jobs/job_controller.py
new file mode 100644
index 0000000..a53c500
--- /dev/null
+++ b/sigi/apps/utils/jobs/job_controller.py
@@ -0,0 +1,90 @@
+from django_extensions.management.jobs import BaseJob
+from django_extensions.management.jobs import get_jobs
+from django.utils import timezone
+from django.utils.formats import localize
+from django.utils.translation import gettext as _
+from sigi.apps.utils.models import Cronjob, JobSchedule
+
+WHEN_SETS = {
+ "daily": "0 0 * * *",
+ "hourly": "0 * * * *",
+ "monthly": "0 0 1 * *",
+ "weekly": "0 0 * * 0",
+ "yearly": "0 0 1 1 *",
+ "minutely": "* * * * *",
+}
+
+
+class Job(BaseJob):
+ help = "Controlador de cronjobs do SIGI."
+
+ def execute(self):
+ print("Rodando controlador de jobs...")
+ self.remove_old_jobs()
+ self.sync_new_jobs()
+ self.run_scheduled()
+ self.schedule_jobs()
+
+ def remove_old_jobs(self):
+ """Remover das tabelas os jobs que foram removidos do código"""
+ print("\tRemover das tabelas os jobs que foram removidos do código...")
+ all_jobs = get_jobs()
+ excludes = Cronjob.objects.all()
+ for app_name, job_name in all_jobs.keys():
+ excludes = excludes.exclude(app_name=app_name, job_name=job_name)
+ print("\t\t", excludes.delete())
+
+ def sync_new_jobs(self):
+ """
+ Atualizar a tabela de JOBS com os novos JOBS que tenham sido criados
+ """
+ print(
+ "\tAtualizar a tabela de JOBS com os novos JOBS que tenham "
+ "sido criados..."
+ )
+ all_jobs = get_jobs()
+ for (app_name, job_name), JobClass in all_jobs.items():
+ if app_name == "sigi.apps.utils" and job_name == "job_controller":
+ # Ignorar job_controller
+ continue
+ try:
+ job = Cronjob.objects.get(app_name=app_name, job_name=job_name)
+ except Cronjob.DoesNotExist:
+ # Inserir o JOB na tabela de JOBS #
+ job_obj = JobClass()
+ if job_obj.when in WHEN_SETS:
+ expressao_cron = WHEN_SETS[job_obj.when]
+ else:
+ expressao_cron = WHEN_SETS["daily"] # Default
+ job = Cronjob(
+ app_name=app_name,
+ job_name=job_name,
+ expressao_cron=expressao_cron,
+ )
+ job.save()
+ print(f"\t\tNovo job encontrado: {job_name}: {job_obj.help}")
+
+ def run_scheduled(self):
+ """Executa os jobs que estão agendados"""
+ print("\tExecutar os jobs que estão agendados...")
+ for sched in JobSchedule.objects.filter(
+ status=JobSchedule.STATUS_AGENDADO
+ ):
+ agora = timezone.localtime()
+ if sched.iniciar <= agora:
+ sched.run_job()
+
+ def schedule_jobs(self):
+ """Criar agenda para próxima execução"""
+ print("\tCriar agenda para próxima execução...")
+ for job in Cronjob.objects.exclude(
+ jobschedule__status__in=[
+ JobSchedule.STATUS_AGENDADO,
+ JobSchedule.STATUS_EXECUTANDO,
+ ]
+ ):
+ sched = job.next_schedule()
+ print(
+ f"\t\tAgendado job {sched.job.job_name} "
+ f"para {localize(sched.iniciar)}"
+ )
diff --git a/sigi/apps/utils/jobs/monthly/__init__.py b/sigi/apps/utils/jobs/monthly/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/sigi/apps/utils/jobs/weekly/__init__.py b/sigi/apps/utils/jobs/weekly/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/sigi/apps/utils/jobs/yearly/__init__.py b/sigi/apps/utils/jobs/yearly/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/sigi/apps/utils/migrations/0002_cronjob_jobschedule.py b/sigi/apps/utils/migrations/0002_cronjob_jobschedule.py
new file mode 100644
index 0000000..1c45a0e
--- /dev/null
+++ b/sigi/apps/utils/migrations/0002_cronjob_jobschedule.py
@@ -0,0 +1,45 @@
+# Generated by Django 4.2.7 on 2024-02-26 12:53
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('utils', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Cronjob',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('app_name', models.CharField(editable=False, max_length=100, verbose_name='app')),
+ ('job_name', models.CharField(editable=False, max_length=100, verbose_name='job')),
+ ('expressao_cron', models.CharField(default='* * * * *', help_text="Usar expressoões no formato padrão de CRON: 'minute hour day month day-of-week'. Mais detalhes: CronHowTo", max_length=100, verbose_name='expressão CRON')),
+ ],
+ options={
+ 'verbose_name': 'Cron job',
+ 'verbose_name_plural': 'Cron jobs',
+ 'ordering': ('app_name', 'job_name'),
+ },
+ ),
+ migrations.CreateModel(
+ name='JobSchedule',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('iniciar', models.DateTimeField(verbose_name='Iniciar em')),
+ ('iniciado', models.DateTimeField(blank=True, null=True, verbose_name='Iniciado em')),
+ ('status', models.CharField(choices=[('A', 'Agendado'), ('E', 'Executando'), ('C', 'Concluído')], default='A', max_length=1, verbose_name='estado')),
+ ('tempo_gasto', models.DurationField(blank=True, editable=False, null=True, verbose_name='tempo gasto')),
+ ('resultado', models.TextField(blank=True, editable=False, verbose_name='resultado da execução')),
+ ('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='utils.cronjob', verbose_name='Cron job')),
+ ],
+ options={
+ 'verbose_name': 'Agenda de execução',
+ 'verbose_name_plural': 'Agenda de execuções',
+ 'ordering': ('iniciar',),
+ },
+ ),
+ ]
diff --git a/sigi/apps/utils/models.py b/sigi/apps/utils/models.py
index 2858e3f..46c4af4 100644
--- a/sigi/apps/utils/models.py
+++ b/sigi/apps/utils/models.py
@@ -1,8 +1,15 @@
+import io
+from contextlib import redirect_stderr, redirect_stdout
+from cron_converter import Cron
from pyexpat import model
from django.db import models
from django.contrib.auth.models import Group
+from django.utils import timezone
+from django.utils.formats import localize
from django.utils.translation import gettext as _
+from django_extensions.management.jobs import get_job, get_jobs
from tinymce.models import HTMLField
+from sigi.apps.utils.management.jobs import JobReportMixin
class SigiAlert(models.Model):
@@ -26,3 +33,152 @@ class SigiAlert(models.Model):
def __str__(self):
return self.titulo
+
+
+class Cronjob(models.Model):
+ app_name = models.CharField(_("app"), max_length=100, editable=False)
+ job_name = models.CharField(_("job"), max_length=100, editable=False)
+ expressao_cron = models.CharField(
+ _("expressão CRON"),
+ max_length=100,
+ default="* * * * *",
+ help_text=_(
+ "Usar expressoões no formato padrão de CRON: "
+ "'minute hour day month day-of-week'. "
+ "Mais detalhes: "
+ ""
+ "CronHowTo"
+ ""
+ ),
+ )
+
+ class Meta:
+ ordering = ("app_name", "job_name")
+ verbose_name = _("Cron job")
+ verbose_name_plural = _("Cron jobs")
+
+ def __str__(self):
+ return self.job_name
+
+ def run(self):
+ try:
+ JobClass = get_job(self.app_name, self.job_name)
+ except KeyError:
+ return (
+ f"A rotina de JOB {self.job.job_name} do app "
+ f"{self.job.app_name} não foi encontrada."
+ )
+ try:
+ job_obj = JobClass()
+ with io.StringIO() as so_buf, io.StringIO() as se_buf, redirect_stdout(
+ so_buf
+ ), redirect_stderr(
+ se_buf
+ ):
+ job_obj.execute()
+ messages = so_buf.getvalue()
+ errors = se_buf.getvalue()
+ report_data = ["", "MENSAGENS", "---------", ""]
+ if messages:
+ report_data.extend(messages.splitlines())
+ else:
+ report_data.extend(["Nenhuma mensagem gerada", ""])
+ report_data.extend(["", "ERROS", "-----", ""])
+ if errors:
+ report_data.extend(errors.splitlines())
+ else:
+ report_data.extend(["Nenhum erro gerado", ""])
+ return "\n".join(report_data)
+ except Exception as e:
+ # Qualquer erro deve ser reportado
+ return _(f"JOB abortado com erro: {str(e)}")
+
+ def next_schedule(self):
+ """Recupera a agenda da próxima execução. Se não existe, cria."""
+ try:
+ sch = self.jobschedule_set.get(
+ status__in=[
+ JobSchedule.STATUS_AGENDADO,
+ JobSchedule.STATUS_EXECUTANDO,
+ ]
+ )
+ except JobSchedule.DoesNotExist:
+ iniciar = self.get_next_schedule_time()
+ sch = JobSchedule(job=self, iniciar=iniciar)
+ sch.save()
+ return sch
+
+ def get_next_schedule_time(self):
+ cron_instance = Cron(self.expressao_cron)
+ scheduller = cron_instance.schedule(timezone.localtime())
+ return scheduller.next()
+
+
+class JobSchedule(models.Model):
+ STATUS_AGENDADO = "A"
+ STATUS_EXECUTANDO = "E"
+ STATUS_CONCLUIDO = "C"
+ STATUS_CHOICES = (
+ (STATUS_AGENDADO, _("Agendado")),
+ (STATUS_EXECUTANDO, _("Executando")),
+ (STATUS_CONCLUIDO, _("Concluído")),
+ )
+ job = models.ForeignKey(
+ Cronjob, verbose_name=_("Cron job"), on_delete=models.CASCADE
+ )
+ iniciar = models.DateTimeField(_("Iniciar em"))
+ iniciado = models.DateTimeField(_("Iniciado em"), blank=True, null=True)
+ status = models.CharField(
+ _("estado"),
+ max_length=1,
+ choices=STATUS_CHOICES,
+ default=STATUS_AGENDADO,
+ )
+ tempo_gasto = models.DurationField(
+ _("tempo gasto"), blank=True, null=True, editable=False
+ )
+ resultado = models.TextField(
+ _("resultado da execução"), blank=True, editable=False
+ )
+
+ class Meta:
+ ordering = ("iniciar",)
+ verbose_name = _("Agenda de execução")
+ verbose_name_plural = _("Agenda de execuções")
+
+ class DoesNotExecute(Exception):
+ """This scheduled job cannot be executed because not in AGENDADO state"""
+
+ pass
+
+ def __str__(self):
+ if self.status == JobSchedule.STATUS_AGENDADO:
+ return _(
+ f"{self.job.job_name}: início agendado para "
+ f"{localize(timezone.localtime(self.iniciar))}."
+ )
+ elif self.status == JobSchedule.STATUS_EXECUTANDO:
+ return _(
+ f"{self.job.job_name}: em execução desde "
+ f"{localize(timezone.localtime(self.iniciado))}"
+ )
+ return _(
+ f"{self.job.job_name}: executado em "
+ f"{localize(timezone.localtime(self.iniciado))}, "
+ f"levando {self.tempo_gasto} para concluir"
+ )
+
+ def run_job(self):
+ """Executa o job agendado. Esta rotina não verifica se a agenda está
+ na hora certa, apenas executa o job associado."""
+
+ if self.status != JobSchedule.STATUS_AGENDADO:
+ raise JobSchedule.DoesNotExecute()
+
+ self.iniciado = timezone.localtime()
+ self.status = JobSchedule.STATUS_EXECUTANDO
+ self.save()
+ self.resultado = self.job.run()
+ self.status = JobSchedule.STATUS_CONCLUIDO
+ self.tempo_gasto = timezone.localtime() - self.iniciado
+ self.save()
diff --git a/sigi/apps/utils/templates/admin/utils/jobschedule/change_form.html b/sigi/apps/utils/templates/admin/utils/jobschedule/change_form.html
new file mode 100644
index 0000000..cb6c0d7
--- /dev/null
+++ b/sigi/apps/utils/templates/admin/utils/jobschedule/change_form.html
@@ -0,0 +1,33 @@
+{% extends "admin/change_form.html" %}
+{% load static i18n %}
+
+{% block extrastyle %}
+{{ block.super }}
+
+{% endblock %}
+
+{% block after_field_sets %}
+
+{% endblock %}
\ No newline at end of file
diff --git a/sigi/apps/utils/views.py b/sigi/apps/utils/views.py
index dc279b9..0e4d6cb 100644
--- a/sigi/apps/utils/views.py
+++ b/sigi/apps/utils/views.py
@@ -1,9 +1,14 @@
+import docutils.core
+import io
+from contextlib import redirect_stdout, redirect_stderr
from parsel import Selector
from django.contrib.auth.decorators import user_passes_test
from django.contrib.auth.decorators import login_required
-from django.shortcuts import render
+from django.shortcuts import render, get_object_or_404
+from django.template.loader import render_to_string
from django.utils import timezone
from django_extensions.management.jobs import get_job
+from sigi.apps.utils.models import JobSchedule
@login_required
@@ -11,9 +16,46 @@ from django_extensions.management.jobs import get_job
def user_run_job(request, job_name):
job = get_job(None, job_name)()
start_time = timezone.localtime()
- job.do_job()
- end_time = timezone.localtime()
- rst, html = job.prepare_report(start_time, end_time)
+ if hasattr(job, "do_job"):
+ job.do_job()
+ end_time = timezone.localtime()
+ rst, html = job.prepare_report(start_time, end_time)
+ else:
+ with io.StringIO() as so_buf, io.StringIO() as se_buf, redirect_stdout(
+ so_buf
+ ), redirect_stderr(se_buf):
+ job.execute()
+ messages = so_buf.getvalue()
+ errors = se_buf.getvalue()
+ report_data = ["", "MENSAGENS", "---------", ""]
+ if messages:
+ report_data.extend(messages.splitlines())
+ else:
+ report_data.extend(["Nenhuma mensagem gerada", ""])
+ report_data.extend(["", "ERROS", "-----", ""])
+ if errors:
+ report_data.extend(errors.splitlines())
+ else:
+ report_data.extend(["Nenhum erro gerado", ""])
+ end_time = timezone.localtime()
+ rst = render_to_string(
+ "emails/base_report.rst",
+ {
+ "title": job.help,
+ "start_time": start_time,
+ "end_time": end_time,
+ "report_data": report_data,
+ },
+ )
+ html = docutils.core.publish_string(
+ rst,
+ writer_name="html5",
+ settings_overrides={
+ "input_encoding": "unicode",
+ "output_encoding": "unicode",
+ },
+ )
+
dp = Selector(text=html)
return render(
diff --git a/sigi/menu_conf.yaml b/sigi/menu_conf.yaml
index 19b31d1..4238cf4 100644
--- a/sigi/menu_conf.yaml
+++ b/sigi/menu_conf.yaml
@@ -18,6 +18,11 @@ admin_menu:
- title: Encerra inscrições de eventos
view_name: utils_runjob
view_param: encerra_inscricao
+ - title: Jobs de cron
+ view_name: admin:utils_cronjob_changelist
+ - title: Jobs agendados
+ view_name: admin_utils_jobschedule_changelist
+ querystr: status__exact=A
main_menu:
- title: Municípios