Skip to content

Commit

Permalink
add desktop certificat
Browse files Browse the repository at this point in the history
Kev-Roche committed May 27, 2024
1 parent 07f034c commit fdbe30c
Showing 19 changed files with 1,138 additions and 0 deletions.
70 changes: 70 additions & 0 deletions desktop_certificate/README.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
===================
Desktop Certificats
===================

..
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:2bffd83f3e44d4fff1cab3eca36cc7170c1008fed1bf1fea9222d041e920e331
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
:target: https://odoo-community.org/page/development-status
:alt: Beta
.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png
:target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
:alt: License: AGPL-3
.. |badge3| image:: https://img.shields.io/badge/github-akretion%2Fak--odoo--incubator-lightgray.png?logo=github
:target: https://github.com/akretion/ak-odoo-incubator/tree/14.0/desktop_certificate
:alt: akretion/ak-odoo-incubator

|badge1| |badge2| |badge3|

This module allows to manage certificates TLS / MPKI / SSL.
It manages the creation and renewal of certificates and the installation of the certificates on your server.
It allows you to manage your certificates in a simple and efficient way.

**Table of contents**

.. contents::
:local:

Bug Tracker
===========

Bugs are tracked on `GitHub Issues <https://github.com/akretion/ak-odoo-incubator/issues>`_.
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us to smash it by providing a detailed and welcomed
`feedback <https://github.com/akretion/ak-odoo-incubator/issues/new?body=module:%20desktop_certificate%0Aversion:%2014.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.

Do not contact contributors directly about support or help with technical issues.

Credits
=======

Authors
~~~~~~~

* Akretion

Contributors
~~~~~~~~~~~~

* Kévin Roche <kevin.roche@akretion.com>

Maintainers
~~~~~~~~~~~

.. |maintainer-Kev-Roche| image:: https://github.com/Kev-Roche.png?size=40px
:target: https://github.com/Kev-Roche
:alt: Kev-Roche

Current maintainer:

|maintainer-Kev-Roche|

This module is part of the `akretion/ak-odoo-incubator <https://github.com/akretion/ak-odoo-incubator/tree/14.0/desktop_certificate>`_ project on GitHub.

You are welcome to contribute.
1 change: 1 addition & 0 deletions desktop_certificate/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import models
28 changes: 28 additions & 0 deletions desktop_certificate/__manifest__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Copyright 2024 Akretion (https://www.akretion.com).
# @author Kévin Roche <kevin.roche@akretion.com>
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).

{
"name": "Desktop Certificats",
"summary": "Generate and manage TSL certificates of users",
"version": "14.0.1.0.0",
"category": "tools",
"website": "https://github.com/akretion/ak-odoo-incubator",
"author": "Akretion, Odoo Community Association (OCA)",
"license": "AGPL-3",
"maintainers": ["Kev-Roche"],
"application": False,
"installable": True,
"depends": [
"stock",
"mail",
],
"data": [
"data/data.xml",
"security/res_groups.xml",
"security/ir.model.access.csv",
"security/rule.xml",
"views/desktop.xml",
"views/desktop_certificate.xml",
],
}
56 changes: 56 additions & 0 deletions desktop_certificate/data/data.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo noupdate="1">
<record id="mpki_api_url" model="ir.config_parameter">
<field name="key">mpki_api_url</field>
<field name="value">http://front.home.arpa:8096/certs</field>
</record>
<record id="mpki_api_user" model="ir.config_parameter">
<field name="key">mpki_api_user</field>
<field name="value"></field>
</record>
<record id="mpki_api_password" model="ir.config_parameter">
<field name="key">mpki_api_password</field>
<field name="value" />
</record>

<record id="ir_cron_send_email_certificate_expiration" model="ir.cron">
<field
name="name"
>Email d'alerte d'expiration de certificats informatique.</field>
<field name="active" eval="True" />
<field name="user_id" ref="base.user_root" />
<field name="interval_number">3</field>
<field name="interval_type">days</field>
<field name="numbercall">-1</field>
<field name="model_id" ref="model_desktop" />
<field name="code">model.cron_send_email_certificate_expiration()</field>
</record>
<record id="mail_certificate_expiration" model="mail.template">
<field
name="name"
>Email d'alerte d'expiration de certificats informatique</field>
<field name="model_id" ref="model_desktop" />
<field name="subject">Certificats à renouveler sous 15 jours</field>
<field name="email_to" />
<field name="body_html" type="xml">
<div style="margin: 0px; padding: 0px;">
<p style="margin: 0px; padding: 0px; font-size: 13px;">
--- MAIL AUTOMATIQUE ---<br /><br />
Bonjour,<br /><br />
Voici la liste des certificats qui expireront sous 15 jours:
<br />
<ul>
% for line in object.env.context.get('certifs_ids'):
<li><b>${line.name}</b> : ${line.expiration_date}</li>
% endfor
</ul>

