Rules -> Policies, more things
This commit is contained in:
parent
d3d75737ed
commit
c941107d42
|
@ -1,6 +1,6 @@
|
||||||
"""passbook core source form fields"""
|
"""passbook core source form fields"""
|
||||||
# from django import forms
|
# from django import forms
|
||||||
|
|
||||||
SOURCE_FORM_FIELDS = ['name', 'slug', 'enabled']
|
SOURCE_FORM_FIELDS = ['name', 'slug', 'enabled', 'policies']
|
||||||
|
|
||||||
# class SourceForm(forms.Form)
|
# class SourceForm(forms.Form)
|
||||||
|
|
|
@ -8,7 +8,6 @@
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container">
|
|
||||||
<h1>{% trans "Audit Log" %}</h1>
|
<h1>{% trans "Audit Log" %}</h1>
|
||||||
<div id="pf-list-standard" class="list-group list-view-pf list-view-pf-view">
|
<div id="pf-list-standard" class="list-group list-view-pf list-view-pf-view">
|
||||||
{% for entry in object_list %}
|
{% for entry in object_list %}
|
||||||
|
@ -23,27 +22,31 @@
|
||||||
{{ entry.action }}
|
{{ entry.action }}
|
||||||
</div>
|
</div>
|
||||||
<div class="list-group-item-text">
|
<div class="list-group-item-text">
|
||||||
|
{{ entry.context }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="list-view-pf-additional-info">
|
<div class="list-view-pf-additional-info">
|
||||||
<div class="list-view-pf-additional-info-item">
|
<div class="list-view-pf-additional-info-item">
|
||||||
<span class="pficon pficon-user"></span>
|
<span class="pficon pficon-user"></span>
|
||||||
<strong>{{ entry.user }}</strong>
|
<strong>{{ entry.user }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="list-view-pf-additional-info-item">
|
<div class="list-view-pf-additional-info-item">
|
||||||
<span class="pficon pficon-screen"></span>
|
<span class="pficon pficon-cluster"></span>
|
||||||
<strong>{{ entry.request_ip }}</strong>
|
<strong>{{ entry.app|default:'-' }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="list-view-pf-additional-info-item">
|
<div class="list-view-pf-additional-info-item">
|
||||||
<span class="pficon pficon-cluster"></span>
|
<span class="fa fa-clock-o"></span>
|
||||||
<strong>{{ entry.app|default:'-' }}</strong>
|
<strong>{{ entry.created }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="list-view-pf-additional-info-item">
|
||||||
|
<span class="pficon pficon-screen"></span>
|
||||||
|
<strong>{{ entry.request_ip }}</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
|
||||||
<script>
|
<script>
|
||||||
$(document).ready(function () {
|
$(document).ready(function () {
|
||||||
// Row Checkbox Selection
|
// Row Checkbox Selection
|
||||||
|
|
|
@ -4,53 +4,89 @@
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="col-xs-6 col-sm-2 col-md-2">
|
<div class="col-xs-6 col-sm-2 col-md-2">
|
||||||
<div class="card-pf card-pf-accented card-pf-aggregate-status">
|
<div class="card-pf card-pf-accented card-pf-aggregate-status">
|
||||||
<h2 class="card-pf-title">
|
<h2 class="card-pf-title">
|
||||||
<a href="#"><span class="fa fa-shield"></span><span class="card-pf-aggregate-status-count"></span> {% trans 'Applications' %}</a>
|
<a href="#"><span class="fa fa-shield"></span><span class="card-pf-aggregate-status-count"></span> {% trans 'Applications' %}</a>
|
||||||
</h2>
|
</h2>
|
||||||
<div class="card-pf-body">
|
<div class="card-pf-body">
|
||||||
<p class="card-pf-aggregate-status-notifications">
|
<p class="card-pf-aggregate-status-notifications">
|
||||||
<span class="card-pf-aggregate-status-notification"><a href="#"><span class="pficon pficon-ok"></span>{{ application_count }}</a></span>
|
<span class="card-pf-aggregate-status-notification"><a href="#"><span class="pficon pficon-ok"></span>{{ application_count }}</a></span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="col-xs-6 col-sm-2 col-md-2">
|
||||||
<div class="col-xs-6 col-sm-2 col-md-2">
|
<div class="card-pf card-pf-accented card-pf-aggregate-status">
|
||||||
<div class="card-pf card-pf-accented card-pf-aggregate-status">
|
<h2 class="card-pf-title">
|
||||||
<h2 class="card-pf-title">
|
<a href="#"><span class="fa fa-shield"></span><span class="card-pf-aggregate-status-count"></span> {% trans 'Sources' %}</a>
|
||||||
<a href="#"><span class="fa fa-shield"></span><span class="card-pf-aggregate-status-count"></span> {% trans 'Providers' %}</a>
|
</h2>
|
||||||
</h2>
|
<div class="card-pf-body">
|
||||||
<div class="card-pf-body">
|
<p class="card-pf-aggregate-status-notifications">
|
||||||
<p class="card-pf-aggregate-status-notifications">
|
<span class="card-pf-aggregate-status-notification"><a href="#"><span class="pficon pficon-ok"></span>{{ source_count }}</a></span>
|
||||||
<span class="card-pf-aggregate-status-notification"><a href="#"><span class="pficon pficon-ok"></span>{{ provider_count }}</a></span>
|
</p>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="col-xs-6 col-sm-2 col-md-2">
|
||||||
<div class="col-xs-6 col-sm-2 col-md-2">
|
<div class="card-pf card-pf-accented card-pf-aggregate-status">
|
||||||
<div class="card-pf card-pf-accented card-pf-aggregate-status">
|
<h2 class="card-pf-title">
|
||||||
<h2 class="card-pf-title">
|
<a href="#"><span class="fa fa-shield"></span><span class="card-pf-aggregate-status-count"></span> {% trans 'Providers' %}</a>
|
||||||
<a href="#"><span class="fa fa-shield"></span><span class="card-pf-aggregate-status-count"></span> {% trans 'Policies' %}</a>
|
</h2>
|
||||||
</h2>
|
<div class="card-pf-body">
|
||||||
<div class="card-pf-body">
|
<p class="card-pf-aggregate-status-notifications">
|
||||||
<p class="card-pf-aggregate-status-notifications">
|
<span class="card-pf-aggregate-status-notification"><a href="#"><span class="pficon pficon-ok"></span>{{ provider_count }}</a></span>
|
||||||
<span class="card-pf-aggregate-status-notification"><a href="#"><span class="pficon pficon-ok"></span>{{ policy_count }}</a></span>
|
</p>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="col-xs-6 col-sm-2 col-md-2">
|
||||||
<div class="col-xs-6 col-sm-2 col-md-2">
|
<div class="card-pf card-pf-accented card-pf-aggregate-status">
|
||||||
<div class="card-pf card-pf-accented card-pf-aggregate-status">
|
<h2 class="card-pf-title">
|
||||||
<h2 class="card-pf-title">
|
<a href="#"><span class="fa fa-shield"></span><span class="card-pf-aggregate-status-count"></span> {% trans 'Factors' %}</a>
|
||||||
<a href="#"><span class="fa fa-shield"></span><span class="card-pf-aggregate-status-count"></span> {% trans 'Users' %}</a>
|
</h2>
|
||||||
</h2>
|
<div class="card-pf-body">
|
||||||
<div class="card-pf-body">
|
<p class="card-pf-aggregate-status-notifications">
|
||||||
<p class="card-pf-aggregate-status-notifications">
|
<span class="card-pf-aggregate-status-notification"><a href="#"><span class="pficon pficon-ok"></span>{{ factor_count }}</a></span>
|
||||||
<span class="card-pf-aggregate-status-notification"><a href="#"><span class="pficon pficon-ok"></span>{{ user_count }}</a></span>
|
</p>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-xs-6 col-sm-2 col-md-2">
|
||||||
|
<div class="card-pf card-pf-accented card-pf-aggregate-status">
|
||||||
|
<h2 class="card-pf-title">
|
||||||
|
<a href="#"><span class="fa fa-shield"></span><span class="card-pf-aggregate-status-count"></span> {% trans 'Invitation' %}</a>
|
||||||
|
</h2>
|
||||||
|
<div class="card-pf-body">
|
||||||
|
<p class="card-pf-aggregate-status-notifications">
|
||||||
|
<span class="card-pf-aggregate-status-notification"><a href="#"><span class="pficon pficon-ok"></span>{{ invitation_count }}</a></span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-xs-6 col-sm-2 col-md-2">
|
||||||
|
<div class="card-pf card-pf-accented card-pf-aggregate-status">
|
||||||
|
<h2 class="card-pf-title">
|
||||||
|
<a href="#"><span class="fa fa-shield"></span><span class="card-pf-aggregate-status-count"></span> {% trans 'Policies' %}</a>
|
||||||
|
</h2>
|
||||||
|
<div class="card-pf-body">
|
||||||
|
<p class="card-pf-aggregate-status-notifications">
|
||||||
|
<span class="card-pf-aggregate-status-notification"><a href="#"><span class="pficon pficon-ok"></span>{{ policy_count }}</a></span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-xs-6 col-sm-2 col-md-2">
|
||||||
|
<div class="card-pf card-pf-accented card-pf-aggregate-status">
|
||||||
|
<h2 class="card-pf-title">
|
||||||
|
<a href="#"><span class="fa fa-shield"></span><span class="card-pf-aggregate-status-count"></span> {% trans 'Users' %}</a>
|
||||||
|
</h2>
|
||||||
|
<div class="card-pf-body">
|
||||||
|
<p class="card-pf-aggregate-status-notifications">
|
||||||
|
<span class="card-pf-aggregate-status-notification"><a href="#"><span class="pficon pficon-ok"></span>{{ user_count }}</a></span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
@ -2,7 +2,8 @@
|
||||||
from django.views.generic import TemplateView
|
from django.views.generic import TemplateView
|
||||||
|
|
||||||
from passbook.admin.mixins import AdminRequiredMixin
|
from passbook.admin.mixins import AdminRequiredMixin
|
||||||
from passbook.core.models import Application, Policy, Provider, User
|
from passbook.core.models import (Application, Factor, Invitation, Policy,
|
||||||
|
Provider, Source, User)
|
||||||
|
|
||||||
|
|
||||||
class AdministrationOverviewView(AdminRequiredMixin, TemplateView):
|
class AdministrationOverviewView(AdminRequiredMixin, TemplateView):
|
||||||
|
@ -15,4 +16,7 @@ class AdministrationOverviewView(AdminRequiredMixin, TemplateView):
|
||||||
kwargs['policy_count'] = len(Policy.objects.all())
|
kwargs['policy_count'] = len(Policy.objects.all())
|
||||||
kwargs['user_count'] = len(User.objects.all())
|
kwargs['user_count'] = len(User.objects.all())
|
||||||
kwargs['provider_count'] = len(Provider.objects.all())
|
kwargs['provider_count'] = len(Provider.objects.all())
|
||||||
|
kwargs['source_count'] = len(Source.objects.all())
|
||||||
|
kwargs['factor_count'] = len(Factor.objects.all())
|
||||||
|
kwargs['invitation_count'] = len(Invitation.objects.all())
|
||||||
return super().get_context_data(**kwargs)
|
return super().get_context_data(**kwargs)
|
||||||
|
|
|
@ -0,0 +1,18 @@
|
||||||
|
# Generated by Django 2.1.7 on 2019-02-21 12:01
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('passbook_audit', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='loginattempt',
|
||||||
|
name='created',
|
||||||
|
field=models.DateTimeField(auto_now_add=True),
|
||||||
|
),
|
||||||
|
]
|
|
@ -0,0 +1,23 @@
|
||||||
|
# Generated by Django 2.1.7 on 2019-02-21 12:40
|
||||||
|
|
||||||
|
import django.contrib.postgres.fields.jsonb
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('passbook_audit', '0002_auto_20190221_1201'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='auditentry',
|
||||||
|
name='_context',
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='auditentry',
|
||||||
|
name='context',
|
||||||
|
field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, default=dict),
|
||||||
|
),
|
||||||
|
]
|
|
@ -1,10 +1,10 @@
|
||||||
"""passbook audit models"""
|
"""passbook audit models"""
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from json import dumps, loads
|
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import AnonymousUser
|
from django.contrib.auth.models import AnonymousUser
|
||||||
|
from django.contrib.postgres.fields import JSONField
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
@ -43,18 +43,10 @@ class AuditEntry(UUIDModel):
|
||||||
action = models.TextField(choices=ACTIONS)
|
action = models.TextField(choices=ACTIONS)
|
||||||
date = models.DateTimeField(auto_now_add=True)
|
date = models.DateTimeField(auto_now_add=True)
|
||||||
app = models.TextField()
|
app = models.TextField()
|
||||||
_context = models.TextField()
|
context = JSONField(default=dict, blank=True)
|
||||||
_context_cache = None
|
|
||||||
request_ip = models.GenericIPAddressField()
|
request_ip = models.GenericIPAddressField()
|
||||||
created = models.DateTimeField(auto_now_add=True)
|
created = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
@property
|
|
||||||
def context(self):
|
|
||||||
"""Load context data and load json"""
|
|
||||||
if not self._context_cache:
|
|
||||||
self._context_cache = loads(self._context)
|
|
||||||
return self._context_cache
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def create(action, request, **kwargs):
|
def create(action, request, **kwargs):
|
||||||
"""Create AuditEntry from arguments"""
|
"""Create AuditEntry from arguments"""
|
||||||
|
@ -67,7 +59,7 @@ class AuditEntry(UUIDModel):
|
||||||
user=user,
|
user=user,
|
||||||
# User 255.255.255.255 as fallback if IP cannot be determined
|
# User 255.255.255.255 as fallback if IP cannot be determined
|
||||||
request_ip=client_ip or '255.255.255.255',
|
request_ip=client_ip or '255.255.255.255',
|
||||||
_context=dumps(kwargs))
|
context=kwargs)
|
||||||
LOGGER.debug("Logged %s from %s (%s)", action, request.user, client_ip)
|
LOGGER.debug("Logged %s from %s (%s)", action, request.user, client_ip)
|
||||||
return entry
|
return entry
|
||||||
|
|
||||||
|
|
|
@ -15,8 +15,9 @@ class AuthenticationFactor(TemplateView):
|
||||||
form = None
|
form = None
|
||||||
required = True
|
required = True
|
||||||
authenticator = None
|
authenticator = None
|
||||||
|
pending_user = None
|
||||||
request = None
|
request = None
|
||||||
template_name = 'login/form.html'
|
template_name = 'login/factors/base.html'
|
||||||
|
|
||||||
def __init__(self, authenticator):
|
def __init__(self, authenticator):
|
||||||
self.authenticator = authenticator
|
self.authenticator = authenticator
|
||||||
|
@ -26,4 +27,5 @@ class AuthenticationFactor(TemplateView):
|
||||||
kwargs['is_login'] = True
|
kwargs['is_login'] = True
|
||||||
kwargs['title'] = _('Log in to your account')
|
kwargs['title'] = _('Log in to your account')
|
||||||
kwargs['primary_action'] = _('Log in')
|
kwargs['primary_action'] = _('Log in')
|
||||||
|
kwargs['pending_user'] = self.pending_user
|
||||||
return super().get_context_data(**kwargs)
|
return super().get_context_data(**kwargs)
|
||||||
|
|
|
@ -67,6 +67,7 @@ class AuthenticationView(UserPassesTestMixin, View):
|
||||||
# Instantiate Next Factor and pass request
|
# Instantiate Next Factor and pass request
|
||||||
factor = path_to_class(factor_class)
|
factor = path_to_class(factor_class)
|
||||||
self._current_factor = factor(self)
|
self._current_factor = factor(self)
|
||||||
|
self._current_factor.pending_user = self.pending_user
|
||||||
self._current_factor.request = request
|
self._current_factor.request = request
|
||||||
return super().dispatch(request, *args, **kwargs)
|
return super().dispatch(request, *args, **kwargs)
|
||||||
|
|
||||||
|
@ -93,7 +94,8 @@ class AuthenticationView(UserPassesTestMixin, View):
|
||||||
self.pending_factors
|
self.pending_factors
|
||||||
self.request.session[AuthenticationView.SESSION_FACTOR] = next_factor
|
self.request.session[AuthenticationView.SESSION_FACTOR] = next_factor
|
||||||
LOGGER.debug("Rendering Factor is %s", next_factor)
|
LOGGER.debug("Rendering Factor is %s", next_factor)
|
||||||
return redirect(reverse('passbook_core:auth-process', kwargs={'factor': next_factor}))
|
# return redirect(reverse('passbook_core:auth-process', kwargs={'factor': next_factor}))
|
||||||
|
return redirect(reverse('passbook_core:auth-process'))
|
||||||
# User passed all factors
|
# User passed all factors
|
||||||
LOGGER.debug("User passed all factors, logging in")
|
LOGGER.debug("User passed all factors, logging in")
|
||||||
return self._user_passed()
|
return self._user_passed()
|
||||||
|
@ -102,6 +104,7 @@ class AuthenticationView(UserPassesTestMixin, View):
|
||||||
"""Show error message, user cannot login.
|
"""Show error message, user cannot login.
|
||||||
This should only be shown if user authenticated successfully, but is disabled/locked/etc"""
|
This should only be shown if user authenticated successfully, but is disabled/locked/etc"""
|
||||||
LOGGER.debug("User invalid")
|
LOGGER.debug("User invalid")
|
||||||
|
self._cleanup()
|
||||||
return redirect(reverse('passbook_core:auth-denied'))
|
return redirect(reverse('passbook_core:auth-denied'))
|
||||||
|
|
||||||
def _user_passed(self):
|
def _user_passed(self):
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
"""passbook Core Application forms"""
|
"""passbook Core Application forms"""
|
||||||
from django import forms
|
from django import forms
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from passbook.core.models import Application, Provider
|
from passbook.core.models import Application, Provider
|
||||||
|
|
||||||
|
@ -19,3 +20,7 @@ class ApplicationForm(forms.ModelForm):
|
||||||
'launch_url': forms.TextInput(),
|
'launch_url': forms.TextInput(),
|
||||||
'icon_url': forms.TextInput(),
|
'icon_url': forms.TextInput(),
|
||||||
}
|
}
|
||||||
|
labels = {
|
||||||
|
'launch_url': _('Launch URL'),
|
||||||
|
'icon_url': _('Icon URL'),
|
||||||
|
}
|
||||||
|
|
|
@ -18,6 +18,11 @@ class LoginForm(forms.Form):
|
||||||
uid_field = forms.CharField(widget=forms.TextInput(attrs={'placeholder': _('UID')}))
|
uid_field = forms.CharField(widget=forms.TextInput(attrs={'placeholder': _('UID')}))
|
||||||
remember_me = forms.BooleanField(required=False)
|
remember_me = forms.BooleanField(required=False)
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
if CONFIG.y('passbook.uid_fields') == ['email']:
|
||||||
|
self.fields['uid_field'] = forms.EmailField()
|
||||||
|
|
||||||
def clean_uid_field(self):
|
def clean_uid_field(self):
|
||||||
"""Validate uid_field after EmailValidator if 'email' is the only selected uid_fields"""
|
"""Validate uid_field after EmailValidator if 'email' is the only selected uid_fields"""
|
||||||
if CONFIG.y('passbook.uid_fields') == ['email']:
|
if CONFIG.y('passbook.uid_fields') == ['email']:
|
||||||
|
|
|
@ -17,7 +17,7 @@ class FactorForm(forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
||||||
model = Factor
|
model = Factor
|
||||||
fields = ['name', 'slug', 'order', 'policies', 'type', 'enabled']
|
fields = ['name', 'slug', 'order', 'policies', 'type', 'enabled', 'arguments']
|
||||||
widgets = {
|
widgets = {
|
||||||
'type': forms.Select(choices=get_factors()),
|
'type': forms.Select(choices=get_factors()),
|
||||||
'name': forms.TextInput(),
|
'name': forms.TextInput(),
|
||||||
|
|
|
@ -0,0 +1,28 @@
|
||||||
|
# Generated by Django 2.1.7 on 2019-02-21 12:01
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('passbook_core', '0004_auto_20190216_1013'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='policy',
|
||||||
|
name='created',
|
||||||
|
field=models.DateTimeField(auto_now_add=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='policymodel',
|
||||||
|
name='created',
|
||||||
|
field=models.DateTimeField(auto_now_add=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='usersourceconnection',
|
||||||
|
name='created',
|
||||||
|
field=models.DateTimeField(auto_now_add=True),
|
||||||
|
),
|
||||||
|
]
|
|
@ -0,0 +1,19 @@
|
||||||
|
# Generated by Django 2.1.7 on 2019-02-21 12:32
|
||||||
|
|
||||||
|
import django.contrib.postgres.fields.jsonb
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('passbook_core', '0005_auto_20190221_1201'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='factor',
|
||||||
|
name='arguments',
|
||||||
|
field=django.contrib.postgres.fields.jsonb.JSONField(default=dict),
|
||||||
|
),
|
||||||
|
]
|
|
@ -0,0 +1,19 @@
|
||||||
|
# Generated by Django 2.1.7 on 2019-02-21 12:33
|
||||||
|
|
||||||
|
import django.contrib.postgres.fields.jsonb
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('passbook_core', '0006_factor_arguments'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='factor',
|
||||||
|
name='arguments',
|
||||||
|
field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, default=dict),
|
||||||
|
),
|
||||||
|
]
|
|
@ -6,6 +6,7 @@ from time import sleep
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from django.contrib.auth.models import AbstractUser
|
from django.contrib.auth.models import AbstractUser
|
||||||
|
from django.contrib.postgres.fields import JSONField
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.urls import reverse_lazy
|
from django.urls import reverse_lazy
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
@ -69,6 +70,7 @@ class Factor(PolicyModel):
|
||||||
order = models.IntegerField()
|
order = models.IntegerField()
|
||||||
type = models.TextField(unique=True)
|
type = models.TextField(unique=True)
|
||||||
enabled = models.BooleanField(default=True)
|
enabled = models.BooleanField(default=True)
|
||||||
|
arguments = JSONField(default=dict, blank=True)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return "Factor %s" % self.slug
|
return "Factor %s" % self.slug
|
||||||
|
|
|
@ -32,7 +32,7 @@ class PolicyEngine:
|
||||||
"""Check policies for user"""
|
"""Check policies for user"""
|
||||||
signatures = []
|
signatures = []
|
||||||
kwargs = {
|
kwargs = {
|
||||||
'__password__': getattr(user, '__password__')
|
'__password__': getattr(user, '__password__', None)
|
||||||
}
|
}
|
||||||
for policy in self.policies:
|
for policy in self.policies:
|
||||||
signatures.append(_policy_engine_task.s(user.pk, policy.pk.hex, **kwargs))
|
signatures.append(_policy_engine_task.s(user.pk, policy.pk.hex, **kwargs))
|
||||||
|
|
|
@ -61,7 +61,7 @@ INSTALLED_APPS = [
|
||||||
'django.contrib.messages',
|
'django.contrib.messages',
|
||||||
'django.contrib.staticfiles',
|
'django.contrib.staticfiles',
|
||||||
'rest_framework',
|
'rest_framework',
|
||||||
'rest_framework_swagger',
|
'drf_yasg',
|
||||||
'passbook.core.apps.PassbookCoreConfig',
|
'passbook.core.apps.PassbookCoreConfig',
|
||||||
'passbook.admin.apps.PassbookAdminConfig',
|
'passbook.admin.apps.PassbookAdminConfig',
|
||||||
'passbook.api.apps.PassbookAPIConfig',
|
'passbook.api.apps.PassbookAPIConfig',
|
||||||
|
|
|
@ -26,7 +26,7 @@
|
||||||
<div class="login-pf-page">
|
<div class="login-pf-page">
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-sm-8 col-sm-offset-2 col-md-6 col-md-offset-3 col-lg-6 col-lg-offset-3">
|
<div class="col-sm-6 col-sm-offset-3 col-md-6 col-md-offset-3 col-lg-4 col-lg-offset-4">
|
||||||
<header class="login-pf-page-header">
|
<header class="login-pf-page-header">
|
||||||
<img class="login-pf-brand" style="max-height: 10rem;" src="{% static 'img/logo.svg' %}" alt="PatternFly logo" />
|
<img class="login-pf-brand" style="max-height: 10rem;" src="{% static 'img/logo.svg' %}" alt="PatternFly logo" />
|
||||||
{% if config.login.subtext %}
|
{% if config.login.subtext %}
|
||||||
|
|
|
@ -1,8 +1,4 @@
|
||||||
{% extends 'login/form.html' %}
|
{% extends 'login/factors/base.html' %}
|
||||||
|
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
|
||||||
{% block above_form %}
|
|
||||||
<span class="pficon pficon-unlocked"></span>
|
|
||||||
{% trans "This is a text" %}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
|
@ -0,0 +1,31 @@
|
||||||
|
{% extends 'login/form.html' %}
|
||||||
|
|
||||||
|
{% load i18n %}
|
||||||
|
{% load utils %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
{{ block.super }}
|
||||||
|
<style>
|
||||||
|
.login-pf-settings img {
|
||||||
|
max-height: 32px;
|
||||||
|
border-radius: 100%;
|
||||||
|
border-width: 1px;
|
||||||
|
border-color: #000;
|
||||||
|
}
|
||||||
|
.login-pf-settings a {
|
||||||
|
padding-top: 3px;
|
||||||
|
padding-bottom: 3px;
|
||||||
|
line-height: 32px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block above_form %}
|
||||||
|
<div class="form-group login-pf-settings">
|
||||||
|
<p class="form-control-static">
|
||||||
|
<img src="{% gravatar pending_user.email %}" alt="">
|
||||||
|
{{ pending_user.username }}
|
||||||
|
</p>
|
||||||
|
<a href="{% url 'passbook_core:auth-login' %}">{% trans 'Not you?' %}</a>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
|
@ -1,68 +0,0 @@
|
||||||
{% extends "base/skeleton.html" %}
|
|
||||||
|
|
||||||
{% load static %}
|
|
||||||
|
|
||||||
{% block body %}
|
|
||||||
<div class="login-pf-page">
|
|
||||||
<div class="container-fluid">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-sm-8 col-sm-offset-2 col-md-6 col-md-offset-3 col-lg-6 col-lg-offset-3">
|
|
||||||
<header class="login-pf-page-header">
|
|
||||||
<img class="login-pf-brand" src="{% static 'img/Logo_Horizontal_Reversed.svg' %}" alt=" logo" />
|
|
||||||
</header>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-sm-10 col-sm-offset-1 col-md-8 col-md-offset-2 col-lg-8 col-lg-offset-2">
|
|
||||||
<div class="card-pf">
|
|
||||||
<header class="login-pf-header">
|
|
||||||
<select class="selectpicker">
|
|
||||||
<option>English</option>
|
|
||||||
<option>French</option>
|
|
||||||
<option>Italian</option>
|
|
||||||
</select>
|
|
||||||
<h1>Single Sign-On</h1>
|
|
||||||
<p class="text-center">Log in to <strong>Application</strong></p>
|
|
||||||
</header>
|
|
||||||
<form>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="sr-only" for="exampleInputEmail1">Email address</label>
|
|
||||||
<input type="email" class="form-control input-lg" id="exampleInputEmail1" placeholder="Email address">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="sr-only" for="exampleInputPassword1">Password
|
|
||||||
</label>
|
|
||||||
<input type="password" class="form-control input-lg" id="exampleInputPassword1" placeholder="Password">
|
|
||||||
</div>
|
|
||||||
<div class="form-group login-pf-settings">
|
|
||||||
<label class="checkbox-label">
|
|
||||||
<input type="checkbox"> Keep me logged in for 30 days
|
|
||||||
</label>
|
|
||||||
<a href="#">Forgot password?</a>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn btn-primary btn-block btn-lg">Log In</button>
|
|
||||||
</form>
|
|
||||||
<p class="login-pf-signup">Need an account?<a href="#">Sign up</a></p>
|
|
||||||
</div><!-- card -->
|
|
||||||
<footer class="login-pf-page-footer">
|
|
||||||
<div class="login-pf-page-footer-sso-services">
|
|
||||||
<p>One account for all your company services</p>
|
|
||||||
<ul class="login-pf-page-footer-sso-services-logos">
|
|
||||||
<li><img src="{% static 'img/google-drive.svg' %}" alt="google drive icon" /></li>
|
|
||||||
<li><img src="{% static 'img/gmail.svg' %}" alt="gmail icon" /></li>
|
|
||||||
<li><img src="{% static 'img/google-calendar.svg' %}" alt="google calendar icon" /></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<ul class="login-pf-page-footer-links list-unstyled">
|
|
||||||
<li><a class="login-pf-page-footer-link" href="#">Terms of Use</a></li>
|
|
||||||
<li><a class="login-pf-page-footer-link" href="#">Help</a></li>
|
|
||||||
<li><a class="login-pf-page-footer-link" href="#">Privacy Policy</a></li>
|
|
||||||
</ul>
|
|
||||||
</footer>
|
|
||||||
</div><!-- col -->
|
|
||||||
</div><!-- row -->
|
|
||||||
</div><!-- col -->
|
|
||||||
</div><!-- login-pf-page -->
|
|
||||||
</div>
|
|
||||||
<!--row-->
|
|
||||||
</div>
|
|
||||||
<!--container-->
|
|
||||||
{% endblock %}
|
|
|
@ -14,7 +14,7 @@
|
||||||
<span class="icon-bar"></span>
|
<span class="icon-bar"></span>
|
||||||
</button>
|
</button>
|
||||||
<a class="navbar-brand" href="/">
|
<a class="navbar-brand" href="/">
|
||||||
<img src="{% static 'img/brand.svg' %}" alt="PatternFly Enterprise Application" />
|
<img src="{% static 'img/brand.svg' %}" alt="passbook" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="collapse navbar-collapse navbar-collapse-1">
|
<div class="collapse navbar-collapse navbar-collapse-1">
|
||||||
|
@ -63,7 +63,9 @@
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="container-fluid container-cards-pf">
|
<div class="container-fluid container-cards-pf">
|
||||||
{% include 'partials/messages.html' %}
|
<div class="container">
|
||||||
|
{% include 'partials/messages.html' %}
|
||||||
|
</div>
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -9,10 +9,18 @@
|
||||||
<div class="nav-category">
|
<div class="nav-category">
|
||||||
<h2>{% trans 'User Profile'%}</h2>
|
<h2>{% trans 'User Profile'%}</h2>
|
||||||
<ul class="nav nav-pills nav-stacked">
|
<ul class="nav nav-pills nav-stacked">
|
||||||
<li class="{% is_active 'passbook_core:user-settings' %}"><a href="{% url 'passbook_core:user-settings' %}"><i class="fa fa-desktop"></i>{% trans 'Details' %}</a></li>
|
<li class="{% is_active 'passbook_core:user-settings' %}">
|
||||||
<li><a href="#"><i class="fa fa-cog"></i>System Services</a></li>
|
<a href="{% url 'passbook_core:user-settings' %}">
|
||||||
<li><a href="#"><i class="fa fa-file-text-o"></i>Journal</a></li>
|
<i class="fa fa-desktop"></i> {% trans 'Details' %}
|
||||||
<li><a href="#"><i class="fa fa-cloud"></i>Storage</a></li>
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="{% is_active 'passbook_core:user-settings' %}">
|
||||||
|
<a href="{% url 'passbook_core:user-settings' %}">
|
||||||
|
<i class="pficon pficon-locked"></i> {% trans 'Change Password' %}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li><a href="#"><i class="fa fa-file-text-o"></i> Journal</a></li>
|
||||||
|
<li><a href="#"><i class="fa fa-cloud"></i> Storage</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -0,0 +1,14 @@
|
||||||
|
{% extends "user/base.html" %}
|
||||||
|
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block page %}
|
||||||
|
<h1>{% trans 'Change Password' %}</h1>
|
||||||
|
<form action="" method="post" class="form-horizontal">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% include 'partials/form.html' %}
|
||||||
|
<input class="btn btn-primary" type="submit" value="{% trans 'Update' %}">
|
||||||
|
<a class="btn btn-danger"
|
||||||
|
href="{% url 'passbook_core:user-delete' %}?back={{ request.get_full_path }}">{% trans 'Delete user' %}</a>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
|
@ -13,6 +13,7 @@ class OverviewView(LoginRequiredMixin, TemplateView):
|
||||||
template_name = 'overview/index.html'
|
template_name = 'overview/index.html'
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
|
print(self.request.session.keys())
|
||||||
kwargs['applications'] = self.request.user.applications.all()
|
kwargs['applications'] = self.request.user.applications.all()
|
||||||
if self.request.user.is_superuser:
|
if self.request.user.is_superuser:
|
||||||
kwargs['applications'] = Application.objects.all()
|
kwargs['applications'] = Application.objects.all()
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
"""passbook LDAP Forms"""
|
"""passbook LDAP Forms"""
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from passbook.admin.forms.source import SOURCE_FORM_FIELDS
|
from passbook.admin.forms.source import SOURCE_FORM_FIELDS
|
||||||
from passbook.ldap.models import LDAPSource
|
from passbook.ldap.models import LDAPSource
|
||||||
|
@ -14,7 +15,7 @@ class LDAPSourceForm(forms.ModelForm):
|
||||||
model = LDAPSource
|
model = LDAPSource
|
||||||
fields = SOURCE_FORM_FIELDS + ['server_uri', 'bind_cn', 'bind_password',
|
fields = SOURCE_FORM_FIELDS + ['server_uri', 'bind_cn', 'bind_password',
|
||||||
'type', 'domain', 'base_dn', 'create_user',
|
'type', 'domain', 'base_dn', 'create_user',
|
||||||
'reset_password', 'policies']
|
'reset_password']
|
||||||
widgets = {
|
widgets = {
|
||||||
'name': forms.TextInput(),
|
'name': forms.TextInput(),
|
||||||
'server_uri': forms.TextInput(),
|
'server_uri': forms.TextInput(),
|
||||||
|
@ -23,6 +24,11 @@ class LDAPSourceForm(forms.ModelForm):
|
||||||
'domain': forms.TextInput(),
|
'domain': forms.TextInput(),
|
||||||
'base_dn': forms.TextInput(),
|
'base_dn': forms.TextInput(),
|
||||||
}
|
}
|
||||||
|
labels = {
|
||||||
|
'server_uri': _('Server URI'),
|
||||||
|
'bind_cn': _('Bind CN'),
|
||||||
|
'base_dn': _('Base DN'),
|
||||||
|
}
|
||||||
|
|
||||||
# class GeneralSettingsForm(SettingsForm):
|
# class GeneralSettingsForm(SettingsForm):
|
||||||
# """general settings form"""
|
# """general settings form"""
|
||||||
|
|
|
@ -59,6 +59,7 @@ passbook:
|
||||||
uid_fields:
|
uid_fields:
|
||||||
- username
|
- username
|
||||||
- email
|
- email
|
||||||
|
# Factors to load
|
||||||
factors:
|
factors:
|
||||||
- passbook.core.auth.factors.backend
|
- passbook.core.auth.factors.backend
|
||||||
- passbook.core.auth.factors.dummy
|
- passbook.core.auth.factors.dummy
|
||||||
|
@ -67,22 +68,6 @@ passbook:
|
||||||
remember_age: 2592000 # 60 * 60 * 24 * 30, one month
|
remember_age: 2592000 # 60 * 60 * 24 * 30, one month
|
||||||
# Provider-specific settings
|
# Provider-specific settings
|
||||||
ldap:
|
ldap:
|
||||||
# # Completely enable or disable LDAP provider
|
|
||||||
# enabled: false
|
|
||||||
# # AD Domain, used to generate `userPrincipalName`
|
|
||||||
# domain: corp.contoso.com
|
|
||||||
# # Base DN in which passbook should look for users
|
|
||||||
# base_dn: dn=corp,dn=contoso,dn=com
|
|
||||||
# # LDAP field which is used to set the django username
|
|
||||||
# username_field: sAMAccountName
|
|
||||||
# # LDAP server to connect to, can be set to `<domain_name>`
|
|
||||||
# server:
|
|
||||||
# name: corp.contoso.com
|
|
||||||
# use_tls: false
|
|
||||||
# # Bind credentials, used for account creation
|
|
||||||
# bind:
|
|
||||||
# username: Administraotr@corp.contoso.com
|
|
||||||
# password: VerySecurePassword!
|
|
||||||
# Which field from `uid_fields` maps to which LDAP Attribute
|
# Which field from `uid_fields` maps to which LDAP Attribute
|
||||||
login_field_map:
|
login_field_map:
|
||||||
username: sAMAccountName
|
username: sAMAccountName
|
||||||
|
@ -93,10 +78,6 @@ ldap:
|
||||||
mail: email
|
mail: email
|
||||||
given_name: first_name
|
given_name: first_name
|
||||||
name: last_name
|
name: last_name
|
||||||
# # Create new users in LDAP upon sign-up
|
|
||||||
# create_users: true
|
|
||||||
# # Reset LDAP password when user reset their password
|
|
||||||
# reset_password: true
|
|
||||||
oauth_client:
|
oauth_client:
|
||||||
# List of python packages with sources types to load.
|
# List of python packages with sources types to load.
|
||||||
types:
|
types:
|
||||||
|
@ -108,7 +89,6 @@ oauth_client:
|
||||||
- passbook.oauth_client.source_types.supervisr
|
- passbook.oauth_client.source_types.supervisr
|
||||||
- passbook.oauth_client.source_types.twitter
|
- passbook.oauth_client.source_types.twitter
|
||||||
saml_idp:
|
saml_idp:
|
||||||
issuer: passbook
|
|
||||||
# List of python packages with provider types to load.
|
# List of python packages with provider types to load.
|
||||||
types:
|
types:
|
||||||
- passbook.saml_idp.processors.generic
|
- passbook.saml_idp.processors.generic
|
||||||
|
|
|
@ -6,7 +6,7 @@ from django.db import models
|
||||||
|
|
||||||
class CreatedUpdatedModel(models.Model):
|
class CreatedUpdatedModel(models.Model):
|
||||||
"""Base Abstract Model to save created and update"""
|
"""Base Abstract Model to save created and update"""
|
||||||
created = models.DateField(auto_now_add=True)
|
created = models.DateTimeField(auto_now_add=True)
|
||||||
last_updated = models.DateTimeField(auto_now=True)
|
last_updated = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
|
@ -2,8 +2,9 @@
|
||||||
import glob
|
import glob
|
||||||
import os
|
import os
|
||||||
import socket
|
import socket
|
||||||
|
from hashlib import md5
|
||||||
from importlib import import_module
|
from importlib import import_module
|
||||||
from urllib.parse import urljoin
|
from urllib.parse import urlencode, urljoin
|
||||||
|
|
||||||
from django import template
|
from django import template
|
||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
|
@ -11,6 +12,7 @@ from django.conf import settings
|
||||||
from django.db.models import Model
|
from django.db.models import Model
|
||||||
from django.template.loaders.app_directories import get_app_template_dirs
|
from django.template.loaders.app_directories import get_app_template_dirs
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from django.utils.html import escape
|
||||||
from django.utils.translation import ugettext as _
|
from django.utils.translation import ugettext as _
|
||||||
|
|
||||||
from passbook.lib.config import CONFIG
|
from passbook.lib.config import CONFIG
|
||||||
|
@ -178,3 +180,30 @@ def app_versions():
|
||||||
ver = '.'.join([str(x) for x in ver])
|
ver = '.'.join([str(x) for x in ver])
|
||||||
app_versions[app.verbose_name] = ver
|
app_versions[app.verbose_name] = ver
|
||||||
return app_versions
|
return app_versions
|
||||||
|
|
||||||
|
@register.simple_tag
|
||||||
|
def gravatar(email, size=None, rating=None):
|
||||||
|
"""
|
||||||
|
Generates a Gravatar URL for the given email address.
|
||||||
|
|
||||||
|
Syntax::
|
||||||
|
|
||||||
|
{% gravatar <email> [size] [rating] %}
|
||||||
|
|
||||||
|
Example::
|
||||||
|
|
||||||
|
{% gravatar someone@example.com 48 pg %}
|
||||||
|
"""
|
||||||
|
# gravatar uses md5 for their URLs, so md5 can't be avoided
|
||||||
|
gravatar_url = "%savatar/%s" % ('https://secure.gravatar.com/',
|
||||||
|
md5(email.encode('utf-8')).hexdigest()) # nosec
|
||||||
|
|
||||||
|
parameters = [p for p in (
|
||||||
|
('s', size or '158'),
|
||||||
|
('r', rating or 'g'),
|
||||||
|
) if p[1]]
|
||||||
|
|
||||||
|
if parameters:
|
||||||
|
gravatar_url += '?' + urlencode(parameters, doseq=True)
|
||||||
|
|
||||||
|
return escape(gravatar_url)
|
||||||
|
|
|
@ -19,7 +19,7 @@ class OAuthSource(Source):
|
||||||
consumer_key = models.TextField()
|
consumer_key = models.TextField()
|
||||||
consumer_secret = models.TextField()
|
consumer_secret = models.TextField()
|
||||||
|
|
||||||
form = 'passbook.oauth_client.forms.GitHubOAuthSourceForm'
|
form = 'passbook.oauth_client.forms.OAuthSourceForm'
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_link(self):
|
def is_link(self):
|
||||||
|
|
Reference in New Issue