Skip to content

Commit

Permalink
Add view PrometheusExportView (#78)
Browse files Browse the repository at this point in the history
  • Loading branch information
duchenean authored Jan 27, 2025
1 parent 14a575f commit 3996ed0
Show file tree
Hide file tree
Showing 7 changed files with 141 additions and 22 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/pull_request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ jobs:
PYTHON_VERSION: ${{ matrix.python-version }}
CACHE_KEY: ${{ runner.os }}-build-${{ env.cache-name }}-${{ matrix.python-version }}
TEST_COMMAND: coverage run bin/test
REQUIREMENTS_FILE: 'requirements-tests.txt'
INSTALL_DEPENDENCIES_COMMANDS: 'pip install -r requirements-tests.txt'
MATTERMOST_WEBHOOK_URL: ${{ secrets.DELIB_MATTERMOST_WEBHOOK_URL }}
BUILDOUT_CONFIG_FILE: 'test-${{ matrix.plone-version }}.cfg'
- name: Report
Expand Down
8 changes: 6 additions & 2 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,12 @@ Changelog
2.0.6 (unreleased)
------------------

- Nothing changed yet.

- DELIBE-186: Add a new Prometheus export view `prometheus-export` to monitor cron.
[aduchene]
- Complete tests about `MeetingAgendaAPIView`.
[aduchene]
- Rename `institution_locations` view name to `institution-locations`.
[aduchene]

2.0.5 (2024-12-19)
------------------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ const InstitutionsMap = (props) => {
.get(get_bundle_url() + "/assets/wallonia-boundaries.json")
.then((response) => setRegionBoundaries(response.data));
axios
.get(get_portal_url() + "/@@institution_locations")
.get(get_portal_url() + "/@@institution-locations")
.then((response) => setInstitutionLocations(response.data));
}, []);

Expand Down
2 changes: 1 addition & 1 deletion src/plonemeeting/portal/core/browser/static/js/core.js

Large diffs are not rendered by default.

9 changes: 8 additions & 1 deletion src/plonemeeting/portal/core/rest/configure.zcml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

<!-- Views -->
<browser:page
name="institution_locations"
name="institution-locations"
for="Products.CMFPlone.interfaces.IPloneSiteRoot"
class=".site.InstitutionLocationsAPIView"
permission="zope2.View"
Expand All @@ -21,6 +21,13 @@
layer="plonemeeting.portal.core.interfaces.IPlonemeetingPortalCoreLayer"
/>

<browser:page
name="prometheus-export"
for="Products.CMFPlone.interfaces.IPloneSiteRoot"
class=".site.PrometheusExportView"
permission="zope2.View"
layer="plonemeeting.portal.core.interfaces.IPlonemeetingPortalCoreLayer"
/>

<!-- Serializers -->
<adapter factory=".serializers.InstitutionSerializerToJson"/>
Expand Down
60 changes: 60 additions & 0 deletions src/plonemeeting/portal/core/rest/site.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
# -*- coding: utf-8 -*-
import os

from plone import api
from plone.memoize import ram
from plone.protect.interfaces import IDisableCSRFProtection
Expand All @@ -18,6 +20,7 @@

import json
import requests
from datetime import datetime, timedelta


class InstitutionLocationsAPIView(PublicAPIView):
Expand Down Expand Up @@ -82,3 +85,60 @@ def get_institution_locations(self):

self.request.response.setHeader("Content-type", "application/json")
return json.dumps(institution_locations)

class PrometheusExportView(PublicAPIView):
"""Publications Prometheus exporter"""

def __call__(self):
return self.publications_stats()

def publications_stats(self):
"""
Get publications stats in Prometheus format
"""
catalog = api.portal.get_tool(name="portal_catalog")
publications_published = catalog.unrestrictedSearchResults(portal_type="Publication", review_state="published")
res = "# TYPE publications_published gauge\n"
res += "# HELP publications_published Number of publications published\n"
res += "publications_published " + str(len(publications_published)) + "\n\n"

publications_planned = catalog.unrestrictedSearchResults(portal_type="Publication", review_state="planned")
res += "# TYPE publications_planned gauge\n"
res += "# HELP publications_planned Number of publications planned\n"
res += "publications_planned " + str(len(publications_planned)) + "\n\n"

now = datetime.now()
publications_planned_not_published = catalog.unrestrictedSearchResults(portal_type="Publication", review_state="planned", effective=now - timedelta(minutes=60))
res += "# TYPE publications_planned_late gauge\n"
res += "# HELP publications_planned_late Number of publications planned but not published\n"
res += "publications_planned_late " + str(len(publications_planned_not_published)) + "\n\n"

publications_expired = catalog.unrestrictedSearchResults(portal_type="Publication", review_state="published", expires=now)
res += "# TYPE publications_expired gauge\n"
res += "# HELP publications_expired Number of publications expired\n"
res += "publications_expired " + str(len(publications_expired)) + "\n\n"

publications_expired_not_removed = catalog.unrestrictedSearchResults(portal_type="Publication", review_state="published", expires=now - timedelta(minutes=60))
res += "# TYPE publications_expired_late gauge\n"
res += "# HELP publications_expired_late Number of publications expired but not removed\n"
res += "publications_expired_late " + str(len(publications_expired_not_removed)) + "\n\n"