<br />
Cordialement
</p>
</div>
</field>
<field name="report_name">${(object.name or '').replace('/','-')}</field>
<field name="auto_delete" eval="True" />
</record>
</odoo>
3 changes: 3 additions & 0 deletions desktop_certificate/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from . import desktop_certificate

from . import desktop
190 changes: 190 additions & 0 deletions desktop_certificate/models/desktop.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
# Copyright 2024 Akretion (https://www.akretion.com).
# @author Kévin Roche <kevin.roche@akretion.com>

import json
from datetime import datetime

import requests

from odoo import _, api, fields, models
from odoo.exceptions import UserError


class Desktop(models.Model):
_name = "desktop"
_inherit = ["mail.thread"]
_description = "Desktop"

name = fields.Char(string="Nom")
location_id = fields.Many2one(
comodel_name="stock.warehouse", string="Lieu", required=True
)
company_id = fields.Many2one(
comodel_name="res.company",
string="Société",
default=lambda self: self.env.company,
)
certificate_partner_id = fields.Many2one(
comodel_name="res.partner",
string="Certificats envoyés à",
domain=[("user_ids", "!=", False)],
default=lambda self: self.env.user.partner_id,
)
certificate_email = fields.Char(
string="Certificat envoyé à", related="certificate_partner_id.email"
)
certificate_phone = fields.Char(
string="Mot de passe du certificat envoyé à",
related="certificate_partner_id.mobile",
)

certificate_ids = fields.One2many(
comodel_name="desktop.certificate",
inverse_name="desktop_id",
string="Certificats",
)
certificate_state = fields.Selection(
selection=[
("1_short", "< 15 jours"),
("2_medium", "< 3 mois"),
("3_long", "> 3 mois"),
("4_revoked", "Révoqué"),
("5_obsolete", "Obsolète"),
("none", "Aucun certificat"),
],
string="Etat Certificats",
compute="_compute_valid_certificate_state",
store=True,
)

certificate_min_exp_date = fields.Date(
string="Date d'expiration",
compute="_compute_certificate_min_exp_date",
)

valid_certificate_count = fields.Integer(
compute="_compute_valid_certificate_count", string="Certificats Valides"
)

def _compute_certificate_min_exp_date(self):
for rec in self:
rec.certificate_min_exp_date = None
certifs = rec.certificate_ids
if certifs:
current_datetime = datetime.now()
valid_datetime_list = [
certif.expiration_date
for certif in certifs
if certif.expiration_date >= current_datetime
and not certif.revoked
and certif.active
]
if valid_datetime_list:
rec.certificate_min_exp_date = max(valid_datetime_list).date()

def _compute_valid_certificate_state(self):
for rec in self:
certif_states = rec.certificate_ids.mapped("state")
if certif_states:
if "short" in certif_states:
rec.certificate_state = "1_short"
elif "medium" in certif_states:
rec.certificate_state = "2_medium"
elif "long" in certif_states:
rec.certificate_state = "3_long"
elif "revoked" in certif_states:
rec.certificate_state = "4_revoked"
else:
rec.certificate_state = "5_obsolete"
else:
rec.certificate_state = "none"

def cron_send_email_certificate_expiration(self):
certifs = self.env["maintenance.certificate"].search([])
certifs._compute_valid_certificate_state()
short_certifs = certifs.filtered(lambda x: x.state == "short")
if short_certifs:
email_template = self.env.ref("infra.mail_certificate_expiration")
self.with_context(
certifs_ids=short_certifs.equipment_id
).message_post_with_template(email_template.id)

@api.depends("certificate_ids")
def _compute_valid_certificate_count(self):
for record in self:
record.valid_certificate_count = len(
record.certificate_ids.filtered(
lambda c: not c.revoked and c.expiration_date > datetime.now()
)
)

def _get_mpki_api(self):
ir_config_model = self.env["ir.config_parameter"].sudo()
mpki_api = {}
mpki_api["url"] = ir_config_model.get_param("mpki_api_url")
mpki_api["user"] = ir_config_model.get_param("mpki_api_user")
mpki_api["password"] = ir_config_model.get_param("mpki_api_password")
return mpki_api

