Commit cec0cadb authored by Valentin Samir's avatar Valentin Samir

Add some docs using sphinx autodoc

parent 28dd67cb
......@@ -11,6 +11,7 @@ db.sqlite3
manage.py
coverage.xml
docs/_build/
docs/django.inv
.tox
test_venv
......
......@@ -25,8 +25,7 @@ clean_coverage:
clean_tild_backup:
find ./ -name '*~' -delete
clean_docs:
rm -rf docs/_build/
rm -rf docs/package/
rm -rf docs/_build/ docs/django.inv
clean_eggs:
rm -rf .eggs/
......@@ -74,4 +73,4 @@ docs/package: test_venv/bin/sphinx-build
test_venv/bin/sphinx-apidoc -f -e cas_server -o docs/package/ cas_server/migrations/ cas_server/management/ cas_server/tests/ #cas_server/cas.py
docs: docs/package test_venv/bin/sphinx-build
cd docs; export PATH=$(realpath test_venv/bin/):$$PATH; make coverage html
bash -c "source test_venv/bin/activate; cd docs; make html"
......@@ -9,4 +9,5 @@
#
# (c) 2015-2016 Valentin Samir
"""A django CAS server application"""
#: path the the application configuration class
default_app_config = 'cas_server.apps.CasAppConfig'
......@@ -15,86 +15,155 @@ from .models import Username, ReplaceAttributName, ReplaceAttributValue, FilterA
from .models import FederatedIendityProvider
from .forms import TicketForm
TICKETS_READONLY_FIELDS = ('validate', 'service', 'service_pattern',
'creation', 'renew', 'single_log_out', 'value')
TICKETS_FIELDS = ('validate', 'service', 'service_pattern',
'creation', 'renew', 'single_log_out')
class BaseInlines(admin.TabularInline):
"""
Bases: :class:`django.contrib.admin.TabularInline`
class ServiceTicketInline(admin.TabularInline):
"""`ServiceTicket` in admin interface"""
model = ServiceTicket
Base class for inlines in the admin interface.
"""
#: This controls the number of extra forms the formset will display in addition to
#: the initial forms.
extra = 0
class UserAdminInlines(BaseInlines):
"""
Bases: :class:`BaseInlines`
Base class for inlines in :class:`UserAdmin` interface
"""
#: The form :class:`TicketForm<cas_server.forms.TicketForm>` used to display tickets.
form = TicketForm
readonly_fields = TICKETS_READONLY_FIELDS
fields = TICKETS_FIELDS
#: Fields to display on a object that are read only (not editable).
readonly_fields = (
'validate', 'service', 'service_pattern',
'creation', 'renew', 'single_log_out', 'value'
)
#: Fields to display on a object.
fields = (
'validate', 'service', 'service_pattern',
'creation', 'renew', 'single_log_out'
)
class ServiceTicketInline(UserAdminInlines):
"""
Bases: :class:`UserAdminInlines`
class ProxyTicketInline(admin.TabularInline):
"""`ProxyTicket` in admin interface"""
:class:`ServiceTicket<cas_server.models.ServiceTicket>` in admin interface
"""
#: The model which the inline is using.
model = ServiceTicket
class ProxyTicketInline(UserAdminInlines):
"""
Bases: :class:`UserAdminInlines`
:class:`ProxyTicket<cas_server.models.ProxyTicket>` in admin interface
"""
#: The model which the inline is using.
model = ProxyTicket
extra = 0
form = TicketForm
readonly_fields = TICKETS_READONLY_FIELDS
fields = TICKETS_FIELDS
class ProxyGrantingInline(admin.TabularInline):
"""`ProxyGrantingTicket` in admin interface"""
class ProxyGrantingInline(UserAdminInlines):
"""
Bases: :class:`UserAdminInlines`
:class:`ProxyGrantingTicket<cas_server.models.ProxyGrantingTicket>` in admin interface
"""
#: The model which the inline is using.
model = ProxyGrantingTicket
extra = 0
form = TicketForm
readonly_fields = TICKETS_READONLY_FIELDS
fields = TICKETS_FIELDS[1:]
class UserAdmin(admin.ModelAdmin):
"""`User` in admin interface"""
"""
Bases: :class:`django.contrib.admin.ModelAdmin`
:class:`User<cas_server.models.User>` in admin interface
"""
#: See :class:`ServiceTicketInline`, :class:`ProxyTicketInline`, :class:`ProxyGrantingInline`
#: objects below the :class:`UserAdmin` fields.
inlines = (ServiceTicketInline, ProxyTicketInline, ProxyGrantingInline)
#: Fields to display on a object that are read only (not editable).
readonly_fields = ('username', 'date', "session_key")
#: Fields to display on a object.
fields = ('username', 'date', "session_key")
#: Fields to display on the list of class:`UserAdmin` objects.
list_display = ('username', 'date', "session_key")
class UsernamesInline(admin.TabularInline):
"""`Username` in admin interface"""
class UsernamesInline(BaseInlines):
"""
Bases: :class:`BaseInlines`
:class:`Username<cas_server.models.Username>` in admin interface
"""
#: The model which the inline is using.
model = Username
extra = 0
class ReplaceAttributNameInline(admin.TabularInline):
"""`ReplaceAttributName` in admin interface"""
class ReplaceAttributNameInline(BaseInlines):
"""
Bases: :class:`BaseInlines`
:class:`ReplaceAttributName<cas_server.models.ReplaceAttributName>` in admin interface
"""
#: The model which the inline is using.
model = ReplaceAttributName
extra = 0
class ReplaceAttributValueInline(admin.TabularInline):
"""`ReplaceAttributValue` in admin interface"""
class ReplaceAttributValueInline(BaseInlines):
"""
Bases: :class:`BaseInlines`
:class:`ReplaceAttributValue<cas_server.models.ReplaceAttributValue>` in admin interface
"""
#: The model which the inline is using.
model = ReplaceAttributValue
extra = 0
class FilterAttributValueInline(admin.TabularInline):
"""`FilterAttributValue` in admin interface"""
class FilterAttributValueInline(BaseInlines):
"""
Bases: :class:`BaseInlines`
:class:`FilterAttributValue<cas_server.models.FilterAttributValue>` in admin interface
"""
#: The model which the inline is using.
model = FilterAttributValue
extra = 0
class ServicePatternAdmin(admin.ModelAdmin):
"""`ServicePattern` in admin interface"""
"""
Bases: :class:`django.contrib.admin.ModelAdmin`
:class:`ServicePattern<cas_server.models.ServicePattern>` in admin interface
"""
#: See :class:`UsernamesInline`, :class:`ReplaceAttributNameInline`,
#: :class:`ReplaceAttributValueInline`, :class:`FilterAttributValueInline` objects below
#: the :class:`ServicePatternAdmin` fields.
inlines = (
UsernamesInline,
ReplaceAttributNameInline,
ReplaceAttributValueInline,
FilterAttributValueInline
)
#: Fields to display on the list of class:`ServicePatternAdmin` objects.
list_display = ('pos', 'name', 'pattern', 'proxy',
'single_log_out', 'proxy_callback', 'restrict_users')
class FederatedIendityProviderAdmin(admin.ModelAdmin):
"""`FederatedIendityProvider` in admin interface"""
"""
Bases: :class:`django.contrib.admin.ModelAdmin`
:class:`FederatedIendityProvider<cas_server.models.FederatedIendityProvider>` in admin
interface
"""
#: Fields to display on a object.
fields = ('pos', 'suffix', 'server_url', 'cas_protocol_version', 'verbose_name', 'display')
#: Fields to display on the list of class:`FederatedIendityProviderAdmin` objects.
list_display = ('verbose_name', 'suffix', 'display')
......
......@@ -14,6 +14,12 @@ from django.apps import AppConfig
class CasAppConfig(AppConfig):
"""django CAS application config class"""
"""
Bases: :class:`django.apps.AppConfig`
django CAS application config class
"""
#: Full Python path to the application. It must be unique across a Django project.
name = 'cas_server'
#: Human-readable name for the application.
verbose_name = _('Central Authentication Service')
......@@ -26,55 +26,112 @@ from .models import FederatedUser
class AuthUser(object):
"""Authentication base class"""
"""
Authentication base class
:param unicode username: A username, stored in the :attr:`username` class attribute.
"""
#: username used to instanciate the current object
username = None
def __init__(self, username):
self.username = username
def test_password(self, password):
"""test `password` agains the user"""
"""
Tests ``password`` agains the user password.
:raises NotImplementedError: always. The method need to be implemented by subclasses
"""
raise NotImplementedError()
def attributs(self):
"""return a dict of user attributes"""
"""
The user attributes.
raises NotImplementedError: always. The method need to be implemented by subclasses
"""
raise NotImplementedError()
class DummyAuthUser(AuthUser): # pragma: no cover
"""A Dummy authentication class"""
"""
A Dummy authentication class. Authentication always fails
def __init__(self, username):
super(DummyAuthUser, self).__init__(username)
:param unicode username: A username, stored in the :attr:`username<AuthUser.username>`
class attribute. There is no valid value for this attribute here.
"""
def test_password(self, password):
"""test `password` agains the user"""
"""
Tests ``password`` agains the user password.
:param unicode password: a clear text password as submited by the user.
:return: always ``False``
:rtype: bool
"""
return False
def attributs(self):
"""return a dict of user attributes"""
"""
The user attributes.
:return: en empty :class:`dict`.
:rtype: dict
"""
return {}
class TestAuthUser(AuthUser):
"""A test authentication class with one user test having
alose test as password and some attributes"""
"""
A test authentication class only working for one unique user.
def __init__(self, username):
super(TestAuthUser, self).__init__(username)
:param unicode username: A username, stored in the :attr:`username<AuthUser.username>`
class attribute. The uniq valid value is ``settings.CAS_TEST_USER``.
"""
def test_password(self, password):
"""test `password` agains the user"""
"""
Tests ``password`` agains the user password.
:param unicode password: a clear text password as submited by the user.
:return: ``True`` if :attr:`username<AuthUser.username>` is valid and
``password`` is equal to ``settings.CAS_TEST_PASSWORD``, ``False`` otherwise.
:rtype: bool
"""
return self.username == settings.CAS_TEST_USER and password == settings.CAS_TEST_PASSWORD
def attributs(self):
"""return a dict of user attributes"""
return settings.CAS_TEST_ATTRIBUTES
"""
The user attributes.
:return: the ``settings.CAS_TEST_ATTRIBUTES`` :class:`dict` if
:attr:`username<AuthUser.username>` is valid, an empty :class:`dict` otherwise.
:rtype: dict
"""
if self.username == settings.CAS_TEST_USER:
return settings.CAS_TEST_ATTRIBUTES
else:
return {}
class MysqlAuthUser(AuthUser): # pragma: no cover
"""A mysql auth class: authentication user agains a mysql database"""
"""
A mysql authentication class: authentication user agains a mysql database
:param unicode username: A username, stored in the :attr:`username<AuthUser.username>`
class attribute. Valid value are fetched from the MySQL database set with
``settings.CAS_SQL_*`` settings parameters using the query
``settings.CAS_SQL_USER_QUERY``.
"""
#: Mysql user attributes as a :class:`dict` if the username is found in the database.
user = None
def __init__(self, username):
# see the connect function at
# http://mysql-python.sourceforge.net/MySQLdb.html#functions-and-attributes
# for possible mysql config parameters.
mysql_config = {
"user": settings.CAS_SQL_USERNAME,
"passwd": settings.CAS_SQL_PASSWORD,
......@@ -94,7 +151,14 @@ class MysqlAuthUser(AuthUser): # pragma: no cover
super(MysqlAuthUser, self).__init__(username)
def test_password(self, password):
"""test `password` agains the user"""
"""
Tests ``password`` agains the user password.
:param unicode password: a clear text password as submited by the user.
:return: ``True`` if :attr:`username<AuthUser.username>` is valid and ``password`` is
correct, ``False`` otherwise.
:rtype: bool
"""
if self.user:
return check_password(
settings.CAS_SQL_PASSWORD_CHECK,
......@@ -106,7 +170,14 @@ class MysqlAuthUser(AuthUser): # pragma: no cover
return False
def attributs(self):
"""return a dict of user attributes"""
"""
The user attributes.
:return: a :class:`dict` with the user attributes. Attributes may be :func:`unicode`
or :class:`list` of :func:`unicode`. If the user do not exists, the returned
:class:`dict` is empty.
:rtype: dict
"""
if self.user:
return self.user
else:
......@@ -114,7 +185,14 @@ class MysqlAuthUser(AuthUser): # pragma: no cover
class DjangoAuthUser(AuthUser): # pragma: no cover
"""A django auth class: authenticate user agains django internal users"""
"""
A django auth class: authenticate user agains django internal users
:param unicode username: A username, stored in the :attr:`username<AuthUser.username>`
class attribute. Valid value are usernames of django internal users.
"""
#: a django user object if the username is found. The user model is retreived
#: using :func:`django.contrib.auth.get_user_model`.
user = None
def __init__(self, username):
......@@ -126,14 +204,27 @@ class DjangoAuthUser(AuthUser): # pragma: no cover
super(DjangoAuthUser, self).__init__(username)
def test_password(self, password):
"""test `password` agains the user"""
"""
Tests ``password`` agains the user password.
:param unicode password: a clear text password as submited by the user.
:return: ``True`` if :attr:`user` is valid and ``password`` is
correct, ``False`` otherwise.
:rtype: bool
"""
if self.user:
return self.user.check_password(password)
else:
return False
def attributs(self):
"""return a dict of user attributes"""
"""
The user attributes, defined as the fields on the :attr:`user` object.
:return: a :class:`dict` with the :attr:`user` object fields. Attributes may be
If the user do not exists, the returned :class:`dict` is empty.
:rtype: dict
"""
if self.user:
attr = {}
for field in self.user._meta.fields:
......@@ -144,7 +235,16 @@ class DjangoAuthUser(AuthUser): # pragma: no cover
class CASFederateAuth(AuthUser):
"""Authentication class used then CAS_FEDERATE is True"""
"""
Authentication class used then CAS_FEDERATE is True
:param unicode username: A username, stored in the :attr:`username<AuthUser.username>`
class attribute. Valid value are usernames of
:class:`FederatedUser<cas_server.models.FederatedUser>` object.
:class:`FederatedUser<cas_server.models.FederatedUser>` object are created on CAS
backends successful ticket validation.
"""
#: a :class`FederatedUser<cas_server.models.FederatedUser>` object if ``username`` is found.
user = None
def __init__(self, username):
......@@ -157,7 +257,17 @@ class CASFederateAuth(AuthUser):
super(CASFederateAuth, self).__init__(username)
def test_password(self, ticket):
"""test `password` agains the user"""
"""
Tests ``password`` agains the user password.
:param unicode password: The CAS tickets just used to validate the user authentication
against its CAS backend.
:return: ``True`` if :attr:`user` is valid and ``password`` is
a ticket validated less than ``settings.CAS_TICKET_VALIDITY`` secondes and has not
being previously used for authenticated this
:class:`FederatedUser<cas_server.models.FederatedUser>`. ``False`` otherwise.
:rtype: bool
"""
if not self.user or not self.user.ticket:
return False
else:
......@@ -168,7 +278,13 @@ class CASFederateAuth(AuthUser):
)
def attributs(self):
"""return a dict of user attributes"""
"""
The user attributes, as returned by the CAS backend.
:return: :obj:`FederatedUser.attributs<cas_server.models.FederatedUser.attributs>`.
If the user do not exists, the returned :class:`dict` is empty.
:rtype: dict
"""
if not self.user: # pragma: no cover (should not happen)
return {}
else:
......
......@@ -10,25 +10,32 @@
#
# (c) 2016 Valentin Samir
"""federated mode helper classes"""
from .default_settings import settings
from .default_settings import SessionStore
from django.db import IntegrityError
from .cas import CASClient
from .models import FederatedUser, FederateSLO, User
import logging
from importlib import import_module
from six.moves import urllib
SessionStore = import_module(settings.SESSION_ENGINE).SessionStore
#: logger facility
logger = logging.getLogger(__name__)
class CASFederateValidateUser(object):
"""Class CAS client used to authenticate the user again a CAS provider"""
"""
Class CAS client used to authenticate the user again a CAS provider
:param cas_server.models.FederatedIendityProvider provider: The provider to use for
authenticate the user.
:param unicode service_url: The service url to transmit to the ``provider``.
"""
#: the provider returned username
username = None
#: the provider returned attributes
attributs = {}
#: the CAS client instance
client = None
def __init__(self, provider, service_url):
......@@ -41,15 +48,31 @@ class CASFederateValidateUser(object):
)
def get_login_url(self):
"""return the CAS provider login url"""
"""
:return: the CAS provider login url
:rtype: unicode
"""
return self.client.get_login_url()
def get_logout_url(self, redirect_url=None):
"""return the CAS provider logout url"""
"""
:param redirect_url: The url to redirect to after logout from the provider, if provided.
:type redirect_url: :obj:`unicode` or :obj:`NoneType<types.NoneType>`
:return: the CAS provider logout url
:rtype: unicode
"""
return self.client.get_logout_url(redirect_url)
def verify_ticket(self, ticket):
"""test `ticket` agains the CAS provider, if valid, create the local federated user"""
"""
test ``ticket`` agains the CAS provider, if valid, create a
:class:`FederatedUser<cas_server.models.FederatedUser>` matching provider returned
username and attributes.
:param unicode ticket: The ticket to validate against the provider CAS
:return: ``True`` if the validation succeed, else ``False``.
:rtype: bool
"""
try:
username, attributs = self.client.verify_ticket(ticket)[:2]
except urllib.error.URLError:
......@@ -73,7 +96,15 @@ class CASFederateValidateUser(object):
@staticmethod
def register_slo(username, session_key, ticket):
"""association a ticket with a (username, session) for processing later SLO request"""
"""
association a ``ticket`` with a (``username``, ``session_key``) for processing later SLO
request by creating a :class:`cas_server.models.FederateSLO` object.
:param unicode username: A logged user username, with the ``@`` component.
:param unicode session_key: A logged user session_key matching ``username``.
:param unicode ticket: A ticket used to authentication ``username`` for the session
``session_key``.
"""
try:
FederateSLO.objects.create(
username=username,
......@@ -84,7 +115,14 @@ class CASFederateValidateUser(object):
pass
def clean_sessions(self, logout_request):
"""process a SLO request"""
"""
process a SLO request: Search for ticket values in ``logout_request``. For each
ticket value matching a :class:`cas_server.models.FederateSLO`, disconnect the
corresponding user.
:param unicode logout_request: An XML document contening one or more Single Log Out
requests.
"""
try:
slos = self.client.get_saml_slos(logout_request) or []
except NameError: # pragma: no cover (should not happen)
......
......@@ -19,20 +19,33 @@ import cas_server.models as models
class WarnForm(forms.Form):
"""Form used on warn page before emiting a ticket"""
"""
Bases: :class:`django.forms.Form`
Form used on warn page before emiting a ticket
"""
#: The service url for which the user want a ticket
service = forms.CharField(widget=forms.HiddenInput(), required=False)
#: Is the service asking the authentication renewal ?
renew = forms.BooleanField(widget=forms.HiddenInput(), required=False)
#: Url to redirect to if the authentication fail (user not authenticated or bad service)
gateway = forms.CharField(widget=forms.HiddenInput(), required=False)
method = forms.CharField(widget=forms.HiddenInput(), required=False)
#: ``True`` if the user has been warned of the ticket emission
warned = forms.BooleanField(widget=forms.HiddenInput(), required=False)
#: A valid LoginTicket to prevent POST replay
lt = forms.CharField(widget=forms.HiddenInput(), required=False)
class FederateSelect(forms.Form):
"""
Form used on the login page when CAS_FEDERATE is True
allowing the user to choose a identity provider.
Bases: :class:`django.forms.Form`
Form used on the login page when ``settings.CAS_FEDERATE`` is ``True``
allowing the user to choose an identity provider.
"""
#: The providers the user can choose to be used as authentication backend
provider = forms.ModelChoiceField(
queryset=models.FederatedIendityProvider.objects.filter(display=True).order_by(
"pos",
......@@ -42,27 +55,49 @@ class FederateSelect(forms.Form):
to_field_name="suffix",
label=_('Identity provider'),
)
#: The service url for which the user want a ticket
service = forms.CharField(label=_('service'), widget=forms.HiddenInput(), required=False)
method = forms.CharField(widget=forms.HiddenInput(), required=False)
#: A checkbox to remember the user choices of :attr:`provider<FederateSelect.provider>`
remember = forms.BooleanField(label=_('Remember the identity provider'), required=False)
#: A checkbox to ask to be warn before emiting a ticket for another service
warn = forms.BooleanField(label=_('warn'), required=False)
#: Is the service asking the authentication renewal ?
renew = forms.BooleanField(widget=forms.HiddenInput(), required=False)
class UserCredential(forms.Form):
"""Form used on the login page to retrive user credentials"""
"""
Bases: :class:`django.forms.Form`
Form used on the login page to retrive user credentials
"""
#: The user username
username = forms.CharField(label=_('login'))
#: The service url for which the user want a ticket
service = forms.CharField(label=_('service'), widget=forms.HiddenInput(), required=False)
#: The user password
password = forms.CharField(label=_('password'), widget=forms.PasswordInput)
#: A valid LoginTicket to prevent POST replay
lt = forms.CharField(widget=forms.HiddenInput(), required=False)
method = forms.CharField(widget=forms.HiddenInput(), required=False)
#: A checkbox to ask to be warn before emiting a ticket for another service
warn = forms.BooleanField(label=_('warn'), required=False)
#: Is the service asking the authentication renewal ?
renew = forms.BooleanField(widget=forms.HiddenInput(), required=False)
def __init__(self, *args, **kwargs):
super(UserCredential, self).__init__(*args, **kwargs)
def clean(self):
"""
Validate that the submited :attr:`username` and :attr:`password` are valid
:raises django.forms.ValidationError: if the :attr:`username` and :attr:`password`
are not valid.
:return: The cleaned POST data
:rtype: dict
"""
cleaned_data = super(UserCredential, self).clean()
auth = utils.import_attr(settings.CAS_AUTH_CLASS)(cleaned_data.get("username"))
if auth.test_password(cleaned_data.get("password")):
......@@ -73,17 +108,51 @@ class UserCredential(forms.Form):
class FederateUserCredential(UserCredential):
"""Form used on the login page to retrive user credentials"""
"""
Bases: :class:`UserCredential`
Form used on a auto submited page for linking the views
:class:`FederateAuth<cas_server.views.FederateAuth>` and
:class:`LoginView<cas_server.views.LoginView>`.
On successful authentication on a provider, in the view
:class:`FederateAuth<cas_server.views.FederateAuth>` a
:class:`FederatedUser<cas_server.models.FederatedUser>` is created by
:meth:`cas_server.federate.CASFederateValidateUser.verify_ticket` and the user is redirected
to :class:`LoginView<cas_server.views.LoginView>`. This form is then automatically filled
with infos matching the created :class:`FederatedUser<cas_server.models.FederatedUser>`
using the ``ticket`` as one time password and submited using javascript. If javascript is
not enabled, a connect button is displayed.
This stub authentication form, allow to implement the federated mode with very few
modificatons to the :class:`LoginView<cas_server.views.LoginView>` view.
"""
#: the user username with the ``@`` component
username = forms.CharField(widget=forms.HiddenInput())
#: The service url for which the user want a ticket
service = forms.CharField(widget=forms.HiddenInput(), required=False)
#: The ``ticket`` used to authenticate the user against a provider
password = forms.CharField(widget=forms.HiddenInput())
#: alias of :attr:`password`
ticket = forms.CharField(widget=forms.HiddenInput())
#: A valid LoginTicket to prevent POST replay
lt = forms.CharField(widget=forms.HiddenInput(), required=False)
method = forms.CharField(widget=forms.HiddenInput(), required=False)
#: Has the user asked to be warn before emiting a ticket for another service
warn = forms.BooleanField(widget=forms.HiddenInput(), required=False)
#: Is the service asking the authentication renewal ?
renew = forms.BooleanField(widget=forms.HiddenInput(), required=False)
def clean(self):
"""
Validate that the submited :attr:`username` and :attr:`password` are valid using
the :class:`CASFederateAuth<cas_server.auth.CASFederateAuth>` auth class.
:raises django.forms.ValidationError: if the :attr:`username` and :attr:`password`
do not correspond to a :class:`FederatedUser<cas_server.models.FederatedUser>`.
:return: The cleaned POST data
:rtype: dict
"""
cleaned_data = super(FederateUserCredential, self).clean()
try:
user = models.FederatedUser.get_from_federated_username(cleaned_data["username"])
......@@ -99,7 +168,11 @@ class FederateUserCredential(UserCredential):
class TicketForm(forms.ModelForm):
"""Form for Tickets in the admin interface"""
"""
Bases: :class:`django.forms.ModelForm`
Form for Tickets in the admin interface
"""
class Meta:
model = models.Ticket
exclude = []
......
This diff is collapsed.
This diff is collapsed.
......@@ -10,7 +10,7 @@
#
# (c) 2015-2016 Valentin Samir