dangling_publications = publications_planned_not_published + publications_expired_not_removed
res += "# TYPE dangling_publications gauge\n"
res += "# HELP dangling_publications Number of dangling publications\n"
res += "dangling_publications " + str(len(dangling_publications)) + "\n\n"

cron_log_path = "./var/log/cron.log"
if os.path.exists(cron_log_path):
last_cron_run = os.path.getmtime(cron_log_path)
res += "# TYPE last_cron_run gauge\n"
res += "# HELP last_cron_run Last cron run\n"
res += "last_cron_run " + str(last_cron_run) + "\n\n"

cron_is_not_running = last_cron_run < now.timestamp() - 3600
res += "# TYPE cron_is_not_running gauge\n"
res += "# HELP cron_is_not_running Cron is not running\n"
res += "cron_is_not_running " + str(cron_is_not_running) + "\n\n"

self.request.response.setHeader("Content-type", "text/plain")
return res
80 changes: 64 additions & 16 deletions src/plonemeeting/portal/core/tests/test_rest.py
Original file line number Diff line number Diff line change
@@ -1,36 +1,32 @@
from plone import api
from plonemeeting.portal.core.tests.portal_test_case import PmPortalDemoFunctionalTestCase

import json
from plone.testing.zope import Browser


class TestRestViews(PmPortalDemoFunctionalTestCase):

def test_rest_institution_locations_api_view(self):
""" Test if the values from InstitutionLocationsView are correct"""
"""Test if the values from InstitutionLocationsView are correct"""
portal = api.portal.get()

view = portal.unrestrictedTraverse("@@institution_locations")
view = portal.unrestrictedTraverse("@@institution-locations")
render = view()
# Institutions with imaginary name are ignored
self.assertDictEqual({}, json.loads(render))

self.login_as_manager()
namur = api.content.create(
container=self.portal, type="Institution", id="namur", title="Namur"
)
liege = api.content.create(
container=self.portal, type="Institution", id="liege", title="Liège"
)
view = portal.unrestrictedTraverse("@@institution_locations")
namur = api.content.create(container=self.portal, type="Institution", id="namur", title="Namur")
liege = api.content.create(container=self.portal, type="Institution", id="liege", title="Liège")
view = portal.unrestrictedTraverse("@@institution-locations")
render = view()
# Not published institutions are ignored too
self.assertDictEqual({}, json.loads(render))

api.content.transition(obj=namur, transition='publish')
api.content.transition(obj=liege, transition='publish')
api.content.transition(obj=namur, transition="publish")
api.content.transition(obj=liege, transition="publish")
# We should have some data now :
view = portal.unrestrictedTraverse("@@institution_locations")
view = portal.unrestrictedTraverse("@@institution-locations")
render = view()
json_response = json.loads(render)
self.assertIn("namur", json_response.keys())
Expand All @@ -42,7 +38,59 @@ def test_rest_institution_locations_api_view(self):
self.assertTrue(hasattr(portal, "api_institution_locations"))

def test_rest_meeting_agenda_api_view(self):
meeting = self.portal["belleville"].decisions.listFolderContents(
{"portal_type": "Meeting"})[0]
"""Test if the values from MeetingAgendaAPIView are correct"""
meeting = self.portal["belleville"].decisions.listFolderContents({"portal_type": "Meeting"})[0]
browser = Browser(self.layer["app"])
browser.handleErrors = False

# Construct the URL to the view
url = f"{meeting.absolute_url()}/@@agenda"
browser.open(url)

self.assertEqual(browser.headers.get("Content-Type"), "application/json")
data = json.loads(browser.contents)

self.assertEqual(len(data), 3)
self.assertIn("formatted_title", data[0])
self.assertDictEqual(
data[0]["formatted_title"],
{"content-type": "text/html", "data": "<p>Approbation du PV du XXX</p>", "encoding": "utf-8"},
)
self.assertEqual(data[0]["number"], "1")
self.assertEqual(data[0]["number"], "1")
self.assertEqual(data[0]["number"], "1")

titles = [item["title"] for item in data]
self.assertIn("Point tourisme", titles)
self.assertIn("Point tourisme urgent", titles)

def test_prometheus_exporter(self):
"""Test if the values from PrometheusExporter are correct"""
view = self.portal.unrestrictedTraverse("@@prometheus-export")
render = view()
request = self.portal.REQUEST
view = meeting.restrictedTraverse("@@view")
self.assertEqual(request.response.getHeader("Content-type"), "text/plain; charset=utf-8")

# Parse the output metrics
metrics = dict()
for line in render.split("\n"):
if line and not line.startswith("#"):
key, value = line.split(" ")
metrics[key] = float(value)

self.assertIn("publications_published", metrics)
self.assertIn("publications_planned", metrics)
self.assertIn("publications_planned_late", metrics)
self.assertIn("publications_expired", metrics)
self.assertIn("publications_expired_late", metrics)
self.assertIn("dangling_publications", metrics)

expected_published = len(
self.portal.portal_catalog.unrestrictedSearchResults(portal_type="Publication", review_state="published")
)
self.assertEqual(metrics["publications_published"], expected_published)

expected_planned = len(
self.portal.portal_catalog.unrestrictedSearchResults(portal_type="Publication", review_state="planned")
)
self.assertEqual(metrics["publications_planned"], expected_planned)

0 comments on commit 3996ed0

Please sign in to comment.