def generate_certificate(self):
mpki_api = self._get_mpki_api()
for record in self:
partner = record.certificate_partner_id
if not partner.email:
raise UserError(_("L'email du destinataire est vide"))
if not partner.mobile:
raise UserError(_("Le mobile du destinataire est vide"))
location = record.location_id.partner_id
params = {
"certificate": {
"name": record.name,
},
"partner": {
"name": partner.display_name,
"phone": partner.mobile,
"email": partner.email,
},
"location": {
"name": location.name,
"company": location.company_id.name,
"city": location.city,
"zipcode": location.zip,
"country": location.country_id.name,
},
}
res = requests.post(
mpki_api["url"],
auth=(mpki_api["user"], mpki_api["password"]),
json=params,
)
if res.status_code == requests.codes.ok:
res_dict = res.json()
self.env["desktop.certificate"].create(
{
"revoked": not res_dict["valid"],
"name": res_dict["name"],
"expiration_date": datetime.fromisoformat(
res_dict["valid_until"]
),
"desktop_id": record.id,
"sent_to_email": record.certificate_email,
"sent_to_phone": record.certificate_phone,
}
)
else:
try:
raise UserError(json.dumps(res.json(), indent=4))
except ValueError:
raise UserError(res.text)
return None

@api.onchange("location_id")
def _onchange_location(self):
if self.location_id:
self.company_id = self.location_id.company_id.id or False

def unlink(self):
desktops = self.mapped("desktop_id")
super().unlink()
desktops.unlink()
return True
91 changes: 91 additions & 0 deletions desktop_certificate/models/desktop_certificate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# Copyright 2024 Akretion (https://www.akretion.com).
# @author Kévin Roche <kevin.roche@akretion.com>
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).

import json
from datetime import datetime

import requests
from dateutil.relativedelta import relativedelta

from odoo import _, fields, models
from odoo.exceptions import UserError


class DesktopCertificate(models.Model):
_name = "desktop.certificate"
_description = "Desktop Certificate"

name = fields.Char(string="Nom")
desktop_id = fields.Many2one(
comodel_name="desktop",
string="Poste",
)
active = fields.Boolean(default=True, string="Actif")
revoked = fields.Boolean(string="Révoqué", default=False)
expiration_date = fields.Datetime(string="Date d'expiration")
sent_to_email = fields.Char(string="Destinataire mail")
sent_to_phone = fields.Char(
string="Destinataire SMS",
)
company_id = fields.Many2one(
comodel_name="res.company",
related="desktop_id.company_id",
)

state = fields.Selection(
selection=[
("obsolete", "Obsolète"),
("revoked", "Révoqué"),
("long", "> 3 mois"),
("medium", "< 3 mois"),
("short", "< 15 jours"),
],
string="Etat du Certificat",
compute="_compute_state",
store=False,
)

def _compute_state(self):
for rec in self:
if rec.revoked or not rec.active:
rec.state = "revoked"
elif rec.expiration_date < datetime.now():
rec.state = "obsolete"
elif rec.expiration_date < datetime.now() + relativedelta(weeks=2):
rec.state = "short"
elif rec.expiration_date < datetime.now() + relativedelta(months=3):
rec.state = "medium"
else:
rec.state = "long"

def action_archive(self):
mpki_api = self[0].equipment_id._get_mpki_api()
usable = self.filtered(
lambda c: c.expiration_date > datetime.now() and not c.revoked
)
for record in usable:
res = requests.delete(
mpki_api["url"],
auth=(mpki_api["user"], mpki_api["password"]),
params={"serial": record.serial},
)
# Archive if certificate is revoked or not found (already revoked)
if res.status_code == requests.codes.ok or res.status_code == 404:
record.revoked = True
super(DesktopCertificate, record).action_archive()
else:
try:
raise UserError(json.dumps(res.json(), indent=4))
except ValueError:
raise UserError(res.text)
# standard archive process for unusable certificates
return super(DesktopCertificate, self - usable).action_archive()

def action_unarchive(self):
raise UserError(
_(
"Vous ne pouvez pas désarchiver un certificat, "
"veuillez en générer un nouveau."
)
)
1 change: 1 addition & 0 deletions desktop_certificate/readme/CONTRIBUTORS.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
* Kévin Roche <kevin.roche@akretion.com>
3 changes: 3 additions & 0 deletions desktop_certificate/readme/DESCRIPTION.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
This module allows to manage certificates TLS / MPKI / SSL.
It manages the creation and renewal of certificates and the installation of the certificates on your server.
It allows you to manage your certificates in a simple and efficient way.
5 changes: 5 additions & 0 deletions desktop_certificate/security/ir.model.access.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_desktop_manager,desktop.manager,model_desktop,desktop_certificate.group_certificat_manager,1,1,1,0
access_desktop,desktop.user,model_desktop,base.group_user,1,0,0,0
access_desktop_certificate_manager,desktop.certificate.manager,model_desktop_certificate,desktop_certificate.group_certificat_manager,1,1,1,0
access_desktop_certificate,desktop.certificate.user,model_desktop_certificate,base.group_user,1,0,0,0
14 changes: 14 additions & 0 deletions desktop_certificate/security/res_groups.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!-- Copyright (C) 2024 Akretion (<http://www.akretion.com>).
@author Kévin Roche <kevin.roche@akretion.com>
License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -->
<odoo>
<record id="group_certificat_manager" model="res.groups">
<field name="name">Gestionnaire des certificats utilisateurs</field>
<field name="comment">Permet de générer/révoquer les certificats</field>
<field
name="users"
eval="[(4, ref('base.user_root')), (4, ref('base.user_admin')) ]"
/>
</record>
</odoo>
13 changes: 13 additions & 0 deletions desktop_certificate/security/rule.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="desktop_certificat_rule" model="ir.rule">
<field name="name">Certificat multicompany rule</field>
<field name="model_id" ref="model_desktop_certificate" />
<field name="perm_read" eval="True" />
<field name="perm_create" eval="True" />
<field name="perm_write" eval="True" />
<field name="perm_unlink" eval="True" />
<field name="domain_force"> [('company_id', 'in', company_ids)]</field>
</record>

</odoo>
419 changes: 419 additions & 0 deletions desktop_certificate/static/description/index.html

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions desktop_certificate/tests/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import test_certificats
65 changes: 65 additions & 0 deletions desktop_certificate/tests/test_certificats.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Copyright 2024 Akretion (https://www.akretion.com).
# @author Pierrick Brun <pierrick.brun@akretion.com>
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).


from odoo.exceptions import UserError
from odoo.tests.common import TransactionCase

try:
from vcr_unittest import VCRMixin
except ImportError:
VCRMixin = None


class TestCertificateApi(TransactionCase, VCRMixin):
def setUp(self):
super().setUp()
warehouse = self.env.ref("stock.warehouse0")
contact = self.env["res.partner"].create(
{
"name": "Jhon Doe",
"email": "jhon.doe@akretion.com",
"mobile": "+33699999999",
}
)
self.desktop = self.env["desktop"].create(
{
"location_id": warehouse.id,
"name": "TEST",
"certificate_partner_id": contact.id,
}
)
self._set_mpki_api()

def _set_mpki_api(self):
ir_config_model = self.env["ir.config_parameter"].sudo()
# This is the IP address of the docker host
ir_config_model.set_param("mpki_api_url", "http://172.17.0.1:8000/certs")
ir_config_model.set_param("mpki_api_user", "abilis")
ir_config_model.set_param("mpki_api_password", "TESTTESTTESTTEST")

def _get_vcr_kwargs(self, **kwargs):
return {
"record_mode": "once",
"match_on": ["method", "path", "query"],
"filter_headers": ["Authorization"],
"decode_compressed_response": True,
}

def test_generate_certificate(self):
self.assertEqual(len(self.desktop.certificate_ids), 0)
self.desktop.generate_certificate()
self.assertEqual(len(self.desktop.certificate_ids), 1)
certificate1 = self.desktop.certificate_ids
self.desktop.generate_certificate()
self.assertEqual(len(self.desktop.certificate_ids), 2)
for cert in self.desktop.certificate_ids:
self.assertEqual(cert.sent_to_email, "jhon.doe@akretion.com")
self.assertEqual(cert.sent_to_phone, "+33699999999")
self.assertEqual(cert.revoked, False)
certificate1.action_archive()
self.assertEqual(certificate1.revoked, True)
with self.assertRaises(UserError):
certificate1.action_unarchive()
self.assertEqual(len(self.desktop.certificate_ids), 1)
125 changes: 125 additions & 0 deletions desktop_certificate/views/desktop.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!-- Copyright (C) 2024 Akretion (<http://www.akretion.com>).
@author Kévin Roche <kevin.roche@akretion.com> -->
<odoo>
<record id="desktop_certificate_action" model="ir.actions.act_window">
<field name="name">Certificats</field>
<field name="res_model">desktop.certificate</field>
<field name="view_mode">tree</field>
<field name="domain">[("desktop_id", "=", active_id)]</field>
<field name="context">{"active_test": False}</field>
</record>


<!-- form view -->
<record id="desktop_form" model="ir.ui.view">
<field name="model">desktop</field>
<field name="arch" type="xml">
<form>
<header>
<button
name="generate_certificate"
string="Créer un certificat"
type="object"
/>
</header>
<sheet>
<div class="oe_button_box" name="button_box">
<button
name="%(desktop_certificate_action)d"
type="action"
class="oe_stat_button"
icon="fa-file-archive-o"
>
<field
string="Certificat(s)"
name="valid_certificate_count"
widget="statinfo"
/>
</button>
</div>
<group>
<field name="name" />
<field name="location_id" />
<field name="certificate_state" />
</group>
<group string="Utilisateur">
<field name="certificate_partner_id" />
<field name="certificate_email" readonly="1" />
<field name="certificate_phone" readonly="1" />
</group>
</sheet>
</form>
</field>
</record>


<!-- tree view -->
<record id="desktop_tree" model="ir.ui.view">
<field name="model">desktop</field>
<field name="arch" type="xml">
<tree
decoration-warning="certificate_state == '2_medium'"
decoration-danger="certificate_state == '1_short'"
>
<field name="name" />
<field name="location_id" />
<field name="certificate_state" />
<field name="certificate_partner_id" />
</tree>
</field>
</record>


<!-- search view -->
<record id="desktop_search" model="ir.ui.view">
<field name="model">desktop</field>
<field name="arch" type="xml">
<search>
<filter
name="certificate_state"
string=" Etat du certificat"
domain="[]"
context="{'group_by':'certificate_state'}"
/>
<filter
name="certificate_partner_id"
string="Utilisateur"
domain="[]"
context="{'group_by':'certificate_partner_id'}"
/>
<filter
name="location_id"
string="Lieu"
domain="[]"
context="{'group_by':'location_id'}"
/>
</search>
</field>
</record>

<record id="generate_certificate_act_server" model="ir.actions.server">
<field name="name">Générer le(s) certificat(s)</field>
<field name="model_id" ref="desktop_certificate.model_desktop" />
<field name="binding_model_id" ref="desktop_certificate.model_desktop" />
<field name="state">code</field>
<field
name="code"
>action = model.browse(env.context['active_ids']).generate_certificate()</field>
</record>


<record id="desktop_action" model="ir.actions.act_window">
<field name="name">Postes</field>
<field name="res_model">desktop</field>
<field name="view_mode">tree,form</field>
<field name="context">{"active_test": False}</field>
</record>
<menuitem
id="desktop_certificate_menu"
name="Postes / Certificats"
parent="base.menu_administration"
sequence="3"
action="desktop_certificate.desktop_action"
/>
</odoo>
46 changes: 46 additions & 0 deletions desktop_certificate/views/desktop_certificate.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!-- Copyright (C) 2024 Akretion (<http://www.akretion.com>).
@author Kévin Roche <kevin.roche@akretion.com>
License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -->
<odoo>
<record id="desktop_certificate_form" model="ir.ui.view">
<field name="model">desktop.certificate</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<field name="name" />
<field name="desktop_id" readonly="1" />
<field name="expiration_date" />
<field name="state" />
</group>
<group string="Utilisateur">
<field name="sent_to_email" readonly="1" />
<field name="sent_to_phone" readonly="1" />
</group>
</sheet>
</form>
</field>
</record>
<record id="desktop_certificate_tree" model="ir.ui.view">
<field name="model">desktop.certificate</field>
<field name="arch" type="xml">
<tree
string="Certificats"
create="0"
edit="0"
delete="0"
duplicate="0"
decoration-muted="active == False"
>
<field name="name" />
<field name="desktop_id" readonly="1" />
<field name="state" />
<field name="expiration_date" />
<field name="active" />
<field name="sent_to_email" />
<field name="sent_to_phone" />
</tree>
</field>
</record>
</odoo>
1 change: 1 addition & 0 deletions setup/desktop_certificate/odoo/addons/desktop_certificate
6 changes: 6 additions & 0 deletions setup/desktop_certificate/setup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import setuptools

setuptools.setup(
setup_requires=['setuptools-odoo'],
odoo_addon=True,
)

0 comments on commit fdbe30c

Please sign in to comment.