Browse Source

Merge branch 'fix_shaper' into devel

devel
Dmitry Novikov 7 years ago
parent
commit
93f8fdde3b
  1. 30
      abonapp/forms.py
  2. 20
      abonapp/locale/ru/LC_MESSAGES/django.po
  3. 51
      abonapp/migrations/0006_change_ip.py
  4. 61
      abonapp/models.py
  5. 83
      abonapp/templates/abonapp/editAbon.html
  6. 24
      abonapp/templates/abonapp/modal_add_lease.html
  7. 27
      abonapp/templates/abonapp/modal_ip_form.html
  8. 7
      abonapp/urls.py
  9. 191
      abonapp/views.py
  10. 6
      accounts_app/templatetags/acc_tags.py
  11. 37
      agent/commands/dhcp.py
  12. 2
      dhcp_lever.py
  13. 8
      djing/lib/auth_backends.py
  14. 9
      djing/lib/mixins.py
  15. 1
      ip_pool/admin.py
  16. 38
      ip_pool/forms.py
  17. 43
      ip_pool/migrations/0003_auto_20181015_1430.py
  18. 61
      ip_pool/models.py
  19. 44
      ip_pool/templates/ip_pool/ip_leases_list.html
  20. 4
      ip_pool/templates/ip_pool/net_edit.html
  21. 6
      ip_pool/templates/ip_pool/network_list.html
  22. 1
      ip_pool/urls.py
  23. 17
      ip_pool/views.py
  24. 2
      nas_app/nas_managers/__init__.py
  25. 80
      nas_app/nas_managers/core.py
  26. 388
      nas_app/nas_managers/mod_mikrotik.py
  27. 110
      nas_app/nas_managers/structs.py
  28. 4
      nas_app/views.py
  29. 10
      periodic.py
  30. 2
      searchapp/views.py
  31. 12
      systemd_units/djing_backup.service
  32. 11
      systemd_units/djing_backup.timer
  33. 8
      systemd_units/do_backup.sh

30
abonapp/forms.py

@ -4,6 +4,8 @@ from django.contrib.auth.hashers import make_password
from random import choice from random import choice
from string import digits, ascii_lowercase from string import digits, ascii_lowercase
from djing.lib import LogicError
from ip_pool.models import NetworkModel
from nas_app.models import NASModel from nas_app.models import NASModel
from . import models from . import models
from django.conf import settings from django.conf import settings
@ -166,5 +168,31 @@ class MarkersForm(forms.ModelForm):
class AmountMoneyForm(forms.Form): class AmountMoneyForm(forms.Form):
amount = forms.FloatField(max_value=50000, label=_('Amount of money'))
amount = forms.FloatField(max_value=5000, label=_('Amount of money'))
comment = forms.CharField(max_length=128, label=_('Comment'), required=False) comment = forms.CharField(max_length=128, label=_('Comment'), required=False)
class AddIpForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
instance = getattr(self, 'instance')
if instance:
if instance.group:
self.fields['networks'].queryset = NetworkModel.objects.filter(groups=instance.group)
if not self.initial['ip_address']:
if instance:
net = NetworkModel.objects.filter(groups=instance.group).first()
if net is not None:
ips = (ip.ip_address for ip in
models.Abon.objects.filter(group=instance.group).order_by('ip_address').only(
'ip_address').iterator())
free_ip = net.get_free_ip(ips)
self.initial['ip_address'] = free_ip
else:
raise LogicError(_('Subnet has not attached to current group'))
networks = forms.ModelChoiceField(label=_('Networks'), queryset=NetworkModel.objects.none(), empty_label=None)
class Meta:
model = models.Abon
fields = 'ip_address',

20
abonapp/locale/ru/LC_MESSAGES/django.po

@ -116,8 +116,8 @@ msgid "User group"
msgstr "Группа" msgstr "Группа"
#: models.py:96 templates/abonapp/editAbon.html:180 #: models.py:96 templates/abonapp/editAbon.html:180
msgid "Ip addresses"
msgstr "IP Адреса"
msgid "Ip address"
msgstr "IP Адрес"
#: models.py:103 #: models.py:103
msgid "Network access server" msgid "Network access server"
@ -202,11 +202,6 @@ msgstr "Не хватает денег на счету"
msgid "Buy service default log" msgid "Buy service default log"
msgstr "Покупка тарифного плана через админку" msgstr "Покупка тарифного плана через админку"
#: models.py:228
#, python-format
msgid "Account \"%(username)s\" not have any active leases"
msgstr "Учётная запись \"%(username)s\" не имеет ни одной активной сессии"
#: models.py:238 models.py:255 models.py:272 views.py:684 views.py:1132 #: models.py:238 models.py:255 models.py:272 views.py:684 views.py:1132
#: views.py:1175 #: views.py:1175
msgid "NAS required" msgid "NAS required"
@ -515,10 +510,6 @@ msgstr "Выделен из:"
msgid "From" msgid "From"
msgstr "От" msgstr "От"
#: templates/abonapp/editAbon.html:218
msgid "Leases does not found"
msgstr "Аренды ip не найдены"
#: templates/abonapp/editAbon.html:230 #: templates/abonapp/editAbon.html:230
#: templates/abonapp/modal_add_lease.html:16 #: templates/abonapp/modal_add_lease.html:16
#: templates/abonapp/modal_add_phone.html:27 #: templates/abonapp/modal_add_phone.html:27
@ -529,8 +520,8 @@ msgid "Add"
msgstr "Добавить" msgstr "Добавить"
#: templates/abonapp/editAbon.html:234 #: templates/abonapp/editAbon.html:234
msgid "Active networks"
msgstr "Активные подсети"
msgid "Networks"
msgstr "Подсети"
#: templates/abonapp/editAbon.html:242 #: templates/abonapp/editAbon.html:242
msgid "User flags" msgid "User flags"
@ -1159,3 +1150,6 @@ msgstr "Автопродление услуги."
msgid "No author attached" msgid "No author attached"
msgstr "Автор не назначен" msgstr "Автор не назначен"
msgid "User not have ip"
msgstr "У пользователя нет ip"

51
abonapp/migrations/0006_change_ip.py

@ -0,0 +1,51 @@
# Generated by Django 2.1 on 2018-10-10 16:11
# from json import dump
from django.db import migrations, models
TMP_FILE = '/tmp/migrate_ip.json'
DUMP = []
def backup_info(apps, _):
Abon = apps.get_model('abonapp', 'Abon')
abons = Abon.objects.annotate(
addr_count=models.Count('ip_addresses')
).filter(addr_count__gt=0).only('ip_addresses').iterator()
global DUMP
for abon in abons:
ip_addr = abon.ip_addresses.first()
DUMP.append({
'pk': abon.pk,
'addr': ip_addr.ip
})
# with open(TMP_FILE, 'w') as f:
# dump(r, f, indent=2)
def restore_ips(apps, _):
Abon = apps.get_model('abonapp', 'Abon')
for abon in DUMP:
Abon.objects.filter(pk=abon.get('pk')).update(ip_address=abon.get('addr'))
class Migration(migrations.Migration):
dependencies = [
('abonapp', '0005_current_tariff'),
]
operations = [
migrations.RunPython(backup_info),
migrations.RemoveField(
model_name='abon',
name='ip_addresses',
),
migrations.AddField(
model_name='abon',
name='ip_address',
field=models.GenericIPAddressField(blank=True, null=True, unique=True, verbose_name='Ip address'),
),
migrations.RunPython(restore_ips)
]

61
abonapp/models.py

@ -1,5 +1,4 @@
from datetime import datetime from datetime import datetime
from ipaddress import ip_address
from typing import Optional from typing import Optional
from django.conf import settings from django.conf import settings
@ -13,10 +12,10 @@ from django.utils import timezone
from django.utils.translation import ugettext_lazy as _, gettext from django.utils.translation import ugettext_lazy as _, gettext
from accounts_app.models import UserProfile, MyUserManager, BaseAccount from accounts_app.models import UserProfile, MyUserManager, BaseAccount
from nas_app.nas_managers import AbonStruct, TariffStruct, NasFailedResult, NasNetworkError
from nas_app.nas_managers import SubnetQueue, NasFailedResult, NasNetworkError
from group_app.models import Group from group_app.models import Group
from djing.lib import LogicError from djing.lib import LogicError
from ip_pool.models import IpLeaseModel, NetworkModel
from ip_pool.models import NetworkModel
from tariff_app.models import Tariff, PeriodicPay from tariff_app.models import Tariff, PeriodicPay
from bitfield import BitField from bitfield import BitField
@ -90,7 +89,7 @@ class Abon(BaseAccount):
current_tariff = models.OneToOneField(AbonTariff, null=True, blank=True, on_delete=models.SET_NULL, default=None) current_tariff = models.OneToOneField(AbonTariff, null=True, blank=True, on_delete=models.SET_NULL, default=None)
group = models.ForeignKey(Group, on_delete=models.SET_NULL, blank=True, null=True, verbose_name=_('User group')) group = models.ForeignKey(Group, on_delete=models.SET_NULL, blank=True, null=True, verbose_name=_('User group'))
ballance = models.FloatField(default=0.0) ballance = models.FloatField(default=0.0)
ip_addresses = models.ManyToManyField(IpLeaseModel, verbose_name=_('Ip addresses'))
ip_address = models.GenericIPAddressField(verbose_name=_('Ip address'), unique=True, null=True, blank=True)
description = models.TextField(_('Comment'), null=True, blank=True) description = models.TextField(_('Comment'), null=True, blank=True)
street = models.ForeignKey(AbonStreet, on_delete=models.SET_NULL, null=True, blank=True, verbose_name=_('Street')) street = models.ForeignKey(AbonStreet, on_delete=models.SET_NULL, null=True, blank=True, verbose_name=_('Street'))
house = models.CharField(_('House'), max_length=12, null=True, blank=True) house = models.CharField(_('House'), max_length=12, null=True, blank=True)
@ -198,6 +197,25 @@ class Abon(BaseAccount):
comment=comment or _('Buy service default log') comment=comment or _('Buy service default log')
) )
def attach_ip_addr(self, ip, strict=False):
"""
Attach ip address to account
:param ip: Instance of str or ip_address
:param strict: If strict is True then ip not replaced quietly
:return: None
"""
if strict and self.ip_address:
raise LogicError('Ip address already exists')
self.ip_address = ip
self.save(update_fields=('ip_address',))
def free_ip_addr(self) -> bool:
if self.ip_address:
self.ip_address = None
self.save(update_fields=('ip_address',))
return True
return False
# is subscriber have access to service, view in tariff_app.custom_tariffs.<TariffBase>.manage_access() # is subscriber have access to service, view in tariff_app.custom_tariffs.<TariffBase>.manage_access()
def is_access(self) -> bool: def is_access(self) -> bool:
if not self.is_active: if not self.is_active:
@ -210,22 +228,19 @@ class Abon(BaseAccount):
return ct.manage_access(self) return ct.manage_access(self)
# make subscriber from agent structure # make subscriber from agent structure
def build_agent_struct(self, raise_errs=True):
abon_addresses = tuple(ip_address(i.ip) for i in self.ip_addresses.filter(is_active=True))
# if not abon_addresses:
# return
def build_agent_struct(self):
if not self.ip_address:
return
abon_tariff = self.active_tariff() abon_tariff = self.active_tariff()
if abon_tariff is None:
agent_trf = None
else:
trf = abon_tariff.tariff
agent_trf = TariffStruct(trf.id, trf.speedIn, trf.speedOut)
if len(abon_addresses) > 0:
return AbonStruct(self.pk, abon_addresses, agent_trf, self.is_access())
if raise_errs:
raise LogicError(_('Account "%(username)s" not have any active leases') % {
'username': self.username
})
if abon_tariff:
abon_tariff = abon_tariff.tariff
return SubnetQueue(
name="uid%d" % self.pk,
network=self.ip_address,
max_limit=(abon_tariff.speedIn, abon_tariff.speedOut),
queue_type=SubnetQueue.QUEUE_LEAF,
is_access=self.is_access()
)
def nas_sync_self(self) -> Optional[Exception]: def nas_sync_self(self) -> Optional[Exception]:
""" """
@ -281,14 +296,6 @@ class Abon(BaseAccount):
def get_absolute_url(self): def get_absolute_url(self):
return resolve_url('abonapp:abon_home', self.group.id, self.username) return resolve_url('abonapp:abon_home', self.group.id, self.username)
def add_lease(self, ip: str, network: Optional[NetworkModel], mac_addr=None):
existed_client_ips = tuple(l.ip for l in self.ip_addresses.all())
if ip not in existed_client_ips:
lease = IpLeaseModel.objects.create_from_ip(ip=ip, net=network, mac=mac_addr)
if lease is None:
return 'Error while creating a ip lease'
self.ip_addresses.add(lease)
def enable_service(self, tariff: Tariff, deadline=None, time_start=None): def enable_service(self, tariff: Tariff, deadline=None, time_start=None):
""" """
Makes a services for current user Makes a services for current user

83
abonapp/templates/abonapp/editAbon.html

@ -46,9 +46,6 @@
<script type="text/javascript"> <script type="text/javascript">
$(function () { $(function () {
$("#iprefreshbtn").on('click', function(){
$("#{{ form.ip_address.id_for_label }}").val('');
});
$('#passwdtoggler').on('mousedown', function(){ $('#passwdtoggler').on('mousedown', function(){
document.getElementById("{{ form.password.id_for_label }}").type='text'; document.getElementById("{{ form.password.id_for_label }}").type='text';
}).on('mouseup', function(){ }).on('mouseup', function(){
@ -182,61 +179,45 @@
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"> <div class="panel-heading">
<h3 class="panel-title">{% trans 'Ip addresses' %}</h3>
<h3 class="panel-title">{% trans 'Ip address' %}</h3>
</div>
<div class="panel-body">
{% if abon.ip_address %}
<div class="btn-group btn-group-xs">
<a href="{% url 'abonapp:user_session_free' group.pk abon.username %}" class="btn btn-danger" title="{% trans 'Free session' %}" data-toggle="tooltip">
<span class="glyphicon glyphicon-remove"></span>
</a>
<b>{{ abon.ip_address }}</b>
{% if perms.abonapp.can_ping %}
<a href="{% url 'abonapp:ping' group.pk abon.username %}" class="btn btn-default btn-cmd" title="Ping" data-param="{{ abon.ip_address }}">
<span class="glyphicon glyphicon-flash"></span> Ping
</a>
{% else %}
<a href="#" class="btn btn-default disabled" title="{% trans 'Permission denied' %}">
<span class="glyphicon glyphicon-flash"></span> Ping
</a>
{% endif %}
</div>
{% else %}
<span class="text-info">{% trans 'No ip address' %}</span>
{% endif %}
</div> </div>
<ul class="list-group">
{% with can_ping=perms.abonapp.can_ping %}
{% for lease in abon.ip_addresses.all %}
<li class="list-group-item">
<div class="btn-group btn-group-xs">
{% if lease.is_active %}
<a href="{% url 'abonapp:user_session_free' group.pk abon.username lease.pk %}" class="btn btn-danger" title="{% trans 'Free session' %}" data-toggle="tooltip">
<span class="glyphicon glyphicon-remove"></span>
</a>
{% else %}
<a href="{% url 'abonapp:user_session_start' group.pk abon.username lease.pk %}" class="btn btn-success" title="{% trans 'Start session' %}" data-toggle="tooltip">
<span class="glyphicon glyphicon-flash"></span>
</a>
{% endif %}
{% if can_ping %}
<a href="{% url 'abonapp:ping' group.pk abon.username %}" class="btn btn-default btn-cmd" title="Ping" data-param="{{ lease.ip }}">
<span class="glyphicon glyphicon-flash"></span> Ping
</a>
{% else %}
<a href="#" class="btn btn-default disabled" title="{% trans 'Permission denied' %}">
<span class="glyphicon glyphicon-flash"></span> Ping
</a>
{% endif %}
</div>
<span{{ lease.is_active|yesno:', class="text-muted"'|safe }}>
<b>{{ lease }}</b>
<small>{% trans 'Leased by:' %} {{ lease.lease_time|date:'d-m H:i:s' }}.
{% if lease.mac_addr %}
{% trans 'From' %}: {{ lease.mac_addr }}.
{% endif %}
</small>
</span>
</li>
{% empty %}
<li class="list-group-item">{% trans 'Leases does not found' %}</li>
{% endfor %}
{% endwith %}
</ul>
<div class="panel-footer"> <div class="panel-footer">
<div class="btn-group btn-group-sm"> <div class="btn-group btn-group-sm">
{% if abon.is_dynamic_ip %}
<a href="#" class="btn btn-success" disabled>
{% if abon.ip_address %}
<a href="{% url 'abonapp:update_ip' group.pk abon.username %}" class="btn btn-primary btn-modal">
<span class="glyphicon glyphicon-edit"></span>
<span class="hidden-xs">{% trans 'Change' %}</span>
</a>
{% else %} {% else %}
<a href="{% url 'abonapp:lease_add' group.pk abon.username %}" class="btn btn-success btn-modal">
<a href="{% url 'abonapp:update_ip' group.pk abon.username %}" class="btn btn-success btn-modal">
<span class="glyphicon glyphicon-plus"></span>
<span class="hidden-xs">{% trans 'Add' %}</span>
</a>
{% endif %} {% endif %}
<span class="glyphicon glyphicon-plus"></span>
<span class="hidden-xs">{% trans 'Add' %}</span>
</a>
<a href="{% url 'abonapp:active_nets' group.pk %}" class="btn btn-default btn-modal"> <a href="{% url 'abonapp:active_nets' group.pk %}" class="btn btn-default btn-modal">
<span class="glyphicon glyphicon-globe"></span> <span class="glyphicon glyphicon-globe"></span>
<span class="hidden-sm hidden-xs">{% trans 'Active networks' %}</span>
<span class="hidden-sm hidden-xs">{% trans 'Networks' %}</span>
</a> </a>
</div> </div>
</div> </div>

24
abonapp/templates/abonapp/modal_add_lease.html

@ -1,24 +0,0 @@
{% extends request.is_ajax|yesno:'nullcont.htm,abonapp/ext.htm' %}
{% load i18n bootstrap3 %}
{% block content %}
<form role="form" action="{% url 'abonapp:lease_add' group.id uname %}" method="post"> {% csrf_token %}
<div class="modal-header primary">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
<h4 class="modal-title"><span class="glyphicon glyphicon-compressed"></span>{% trans 'Add ip lease' %}</h4>
</div>
<div class="modal-body">
{% bootstrap_form form %}
<div class="btn-group">
<button type="submit" class="btn btn-success">
<span class="glyphicon glyphicon-plus"></span> {% trans 'Add' %}
</button>
<button type="reset" class="btn btn-default">
<span class="glyphicon glyphicon-remove-circle"></span> {% trans 'Reset' %}
</button>
</div>
</div>
</form>
{% endblock %}

27
abonapp/templates/abonapp/modal_ip_form.html

@ -0,0 +1,27 @@
{% extends request.is_ajax|yesno:'bajax.html,base.html' %}
{% load i18n bootstrap3 %}
{% block breadcrumb %}
<ol class="breadcrumb">
<li><span class="glyphicon glyphicon-home"></span></li>
<li><a href="{% url 'abonapp:group_list' %}">{% trans 'User groups' %}</a></li>
<li><a href="{% url 'abonapp:people_list' group.id %}">{{ group.title }}</a></li>
<li><a href="{% url 'abonapp:abon_home' group.id abon.username %}">{{ abon.fio }}</a></li>
<li class="active">{% trans 'Update ip address' %}</li>
</ol>
{% endblock %}
{% block main %}
<form role="form" action="{% url 'abonapp:update_ip' group.id object.username %}" method="post"> {% csrf_token %}
<div class="modal-header primary">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
<h4 class="modal-title"><span class="glyphicon glyphicon-plus"></span>{% trans 'Update ip address' %}</h4>
</div>
<div class="modal-body">
{% bootstrap_form form %}
<button type="submit" class="btn btn-sm btn-primary">
<span class="glyphicon glyphicon-save"></span> {% trans 'Save' %}
</button>
</div>
</form>
{% endblock %}

7
abonapp/urls.py

@ -26,14 +26,13 @@ subscriber_patterns = [
path('tel/add/', views.tel_add, name='telephone_new'), path('tel/add/', views.tel_add, name='telephone_new'),
path('tel/del/', views.tel_del, name='telephone_del'), path('tel/del/', views.tel_del, name='telephone_del'),
path('markers/', views.EditSibscriberMarkers.as_view(), name='markers_edit'), path('markers/', views.EditSibscriberMarkers.as_view(), name='markers_edit'),
path('session/<int:lease_id>/free/', views.user_session_toggle, {'action': 'free'}, name='user_session_free'),
path('session/<int:lease_id>/start/', views.user_session_toggle, {'action': 'start'}, name='user_session_start'),
path('session/free/', views.user_session_free, name='user_session_free'),
path('periodic_pay/', views.add_edit_periodic_pay, name='add_periodic_pay'), path('periodic_pay/', views.add_edit_periodic_pay, name='add_periodic_pay'),
path('periodic_pay/<int:periodic_pay_id>/', views.add_edit_periodic_pay, name='add_periodic_pay'), path('periodic_pay/<int:periodic_pay_id>/', views.add_edit_periodic_pay, name='add_periodic_pay'),
path('periodic_pay/<int:periodic_pay_id>/del/', views.del_periodic_pay, name='del_periodic_pay'), path('periodic_pay/<int:periodic_pay_id>/del/', views.del_periodic_pay, name='del_periodic_pay'),
path('lease/add/', views.lease_add, name='lease_add'),
path('ping/', views.abon_ping, name='ping'), path('ping/', views.abon_ping, name='ping'),
path('set_auto_continue_service/', views.set_auto_continue_service, name='set_auto_continue_service')
path('set_auto_continue_service/', views.set_auto_continue_service, name='set_auto_continue_service'),
path('update_ip/', views.IpUpdateView.as_view(), name='update_ip')
] ]
group_patterns = [ group_patterns = [

191
abonapp/views.py

@ -1,4 +1,3 @@
from ipaddress import ip_address
from typing import Dict, Optional from typing import Dict, Optional
from datetime import datetime, date from datetime import datetime, date
from django.core.exceptions import PermissionDenied, ValidationError from django.core.exceptions import PermissionDenied, ValidationError
@ -6,6 +5,7 @@ from django.db import IntegrityError, ProgrammingError, transaction, Operational
from django.db.models import Count, Q from django.db.models import Count, Q
from django.shortcuts import render, redirect, get_object_or_404, resolve_url from django.shortcuts import render, redirect, get_object_or_404, resolve_url
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin as PermissionRequiredMixin_django
from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseRedirect from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseRedirect
from django.contrib import messages from django.contrib import messages
from django.urls import reverse_lazy from django.urls import reverse_lazy
@ -25,21 +25,22 @@ from taskapp.models import Task
from dialing_app.models import AsteriskCDR from dialing_app.models import AsteriskCDR
from statistics.models import getModel from statistics.models import getModel
from group_app.models import Group from group_app.models import Group
from ip_pool.models import IpLeaseModel, NetworkModel
from ip_pool.forms import LeaseForm
from ip_pool.models import NetworkModel
from guardian.shortcuts import get_objects_for_user, assign_perm from guardian.shortcuts import get_objects_for_user, assign_perm
from guardian.decorators import permission_required_or_403 as permission_required from guardian.decorators import permission_required_or_403 as permission_required
from guardian.mixins import PermissionRequiredMixin
from djing import ping from djing import ping
from djing import lib from djing import lib
from djing.lib.decorators import json_view, only_admins from djing.lib.decorators import json_view, only_admins
from djing.lib.mixins import OnlyAdminsMixin
from djing.global_base_views import OrderedFilteredList, SecureApiView from djing.global_base_views import OrderedFilteredList, SecureApiView
login_decs = login_required, only_admins
class AbonappPermissionMixin(LoginRequiredMixin, OnlyAdminsMixin, PermissionRequiredMixin):
return_403 = True
@method_decorator(login_decs, name='dispatch')
class PeoplesListView(OrderedFilteredList):
class PeoplesListView(LoginRequiredMixin, OnlyAdminsMixin, OrderedFilteredList):
template_name = 'abonapp/peoples.html' template_name = 'abonapp/peoples.html'
def get_queryset(self): def get_queryset(self):
@ -77,8 +78,7 @@ class PeoplesListView(OrderedFilteredList):
return context return context
@method_decorator(login_decs, name='dispatch')
class GroupListView(OrderedFilteredList):
class GroupListView(LoginRequiredMixin, OnlyAdminsMixin, OrderedFilteredList):
context_object_name = 'groups' context_object_name = 'groups'
template_name = 'abonapp/group_list.html' template_name = 'abonapp/group_list.html'
queryset = Group.objects.annotate(usercount=Count('abon')) queryset = Group.objects.annotate(usercount=Count('abon'))
@ -90,9 +90,8 @@ class GroupListView(OrderedFilteredList):
return queryset return queryset
@method_decorator(login_decs, name='dispatch')
@method_decorator(permission_required('abonapp.add_abon'), name='dispatch')
class AbonCreateView(CreateView):
class AbonCreateView(LoginRequiredMixin, OnlyAdminsMixin, PermissionRequiredMixin_django, CreateView):
permission_required = 'abonapp.add_abon'
group = None group = None
abon = None abon = None
form_class = forms.AbonForm form_class = forms.AbonForm
@ -146,9 +145,8 @@ class AbonCreateView(CreateView):
return super(AbonCreateView, self).form_invalid(form) return super(AbonCreateView, self).form_invalid(form)
@method_decorator(login_decs, name='dispatch')
@method_decorator(permission_required('abonapp.delete_abon'), name='dispatch')
class DelAbonDeleteView(DeleteView):
class DelAbonDeleteView(AbonappPermissionMixin, DeleteView):
permission_required = 'abonapp.delete_abon'
model = models.Abon model = models.Abon
slug_url_kwarg = 'uname' slug_url_kwarg = 'uname'
slug_field = 'username' slug_field = 'username'
@ -220,12 +218,14 @@ def abonamount(request, gid: int, uname):
}) })
@method_decorator(login_decs, name='dispatch')
@method_decorator(permission_required('group_app.view_group', (Group, 'pk', 'gid')), name='dispatch')
class DebtsListView(OrderedFilteredList):
class DebtsListView(AbonappPermissionMixin, OrderedFilteredList):
permission_required = 'group_app.view_group'
context_object_name = 'invoices' context_object_name = 'invoices'
template_name = 'abonapp/invoiceForPayment.html' template_name = 'abonapp/invoiceForPayment.html'
def get_permission_object(self):
return self.abon.group
def get_queryset(self): def get_queryset(self):
abon = get_object_or_404(models.Abon, username=self.kwargs.get('uname')) abon = get_object_or_404(models.Abon, username=self.kwargs.get('uname'))
self.abon = abon self.abon = abon
@ -238,12 +238,16 @@ class DebtsListView(OrderedFilteredList):
return context return context
@method_decorator(login_decs, name='dispatch')
@method_decorator(permission_required('group_app.view_group', (Group, 'pk', 'gid')), name='dispatch')
class PayHistoryListView(OrderedFilteredList):
class PayHistoryListView(AbonappPermissionMixin, OrderedFilteredList):
permission_required = 'group_app.view_group'
context_object_name = 'pay_history' context_object_name = 'pay_history'
template_name = 'abonapp/payHistory.html' template_name = 'abonapp/payHistory.html'
def get_permission_object(self):
if hasattr(self, 'abon'):
return self.abon.group
return models.Group.objects.filter(pk=self.kwargs.get('gid')).first()
def get_queryset(self): def get_queryset(self):
abon = get_object_or_404(models.Abon, username=self.kwargs.get('uname')) abon = get_object_or_404(models.Abon, username=self.kwargs.get('uname'))
self.abon = abon self.abon = abon
@ -283,9 +287,8 @@ def abon_services(request, gid: int, uname):
}) })
@method_decorator(login_decs, name='dispatch')
@method_decorator(permission_required('abonapp.view_abon'), name='post')
class AbonHomeUpdateView(UpdateView):
class AbonHomeUpdateView(AbonappPermissionMixin, UpdateView):
permission_required = 'abonapp.view_abon'
model = models.Abon model = models.Abon
form_class = forms.AbonForm form_class = forms.AbonForm
slug_field = 'username' slug_field = 'username'
@ -469,9 +472,8 @@ def unsubscribe_service(request, gid: int, uname, abon_tariff_id: int):
return redirect('abonapp:abon_services', gid=gid, uname=uname) return redirect('abonapp:abon_services', gid=gid, uname=uname)
@method_decorator(login_decs, name='dispatch')
@method_decorator(permission_required('abonapp.view_abonlog'), name='dispatch')
class LogListView(ListView):
class LogListView(AbonappPermissionMixin, ListView):
permission_required = 'abonapp.view_abonlog'
paginate_by = getattr(settings, 'PAGINATION_ITEMS_PER_PAGE', 10) paginate_by = getattr(settings, 'PAGINATION_ITEMS_PER_PAGE', 10)
http_method_names = ('get',) http_method_names = ('get',)
context_object_name = 'logs' context_object_name = 'logs'
@ -479,9 +481,8 @@ class LogListView(ListView):
model = models.AbonLog model = models.AbonLog
@method_decorator(login_decs, name='dispatch')
@method_decorator(permission_required('abonapp.view_invoiceforpayment'), name='dispatch')
class DebtorsListView(ListView):
class DebtorsListView(AbonappPermissionMixin, ListView):
permission_required = 'abonapp.view_invoiceforpayment'
paginate_by = getattr(settings, 'PAGINATION_ITEMS_PER_PAGE', 10) paginate_by = getattr(settings, 'PAGINATION_ITEMS_PER_PAGE', 10)
http_method_names = ('get',) http_method_names = ('get',)
context_object_name = 'invoices' context_object_name = 'invoices'
@ -489,14 +490,16 @@ class DebtorsListView(ListView):
queryset = models.InvoiceForPayment.objects.filter(status=True) queryset = models.InvoiceForPayment.objects.filter(status=True)
@method_decorator(login_decs, name='dispatch')
@method_decorator(permission_required('group_app.view_group', (Group, 'pk', 'gid')), name='dispatch')
class TaskLogListView(ListView):
class TaskLogListView(AbonappPermissionMixin, ListView):
permission_required = 'group_app.view_group'
paginate_by = getattr(settings, 'PAGINATION_ITEMS_PER_PAGE', 10) paginate_by = getattr(settings, 'PAGINATION_ITEMS_PER_PAGE', 10)
http_method_names = ('get',) http_method_names = ('get',)
context_object_name = 'tasks' context_object_name = 'tasks'
template_name = 'abonapp/task_log.html' template_name = 'abonapp/task_log.html'
def get_permission_object(self):
return self.abon.group
def get_queryset(self): def get_queryset(self):
abon = get_object_or_404(models.Abon, username=self.kwargs.get('uname')) abon = get_object_or_404(models.Abon, username=self.kwargs.get('uname'))
self.abon = abon self.abon = abon
@ -509,9 +512,8 @@ class TaskLogListView(ListView):
return context return context
@method_decorator(login_decs, name='dispatch')
@method_decorator(permission_required('abonapp.view_passportinfo'), name='dispatch')
class PassportUpdateView(UpdateView):
class PassportUpdateView(AbonappPermissionMixin, UpdateView):
permission_required = 'abonapp.view_passportinfo'
form_class = forms.PassportForm form_class = forms.PassportForm
model = models.PassportInfo model = models.PassportInfo
template_name = 'abonapp/modal_passport_view.html' template_name = 'abonapp/modal_passport_view.html'
@ -549,6 +551,28 @@ class PassportUpdateView(UpdateView):
return super(PassportUpdateView, self).get_context_data(**context) return super(PassportUpdateView, self).get_context_data(**context)
class IpUpdateView(AbonappPermissionMixin, UpdateView):
permission_required = 'abonapp.change_abon'
form_class = forms.AddIpForm
model = models.Abon
slug_url_kwarg = 'uname'
slug_field = 'username'
template_name = 'abonapp/modal_ip_form.html'
def dispatch(self, request, *args, **kwargs):
try:
return super(IpUpdateView, self).dispatch(request, *args, **kwargs)
except lib.LogicError as e:
messages.error(request, e)
return self.render_to_response(self.get_context_data(**kwargs))
def get_context_data(self, **kwargs):
context = super(IpUpdateView, self).get_context_data(**kwargs)
context['group'] = self.object.group
context['abon'] = self.object
return context
@login_required @login_required
@only_admins @only_admins
def chgroup_tariff(request, gid): def chgroup_tariff(request, gid):
@ -775,8 +799,7 @@ def vcards(r):
return response return response
@method_decorator(login_decs, name='dispatch')
class DialsListView(OrderedFilteredList):
class DialsListView(LoginRequiredMixin, OnlyAdminsMixin, OrderedFilteredList):
context_object_name = 'logs' context_object_name = 'logs'
template_name = 'abonapp/dial_log.html' template_name = 'abonapp/dial_log.html'
@ -1106,9 +1129,8 @@ def del_periodic_pay(request, gid: int, uname, periodic_pay_id):
return redirect('abonapp:abon_services', gid, uname) return redirect('abonapp:abon_services', gid, uname)
@method_decorator(login_decs, name='dispatch')
@method_decorator(permission_required('abonapp.change_abon'), name='dispatch')
class EditSibscriberMarkers(UpdateView):
class EditSibscriberMarkers(AbonappPermissionMixin, UpdateView):
permission_required = 'abonapp.change_abon'
http_method_names = ('get', 'post') http_method_names = ('get', 'post')
template_name = 'abonapp/modal_user_markers.html' template_name = 'abonapp/modal_user_markers.html'
form_class = forms.MarkersForm form_class = forms.MarkersForm
@ -1142,78 +1164,17 @@ class EditSibscriberMarkers(UpdateView):
@login_required @login_required
@only_admins @only_admins
@permission_required('abonapp.change_abon') @permission_required('abonapp.change_abon')
def user_session_toggle(request, gid: int, uname, lease_id: int, action=None):
def user_session_free(request, gid: int, uname):
abon = get_object_or_404(models.Abon, username=uname) abon = get_object_or_404(models.Abon, username=uname)
if abon.nas is None: if abon.nas is None:
messages.error(request, _('NAS required')) messages.error(request, _('NAS required'))
return redirect('abonapp:abon_home', gid, uname) return redirect('abonapp:abon_home', gid, uname)
lease = abon.ip_addresses.get(pk=lease_id)
tm = abon.nas.get_nas_manager()
try:
if action == 'free':
try:
abon_nas_obj = abon.build_agent_struct()
tm.lease_free(abon_nas_obj, ip_address(lease.ip))
messages.success(request, _('Ip lease has been freed'))
lease.free()
except lib.LogicError:
messages.error(request, _('You cannot disable last session'))
elif action == 'start':
lease.start()
abon_nas_obj = abon.build_agent_struct()
tm.lease_start(abon_nas_obj, ip_address(lease.ip))
messages.success(request, _('Ip lease has been started'))
else:
messages.error(request, _('Unexpected action'))
except (NasFailedResult, lib.LogicError) as e:
messages.error(request, e)
return redirect('abonapp:abon_home', gid, uname)
@login_required
@only_admins
@permission_required('abonapp.change_abon')
def lease_add(request, gid: int, uname):
group = get_object_or_404(Group, pk=gid)
if request.method == 'POST':
frm = LeaseForm(request.POST)
if frm.is_valid():
try:
abon = get_object_or_404(models.Abon, username=uname)
cleaned = frm.clean()
ip = cleaned.get('ip_addr')
is_dynamic = cleaned.get('is_dynamic')
network_id = cleaned.get('possible_networks') # str(int)
network = get_object_or_404(NetworkModel, pk=network_id)
lease = IpLeaseModel.objects.create_from_ip(ip, net=network, is_dynamic=is_dynamic)
abon.ip_addresses.add(lease)
if abon.nas is None:
messages.error(request, _('NAS required'))
else:
tm = abon.nas.get_nas_manager()
tm.lease_start(
user=abon.build_agent_struct(),
lease=lease.ip
)
messages.success(request, _('Ip lease has been created'))
return redirect('abonapp:abon_home', gid, uname)
except lib.DuplicateEntry as e:
messages.error(request, e)
else:
messages.error(request, _('Check form errors'))
if abon.ip_address:
abon.free_ip_addr()
messages.success(request, _('Ip lease has been freed'))
else: else:
first_network = NetworkModel.objects.filter(groups=group).first()
if first_network is not None:
free_ip = IpLeaseModel.objects.get_free_ip(first_network)
initial = {'ip_addr': free_ip}
else:
initial = None
frm = LeaseForm(initial=initial)
return render(request, 'abonapp/modal_add_lease.html', {
'form': frm,
'group': group,
'uname': uname
})
messages.error(request, _('User not have ip'))
return redirect('abonapp:abon_home', gid, uname)
@login_required @login_required
@ -1247,9 +1208,8 @@ def abons(request):
ablist = ({ ablist = ({
'id': abn.pk, 'id': abn.pk,
'tarif_id': abn.active_tariff().tariff.pk if abn.active_tariff() is not None else 0, 'tarif_id': abn.active_tariff().tariff.pk if abn.active_tariff() is not None else 0,
'ips': ','.join(str(i.ip) for i in abn.ip_addresses.filter(is_active=True)),
'is_active': abn.is_active
} for abn in models.Abon.objects.all())
'ip': abn.ip_address
} for abn in models.Abon.objects.iterator())
tarlist = ({ tarlist = ({
'id': trf.pk, 'id': trf.pk,
@ -1285,10 +1245,13 @@ class DhcpLever(SecureApiView):
@method_decorator(json_view) @method_decorator(json_view)
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
data = request.GET.copy() data = request.GET.copy()
r = self.on_dhcp_event(data)
if r is not None:
return {'text': r}
return {'status': 'ok'}
try:
r = self.on_dhcp_event(data)
if r is not None:
return {'text': r}
return {'status': 'ok'}
except IntegrityError as e:
return {'status': str(e).replace('\n', ' ')}
@staticmethod @staticmethod
def on_dhcp_event(data: Dict) -> Optional[str]: def on_dhcp_event(data: Dict) -> Optional[str]:

6
accounts_app/templatetags/acc_tags.py

@ -3,7 +3,7 @@ from ipaddress import ip_address, AddressValueError
from django import template from django import template
from django.db.models import Model from django.db.models import Model
from django.apps import apps from django.apps import apps
from ip_pool.models import IpLeaseModel
from abonapp.models import Abon
from six import string_types, class_types from six import string_types, class_types
register = template.Library() register = template.Library()
@ -26,8 +26,8 @@ def can_login_by_location(request):
try: try:
remote_ip = ip_address(request.META.get('REMOTE_ADDR')) remote_ip = ip_address(request.META.get('REMOTE_ADDR'))
if remote_ip.version == 4: if remote_ip.version == 4:
has_leases = IpLeaseModel.objects.filter(ip=str(remote_ip), abon__is_active=True).exists()
return has_leases
has_exist = Abon.objects.filter(ip_address=str(remote_ip), is_active=True).exists()
return has_exist
except AddressValueError: except AddressValueError:
pass pass
return False return False

37
agent/commands/dhcp.py

@ -2,7 +2,6 @@ from typing import Optional
from django.core.exceptions import MultipleObjectsReturned from django.core.exceptions import MultipleObjectsReturned
from abonapp.models import Abon from abonapp.models import Abon
from devapp.models import Device, Port from devapp.models import Device, Port
from ip_pool.models import IpLeaseModel
def dhcp_commit(client_ip: str, client_mac: str, switch_mac: str, switch_port: int) -> Optional[str]: def dhcp_commit(client_ip: str, client_mac: str, switch_mac: str, switch_port: int) -> Optional[str]:
@ -13,22 +12,17 @@ def dhcp_commit(client_ip: str, client_mac: str, switch_mac: str, switch_port: i
if mngr_class.get_is_use_device_port(): if mngr_class.get_is_use_device_port():
abon = Abon.objects.get(dev_port__device=dev, abon = Abon.objects.get(dev_port__device=dev,
dev_port__num=switch_port, dev_port__num=switch_port,
device=dev)
device=dev, is_active=True)
else: else:
abon = Abon.objects.get(device=dev)
abon = Abon.objects.get(device=dev, is_active=True)
if not abon.is_dynamic_ip: if not abon.is_dynamic_ip:
return 'User settings is not dynamic' return 'User settings is not dynamic'
client_ips = tuple(str(ip) for ip in abon.ip_addresses.all())
if client_ip in client_ips:
return 'Ip address already existed'
add_lease_result = abon.add_lease(client_ip, mac_addr=client_mac, network=None)
if add_lease_result is None:
if abon.is_access():
abon.nas_sync_self()
else:
return 'User %s is not access to service' % abon.username
abon.attach_ip_addr(client_ip, strict=False)
if abon.is_access():
r = abon.nas_sync_self()
return r if r else None
else: else:
return add_lease_result
return 'User %s is not access to service' % abon.username
except Abon.DoesNotExist: except Abon.DoesNotExist:
return "User with device with mac '%s' does not exist" % switch_mac return "User with device with mac '%s' does not exist" % switch_mac
except Device.DoesNotExist: except Device.DoesNotExist:
@ -43,16 +37,13 @@ def dhcp_commit(client_ip: str, client_mac: str, switch_mac: str, switch_port: i
def dhcp_expiry(client_ip: str) -> Optional[str]: def dhcp_expiry(client_ip: str) -> Optional[str]:
try:
lease = IpLeaseModel.objects.get(ip=client_ip)
lease.is_active = False
lease.save(update_fields=('is_active',))
abon = Abon.objects.filter(ip_addresses=lease).first()
if abon is None:
return "Subscriber with ip %s does not exist" % client_ip
abon.nas_sync_self()
except IpLeaseModel.DoesNotExist:
pass
abon = Abon.objects.filter(ip_address=client_ip, is_active=True).exclude(current_tariff=None).first()
if abon is None:
return "Subscriber with ip %s does not exist" % client_ip
else:
is_freed = abon.free_ip_addr()
if is_freed:
abon.nas_sync_self()
def dhcp_release(client_ip: str) -> Optional[str]: def dhcp_release(client_ip: str) -> Optional[str]:

2
dhcp_lever.py

@ -5,7 +5,7 @@ from urllib.parse import urlencode
from urllib.request import urlopen from urllib.request import urlopen
from hashlib import sha256 from hashlib import sha256
API_AUTH_SECRET = 'your api key'
API_AUTH_SECRET = 'yourapikey'
SERVER_DOMAIN = 'http://localhost:8000' SERVER_DOMAIN = 'http://localhost:8000'

8
djing/lib/auth_backends.py

@ -3,7 +3,6 @@ from ipaddress import ip_address, AddressValueError
from django.contrib.auth.backends import ModelBackend from django.contrib.auth.backends import ModelBackend
from accounts_app.models import BaseAccount, UserProfile from accounts_app.models import BaseAccount, UserProfile
from abonapp.models import Abon from abonapp.models import Abon
from ip_pool.models import IpLeaseModel
class CustomAuthBackend(ModelBackend): class CustomAuthBackend(ModelBackend):
@ -40,13 +39,12 @@ class LocationAuthBackend(ModelBackend):
def authenticate(self, request, byip, **kwargs): def authenticate(self, request, byip, **kwargs):
try: try:
remote_ip = ip_address(request.META.get('REMOTE_ADDR')) remote_ip = ip_address(request.META.get('REMOTE_ADDR'))
lease = IpLeaseModel.objects.filter(ip=str(remote_ip), abon__is_active=True).first()
if lease is None:
user = Abon.objects.filter(ip_address=str(remote_ip), is_active=True).first()
if user is None:
return return
user = Abon.objects.get(ip_addresses=lease)
if self.user_can_authenticate(user): if self.user_can_authenticate(user):
return user return user
except (AddressValueError, Abon.DoesNotExist):
except AddressValueError:
return return
def get_user(self, user_id): def get_user(self, user_id):

9
djing/lib/mixins.py

@ -0,0 +1,9 @@
from django.contrib.auth.mixins import AccessMixin
class OnlyAdminsMixin(AccessMixin):
"""Verify that the current user is admin."""
def dispatch(self, request, *args, **kwargs):
if not request.user.is_admin:
return self.handle_no_permission()
return super().dispatch(request, *args, **kwargs)

1
ip_pool/admin.py

@ -2,4 +2,3 @@ from django.contrib import admin
from ip_pool import models from ip_pool import models
admin.site.register(models.NetworkModel) admin.site.register(models.NetworkModel)
admin.site.register(models.IpLeaseModel)

38
ip_pool/forms.py

@ -1,14 +1,12 @@
from ipaddress import ip_network, ip_address
from ipaddress import ip_network
from django import forms from django import forms
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.utils.translation import ugettext as _
from ip_pool import models from ip_pool import models
class NetworkForm(forms.ModelForm): class NetworkForm(forms.ModelForm):
def clean_network(self): def clean_network(self):
netw = self.data.get('network') netw = self.data.get('network')
if netw is None: if netw is None:
@ -22,32 +20,8 @@ class NetworkForm(forms.ModelForm):
class Meta: class Meta:
model = models.NetworkModel model = models.NetworkModel
fields = '__all__' fields = '__all__'
class LeaseForm(forms.Form):
def __init__(self, data=None, *args, **kwargs):
super(LeaseForm, self).__init__(data=data, *args, **kwargs)
nets = models.NetworkModel.objects.defer('groups')
if nets.exists():
self.fields['possible_networks'].choices = ((net.pk, str(net.get_network())) for net in nets.iterator())
def clean_ip_addr(self):
ip_addr = self.data.get('ip_addr')
if ip_addr is None:
return
ip_addr = ip_address(ip_addr)
net_id = self.data.get('possible_networks')
if net_id is None:
return ip_addr.compressed
net = models.NetworkModel.objects.get(pk=net_id)
if ip_addr not in net.get_network():
raise ValidationError(_('Ip that you typed is not in subnet that you have selected'))
if ip_addr < ip_address(net.ip_start):
raise ValidationError(_('Ip that you have passed is less than allowed network range'))
if ip_addr > ip_address(net.ip_end):
raise ValidationError(_('Ip that you have passed is greater than allowed network range'))
return ip_addr.compressed
ip_addr = forms.GenericIPAddressField(label=_('Ip address'))
is_dynamic = forms.BooleanField(label=_('Is dynamic'), required=False)
possible_networks = forms.ChoiceField(label=_('Possible networks'))
widgets = {
'groups': forms.SelectMultiple(attrs={
'size': 12
})
}

43
ip_pool/migrations/0003_auto_20181015_1430.py

@ -0,0 +1,43 @@
# Generated by Django 2.1 on 2018-10-15 14:30
from django.db import migrations, models
import djing.fields
class Migration(migrations.Migration):
dependencies = [
('ip_pool', '0002_change_unique'),
]
operations = [
migrations.CreateModel(
name='LeasesHistory',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('ip', models.GenericIPAddressField(verbose_name='Ip address')),
('lease_time', models.DateTimeField(auto_now_add=True, verbose_name='Lease time')),
('mac_addr', djing.fields.MACAddressField(blank=True, integer=True, null=True, verbose_name='Mac address')),
],
options={
'verbose_name': 'History lease',
'verbose_name_plural': 'Leases history',
'db_table': 'ip_pool_leases_history',
'ordering': ('-lease_time',),
},
),
migrations.RemoveField(
model_name='ipleasemodel',
name='is_active',
),
migrations.RemoveField(
model_name='ipleasemodel',
name='is_dynamic',
),
migrations.AddField(
model_name='networkmodel',
name='speed',
field=models.FloatField(default=0.0, verbose_name='Speed for subnet'),
preserve_default=False,
),
]

61
ip_pool/models.py

@ -1,6 +1,6 @@
from datetime import timedelta from datetime import timedelta
from ipaddress import ip_network, ip_address from ipaddress import ip_network, ip_address
from typing import Optional
from typing import Optional, Generator
from django.conf import settings from django.conf import settings
from django.db.utils import IntegrityError from django.db.utils import IntegrityError
@ -39,6 +39,8 @@ class NetworkModel(models.Model):
ip_start = models.GenericIPAddressField(_('Start work ip range')) ip_start = models.GenericIPAddressField(_('Start work ip range'))
ip_end = models.GenericIPAddressField(_('End work ip range')) ip_end = models.GenericIPAddressField(_('End work ip range'))
speed = models.FloatField(_('Speed for subnet'))
def __str__(self): def __str__(self):
netw = self.get_network() netw = self.get_network()
return "%s: %s" % (self.description, netw.with_prefixlen) return "%s: %s" % (self.description, netw.with_prefixlen)
@ -105,6 +107,32 @@ class NetworkModel(models.Model):
return _('Unspecified') return _('Unspecified')
return "I don't know" return "I don't know"
def get_free_ip(self, employed_ips: Optional[Generator]):
"""
Find free ip in network.
:param employed_ips: Sorted from less to more ip addresses from current network.
:return: single finded ip
"""
network = self.get_network()
work_range_start_ip = ip_address(self.ip_start)
work_range_end_ip = ip_address(self.ip_end)
if employed_ips is None:
for ip in network.hosts():
if work_range_start_ip <= ip <= work_range_end_ip:
return ip
return
for ip in network.hosts():
if ip < work_range_start_ip:
continue
elif ip > work_range_end_ip:
break # Not found
used_ip = next(employed_ips)
if used_ip is None:
return ip
used_ip = ip_address(used_ip)
if ip < used_ip:
return ip
class Meta: class Meta:
db_table = 'ip_pool_network' db_table = 'ip_pool_network'
verbose_name = _('Network') verbose_name = _('Network')
@ -145,7 +173,6 @@ class IpLeaseManager(models.Manager):
ip=ip, ip=ip,
network=net, network=net,
is_dynamic=is_dynamic, is_dynamic=is_dynamic,
is_active=True,
mac_addr=mac mac_addr=mac
) )
except IntegrityError as e: except IntegrityError as e:
@ -159,14 +186,13 @@ class IpLeaseManager(models.Manager):
return self.filter(lease_time__lt=senility) return self.filter(lease_time__lt=senility)
# Deprecated. Remove after migrations squashed
class IpLeaseModel(models.Model): class IpLeaseModel(models.Model):
ip = models.GenericIPAddressField(verbose_name=_('Ip address'), unique=True) ip = models.GenericIPAddressField(verbose_name=_('Ip address'), unique=True)
network = models.ForeignKey(NetworkModel, on_delete=models.CASCADE, network = models.ForeignKey(NetworkModel, on_delete=models.CASCADE,
verbose_name=_('Parent network'), null=True, blank=True) verbose_name=_('Parent network'), null=True, blank=True)
mac_addr = MACAddressField(verbose_name=_('Mac address'), null=True, blank=True) mac_addr = MACAddressField(verbose_name=_('Mac address'), null=True, blank=True)
lease_time = models.DateTimeField(_('Lease time'), auto_now_add=True) lease_time = models.DateTimeField(_('Lease time'), auto_now_add=True)
is_dynamic = models.BooleanField(_('Is dynamic'), default=False)
is_active = models.BooleanField(_('Is active'), default=True)
device_info = models.CharField(null=True, blank=True, default=None, max_length=128) device_info = models.CharField(null=True, blank=True, default=None, max_length=128)
objects = IpLeaseManager() objects = IpLeaseManager()
@ -174,16 +200,6 @@ class IpLeaseModel(models.Model):
def __str__(self): def __str__(self):
return self.ip return self.ip
def free(self):
if self.is_active:
self.is_active = False
self.save(update_fields=('is_active',))
def start(self):
if not self.is_active:
self.is_active = True
self.save(update_fields=('is_active',))
def clean(self): def clean(self):
ip = ip_address(self.ip) ip = ip_address(self.ip)
network = self.network.get_network() network = self.network.get_network()
@ -201,7 +217,16 @@ class IpLeaseModel(models.Model):
unique_together = ('ip', 'network', 'mac_addr') unique_together = ('ip', 'network', 'mac_addr')
# class LeasesHistory(models.Model):
# ip = models.GenericIPAddressField(verbose_name=_('Ip address'))
# lease_time = models.DateTimeField(_('Lease time'), auto_now_add=True)
# mac_addr = MACAddressField(_('Mac address'), null=True, blank=True)
class LeasesHistory(models.Model):
ip = models.GenericIPAddressField(verbose_name=_('Ip address'))
lease_time = models.DateTimeField(_('Lease time'), auto_now_add=True)
mac_addr = MACAddressField(_('Mac address'), null=True, blank=True)
def __str__(self):
return self.ip
class Meta:
db_table = 'ip_pool_leases_history'
verbose_name = _('History lease')
verbose_name_plural = _('Leases history')
ordering = '-lease_time',

44
ip_pool/templates/ip_pool/ip_leases_list.html

@ -1,44 +0,0 @@
{% extends 'base.html' %}
{% load i18n %}
{% block breadcrumb %}
<ol class="breadcrumb">
<li><span class="glyphicon glyphicon-home"></span></li>
<li><a href="{% url 'ip_pool:networks' %}">{% trans 'Ip pool' %}</a></li>
<li><a href="{% url 'ip_pool:net_edit' net.id %}">{{ net }}</a></li>
<li class="active">{% trans 'Ip leases list' %}</li>
</ol>
{% endblock %}
{% block page-header %}
{% trans 'Ip leases list' %}
{% endblock %}
{% block main %}
<div class="table-responsive">
<table class="table table-striped table-bordered">
<thead>
<tr>
<th class="col-sm-5">{% trans 'Ip' %}</th>
<th class="col-sm-3">{% trans 'Lease time' %}</th>
<th class="col-sm-3">{% trans 'Network' %}</th>
<th class="col-sm-1">{% trans 'Is dynamic' %}</th>
</tr>
</thead>
<tbody>
{% for ip in object_list %}
<tr>
<td>{{ ip.ip }}</td>
<td>{{ ip.lease_time|date:'j:n H:i:s' }}</td>
<td>{{ ip.get_network }}</td>
<td><input type="checkbox" {{ ip.is_dynamic|yesno:'checked,' }}></td>
</tr>
{% empty %}
<tr>
<td colspan="4">{% trans 'You have not any available dedicated ips in this network' %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

4
ip_pool/templates/ip_pool/net_edit.html

@ -38,10 +38,6 @@
<a href="{% back_url request %}" class="btn btn-default"> <a href="{% back_url request %}" class="btn btn-default">
<span class="glyphicon glyphicon-backward"></span> {% trans 'Back' %} <span class="glyphicon glyphicon-backward"></span> {% trans 'Back' %}
</a> </a>
<a href="{% url 'ip_pool:ip_leases_list' object.pk %}" class="btn btn-default">
<span class="glyphicon glyphicon-eye-open"></span>
<span class="hidden-xs hidden-sm">{% trans 'View employed' %}</span>
</a>
<a href="{% url 'ip_pool:net_groups' object.pk %}" class="btn btn-default"> <a href="{% url 'ip_pool:net_groups' object.pk %}" class="btn btn-default">
<span class="glyphicon glyphicon-user"></span> <span class="glyphicon glyphicon-user"></span>
<span class="hidden-xs hidden-sm">{% trans 'Groups available' %}</span> <span class="hidden-xs hidden-sm">{% trans 'Groups available' %}</span>

6
ip_pool/templates/ip_pool/network_list.html

@ -28,7 +28,7 @@
{% with can_ch_net=perms.ip_pool.change_networkmodel can_del_net=perms.ip_poo.delete_networkmodel %} {% with can_ch_net=perms.ip_pool.change_networkmodel can_del_net=perms.ip_poo.delete_networkmodel %}
{% for netw in networks_list %} {% for netw in networks_list %}
<tr> <tr>
<td><a href="{% url 'ip_pool:ip_leases_list' netw.id %}">{{ netw.get_network }}</a></td>
<td>{{ netw.get_network }}</td>
<td>{{ netw.get_kind_display }}</td> <td>{{ netw.get_kind_display }}</td>
<td>{{ netw.description }}</td> <td>{{ netw.description }}</td>
<td>{{ netw.get_scope }}</td> <td>{{ netw.get_scope }}</td>
@ -55,10 +55,6 @@
<span class="hidden-xs hidden-sm">{% trans 'Permission denied' %}</span> <span class="hidden-xs hidden-sm">{% trans 'Permission denied' %}</span>
</a> </a>
{% endif %} {% endif %}
<a href="{% url 'ip_pool:ip_leases_list' netw.pk %}" class="btn btn-default">
<span class="glyphicon glyphicon-eye-open"></span>
<span class="hidden-xs hidden-sm">{% trans 'View employed' %}</span>
</a>
</td> </td>
</tr> </tr>
{% empty %} {% empty %}

1
ip_pool/urls.py

@ -7,7 +7,6 @@ app_name = 'ip_pool'
urlpatterns = [ urlpatterns = [
path('', views.NetworksListView.as_view(), name='networks'), path('', views.NetworksListView.as_view(), name='networks'),
path('network_add/', views.NetworkCreateView.as_view(), name='net_add'), path('network_add/', views.NetworkCreateView.as_view(), name='net_add'),
path('<int:net_id>/', views.IpLeasesListView.as_view(), name='ip_leases_list'),
path('<int:net_id>/edit/', views.NetworkUpdateView.as_view(), name='net_edit'), path('<int:net_id>/edit/', views.NetworkUpdateView.as_view(), name='net_edit'),
path('<int:net_id>/del/', views.NetworkDeleteView.as_view(), name='net_delete'), path('<int:net_id>/del/', views.NetworkDeleteView.as_view(), name='net_delete'),
path('<int:net_id>/group_attach/', views.network_in_groups, name='net_groups') path('<int:net_id>/group_attach/', views.network_in_groups, name='net_groups')

17
ip_pool/views.py

@ -57,23 +57,6 @@ class NetworkDeleteView(DeleteView):
return super(NetworkDeleteView, self).delete(request, *args, **kwargs) return super(NetworkDeleteView, self).delete(request, *args, **kwargs)
@method_decorator(login_decs, name='dispatch')
@method_decorator(permission_required('ip_pool.view_ipleasemodel'), name='dispatch')
class IpLeasesListView(OrderedFilteredList):
template_name = 'ip_pool/ip_leases_list.html'
model = models.IpLeaseModel
def get_context_data(self, **kwargs):
net_id = self.kwargs.get('net_id')
context = super().get_context_data(**kwargs)
context['net'] = get_object_or_404(models.NetworkModel, pk=net_id)
return context
def get_queryset(self):
net_id = self.kwargs.get('net_id')
return self.model.objects.filter(network__id=net_id)
@method_decorator(login_decs, name='dispatch') @method_decorator(login_decs, name='dispatch')
@method_decorator(permission_required('ip_pool.add_networkmodel'), name='dispatch') @method_decorator(permission_required('ip_pool.add_networkmodel'), name='dispatch')
class NetworkCreateView(CreateView): class NetworkCreateView(CreateView):

2
nas_app/nas_managers/__init__.py

@ -1,6 +1,6 @@
from nas_app.nas_managers.mod_mikrotik import MikrotikTransmitter from nas_app.nas_managers.mod_mikrotik import MikrotikTransmitter
from nas_app.nas_managers.core import NasNetworkError, NasFailedResult from nas_app.nas_managers.core import NasNetworkError, NasFailedResult
from nas_app.nas_managers.structs import TariffStruct, AbonStruct
from nas_app.nas_managers.structs import SubnetQueue
# Указываем какие реализации NAS у нас есть, это будет использоваться в # Указываем какие реализации NAS у нас есть, это будет использоваться в
# web интерфейсе # web интерфейсе

80
nas_app/nas_managers/core.py

@ -1,7 +1,7 @@
from abc import ABC, abstractmethod, abstractproperty from abc import ABC, abstractmethod, abstractproperty
from typing import Iterator, Any, Tuple, Optional from typing import Iterator, Any, Tuple, Optional
from djing import ping from djing import ping
from nas_app.nas_managers.structs import AbonStruct, TariffStruct, VectorAbon, VectorTariff
from nas_app.nas_managers.structs import SubnetQueue, VectorQueue
# Raised if NAS has returned failed result # Raised if NAS has returned failed result
@ -33,66 +33,36 @@ class BaseTransmitter(ABC):
return cls.description return cls.description
@abstractmethod @abstractmethod
def add_user_range(self, user_list: VectorAbon):
def add_user_range(self, queue_list: VectorQueue):
"""add subscribers list to NAS """add subscribers list to NAS
:param user_list: Vector of instances of subscribers
:param queue_list: Vector of instances of subscribers
""" """
@abstractmethod @abstractmethod
def remove_user_range(self, users: VectorAbon):
def remove_user_range(self, queues):
"""remove subscribers list """remove subscribers list
:param users: Vector of instances of subscribers
:param queues: Vector of instances of subscribers
""" """
@abstractmethod @abstractmethod
def add_user(self, user: AbonStruct, *args):
def add_user(self, queue: SubnetQueue, *args):
"""add subscriber """add subscriber
:param user: Subscriber instance
:param queue: Subscriber instance
""" """
@abstractmethod @abstractmethod
def remove_user(self, user: AbonStruct):
def remove_user(self, queue: SubnetQueue):
""" """
remove subscriber remove subscriber
:param user: Subscriber instance
:param queue: Subscriber instance
""" """
@abstractmethod @abstractmethod
def update_user(self, user: AbonStruct, *args):
def update_user(self, queue: SubnetQueue, *args):
""" """
Update subscriber by uid, you can change everything except its uid. Update subscriber by uid, you can change everything except its uid.
Subscriber will found by UID. Subscriber will found by UID.
:param user: Subscriber instance
"""
@abstractmethod
def add_tariff_range(self, tariff_list: VectorTariff):
"""Add services list to NAS.
:param tariff_list: Vector of TariffStruct
"""
@abstractmethod
def remove_tariff_range(self, tariff_list: VectorTariff):
"""Remove tariff list by unique id list.
:param tariff_list: Vector of TariffStruct
"""
@abstractmethod
def add_tariff(self, tariff: TariffStruct):
pass
@abstractmethod
def update_tariff(self, tariff: TariffStruct):
"""
Update tariff by uid, you can change everything except its uid.
Tariff will found by UID.
:param tariff: Service for update
"""
@abstractmethod
def remove_tariff(self, tid: int):
"""
:param tid: unique id of tariff.
:param queue: Subscriber instance
""" """
@abstractmethod @abstractmethod
@ -105,33 +75,15 @@ class BaseTransmitter(ABC):
""" """
@abstractmethod @abstractmethod
def read_users(self) -> VectorAbon:
def read_users(self) -> VectorQueue:
pass pass
@abstractmethod
def lease_free(self, user: AbonStruct, lease):
"""
Remove ip lease from allowed to network
:param lease: ip_address for lease
:param user: Subscriber instance
:return:
"""
@abstractmethod
def lease_start(self, user: AbonStruct, lease):
"""
Starts ip lease to allowed to network
:param lease: ip_address for lease
:param user: Subscriber instance
:return:
"""
def _diff_users(self, users_from_db: Iterator[Any]) -> Tuple[set, set]: def _diff_users(self, users_from_db: Iterator[Any]) -> Tuple[set, set]:
""" """
:param users_from_db: QuerySet of all subscribers that can have service :param users_from_db: QuerySet of all subscribers that can have service
:return: Tuple of 2 lists that contain list to add users and list to remove users :return: Tuple of 2 lists that contain list to add users and list to remove users
""" """
users_struct_gen = (ab.build_agent_struct(raise_errs=False) for ab in users_from_db if
users_struct_gen = (ab.build_agent_struct() for ab in users_from_db if
ab is not None and ab.is_access()) ab is not None and ab.is_access())
users_struct_set = set(ab for ab in users_struct_gen if ab is not None and ab.tariff is not None) users_struct_set = set(ab for ab in users_struct_gen if ab is not None and ab.tariff is not None)
users_from_nas = set(self.read_users()) users_from_nas = set(self.read_users())
@ -153,3 +105,9 @@ class BaseTransmitter(ABC):
for la in list_for_add: for la in list_for_add:
print('\t', la) print('\t', la)
self.add_user_range(list_for_add) self.add_user_range(list_for_add)
def diff_set(one: set, two: set) -> Tuple[set, set]:
list_for_del = (one ^ two) - one
list_for_add = one - two
return list_for_add, list_for_del

388
nas_app/nas_managers/mod_mikrotik.py

@ -3,14 +3,15 @@ import re
import socket import socket
from abc import ABCMeta from abc import ABCMeta
from hashlib import md5 from hashlib import md5
from ipaddress import _BaseAddress, ip_address
from typing import Iterable, Optional, Tuple, Generator, Dict
from ipaddress import ip_network, _BaseNetwork
from typing import Iterable, Optional, Tuple, Generator, Dict, Iterator, Any
from django.conf import settings from django.conf import settings
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from djing.lib.decorators import LazyInitMetaclass from djing.lib.decorators import LazyInitMetaclass
from nas_app.nas_managers.core import BaseTransmitter, NasNetworkError, NasFailedResult
from nas_app.nas_managers.structs import TariffStruct, AbonStruct, VectorAbon, VectorTariff
from nas_app.nas_managers import core
from nas_app.nas_managers import structs as i_structs
from ip_pool.models import NetworkModel
DEBUG = getattr(settings, 'DEBUG', False) DEBUG = getattr(settings, 'DEBUG', False)
@ -40,7 +41,8 @@ class ApiRos(object):
md.update(bytes(pwd, 'utf-8')) md.update(bytes(pwd, 'utf-8'))
md.update(chal) md.update(chal)
for _ in self.talk_iter(("/login", "=name=" + username, for _ in self.talk_iter(("/login", "=name=" + username,
"=response=00" + binascii.hexlify(md.digest()).decode('utf-8'))):
"=response=00" + binascii.hexlify(
md.digest()).decode('utf-8'))):
pass pass
self.is_login = True self.is_login = True
@ -100,12 +102,15 @@ class ApiRos(object):
self.write_bytes(bytes(((l >> 8) & 0xff, l & 0xff))) self.write_bytes(bytes(((l >> 8) & 0xff, l & 0xff)))
elif l < 0x200000: elif l < 0x200000:
l |= 0xC00000 l |= 0xC00000
self.write_bytes(bytes(((l >> 16) & 0xff, (l >> 8) & 0xff, l & 0xff)))
self.write_bytes(
bytes(((l >> 16) & 0xff, (l >> 8) & 0xff, l & 0xff)))
elif l < 0x10000000: elif l < 0x10000000:
l |= 0xE0000000 l |= 0xE0000000
self.write_bytes(bytes(((l >> 24) & 0xff, (l >> 16) & 0xff, (l >> 8) & 0xff, l & 0xff)))
self.write_bytes(bytes(((l >> 24) & 0xff, (l >> 16) & 0xff,
(l >> 8) & 0xff, l & 0xff)))
else: else:
self.write_bytes(bytes((0xf0, (l >> 24) & 0xff, (l >> 16) & 0xff, (l >> 8) & 0xff, l & 0xff)))
self.write_bytes(bytes((0xf0, (l >> 24) & 0xff, (l >> 16) & 0xff,
(l >> 8) & 0xff, l & 0xff)))
def read_len(self): def read_len(self):
c = self.read_bytes(1)[0] c = self.read_bytes(1)[0]
@ -144,7 +149,7 @@ class ApiRos(object):
while n < len(s): while n < len(s):
r = self.sk.send(s[n:]) r = self.sk.send(s[n:])
if r == 0: if r == 0:
raise NasFailedResult("connection closed by remote end")
raise core.NasFailedResult("connection closed by remote end")
n += r n += r
def read_bytes(self, length): def read_bytes(self, length):
@ -152,7 +157,7 @@ class ApiRos(object):
while len(ret) < length: while len(ret) < length:
s = self.sk.recv(length - len(ret)) s = self.sk.recv(length - len(ret))
if len(s) == 0: if len(s) == 0:
raise NasFailedResult("connection closed by remote end")
raise core.NasFailedResult("connection closed by remote end")
ret += s ret += s
return ret return ret
@ -162,19 +167,23 @@ class ApiRos(object):
self.sk.close() self.sk.close()
class MikrotikTransmitter(BaseTransmitter, ApiRos, metaclass=type('_ABC_Lazy_mcs', (ABCMeta, LazyInitMetaclass), {})):
class MikrotikTransmitter(core.BaseTransmitter, ApiRos,
metaclass=type('_ABC_Lazy_mcs',
(ABCMeta, LazyInitMetaclass), {})):
description = _('Mikrotik NAS') description = _('Mikrotik NAS')
def __init__(self, login: str, password: str, ip: str, port: int, *args, **kwargs):
def __init__(self, login: str, password: str, ip: str, port: int, *args,
**kwargs):
try: try:
BaseTransmitter.__init__(self,
login=login, password=password, ip=ip,
port=port, *args, **kwargs
)
core.BaseTransmitter.__init__(self,
login=login, password=password,
ip=ip,
port=port, *args, **kwargs
)
ApiRos.__init__(self, ip, port) ApiRos.__init__(self, ip, port)
self.login(username=login, pwd=password) self.login(username=login, pwd=password)
except ConnectionRefusedError: except ConnectionRefusedError:
raise NasNetworkError('Connection to %s is Refused' % ip)
raise core.NasNetworkError('Connection to %s is Refused' % ip)
def _exec_cmd(self, cmd: Iterable) -> Dict: def _exec_cmd(self, cmd: Iterable) -> Dict:
if not isinstance(cmd, (list, tuple)): if not isinstance(cmd, (list, tuple)):
@ -184,7 +193,7 @@ class MikrotikTransmitter(BaseTransmitter, ApiRos, metaclass=type('_ABC_Lazy_mcs
if k == '!done': if k == '!done':
break break
elif k == '!trap': elif k == '!trap':
raise NasFailedResult(v.get('=message'))
raise core.NasFailedResult(v.get('=message'))
r[k] = v or None r[k] = v or None
return r return r
@ -195,12 +204,12 @@ class MikrotikTransmitter(BaseTransmitter, ApiRos, metaclass=type('_ABC_Lazy_mcs
if k == '!done': if k == '!done':
break break
elif k == '!trap': elif k == '!trap':
raise NasFailedResult(v.get('=message'))
raise core.NasFailedResult(v.get('=message'))
if v: if v:
yield v yield v
@staticmethod @staticmethod
def _build_shape_obj(info: Dict) -> AbonStruct:
def _build_shape_obj(info: Dict) -> i_structs.SubnetQueue:
# Переводим приставку скорости Mikrotik в Mbit/s # Переводим приставку скорости Mikrotik в Mbit/s
def parse_speed(text_speed): def parse_speed(text_speed):
text_speed_digit = float(text_speed[:-1] or 0.0) text_speed_digit = float(text_speed[:-1] or 0.0)
@ -216,10 +225,8 @@ class MikrotikTransmitter(BaseTransmitter, ApiRos, metaclass=type('_ABC_Lazy_mcs
return res return res
speed_out, speed_in = info['=max-limit'].split('/') speed_out, speed_in = info['=max-limit'].split('/')
t = TariffStruct(
speed_in=parse_speed(speed_in),
speed_out=parse_speed(speed_out)
)
speed_in = parse_speed(speed_in)
speed_out = parse_speed(speed_out)
try: try:
target = info.get('=target') target = info.get('=target')
if target is None: if target is None:
@ -228,18 +235,26 @@ class MikrotikTransmitter(BaseTransmitter, ApiRos, metaclass=type('_ABC_Lazy_mcs
disabled = info.get('=disabled', False) disabled = info.get('=disabled', False)
if disabled is not None: if disabled is not None:
disabled = True if disabled == 'true' else False disabled = True if disabled == 'true' else False
if target is not None and name is not None:
if target and name:
# target may be '192.168.0.3/32,192.168.0.2/32' # target may be '192.168.0.3/32,192.168.0.2/32'
ips = (ip.split('/')[0] for ip in target.split(','))
a = AbonStruct(
uid=int(name[3:]),
ips=ips,
tariff=t,
is_access=not disabled
)
if len(a.ips) < 1:
net = target.split(',')[0]
if not net:
return return
a.queue_id = info.get('=.id')
a = i_structs.SubnetQueue(
name=name,
network=net,
max_limit=(speed_in, speed_out),
is_access=not disabled,
queue_id=info.get('=.id')
)
if name.startswith('uid'):
a.queue_type = i_structs.SubnetQueue.QUEUE_LEAF
elif name.startswith('net_'):
a.queue_type = i_structs.SubnetQueue.QUEUE_SUBNET
elif name == 'queue-root':
a.queue_type = i_structs.SubnetQueue.QUEUE_ROOT
else:
a.queue_type = i_structs.SubnetQueue.QUEUE_UNKNOWN
return a return a
except ValueError as e: except ValueError as e:
print('ValueError:', e) print('ValueError:', e)
@ -249,65 +264,65 @@ class MikrotikTransmitter(BaseTransmitter, ApiRos, metaclass=type('_ABC_Lazy_mcs
################################################# #################################################
# Find queue by name # Find queue by name
def find_queue(self, name: str) -> Optional[AbonStruct]:
def find_queue(self, name: str) -> Optional[i_structs.SubnetQueue]:
r = self._exec_cmd(('/queue/simple/print', '?name=%s' % name)) r = self._exec_cmd(('/queue/simple/print', '?name=%s' % name))
if r: if r:
return self._build_shape_obj(r.get('!re')) return self._build_shape_obj(r.get('!re'))
def add_queue(self, user: AbonStruct) -> None:
if not isinstance(user, AbonStruct):
raise TypeError
if user.tariff is None or not isinstance(user.tariff, TariffStruct):
return
ips = ','.join(str(i) for i in user.ips)
def add_queue(self, queue: i_structs.SubnetQueue,
parent_name: str) -> None:
if not isinstance(queue, i_structs.SubnetQueue):
raise TypeError('queue must be instance of SubnetQueue')
self._exec_cmd(( self._exec_cmd((
'/queue/simple/add', '/queue/simple/add',
'=name=uid%d' % user.uid,
'=name=%s' % queue.name,
# FIXME: тут в разных микротиках или =target-addresses или =target # FIXME: тут в разных микротиках или =target-addresses или =target
'=target=%s' % ips,
'=max-limit=%.3fM/%.3fM' % (user.tariff.speedOut, user.tariff.speedIn),
'=target=%s' % queue.network,
'=max-limit=%.3fM/%.3fM' % queue.max_limit,
'=queue=Djing_SFQ/Djing_SFQ', '=queue=Djing_SFQ/Djing_SFQ',
'=burst-time=1/1'
'=burst-time=1/1',
'=parent=%s' % parent_name,
'=total-queue=Djing_SFQ'
)) ))
def remove_queue(self, user: AbonStruct, queue: AbonStruct = None) -> None:
if not isinstance(user, AbonStruct):
def remove_queue(self, queue: i_structs.SubnetQueue) -> None:
if not isinstance(queue, i_structs.SubnetQueue):
raise TypeError raise TypeError
if queue is None:
queue = self.find_queue('uid%d' % user.uid)
if not queue.queue_id:
queue = self.find_queue(queue.name)
if queue is not None: if queue is not None:
queue_id = getattr(queue, 'queue_id')
if queue_id is not None:
if queue.queue_id:
self._exec_cmd(( self._exec_cmd((
'/queue/simple/remove', '/queue/simple/remove',
'=.id=%s' % queue_id
'=.id=%s' % queue.queue_id
)) ))
def remove_queue_range(self, q_ids: Iterable[str]): def remove_queue_range(self, q_ids: Iterable[str]):
self._exec_cmd(('/queue/simple/remove', '=numbers=' + ','.join(q_ids)))
ids = ','.join(q_ids)
if len(ids) > 1:
self._exec_cmd(('/queue/simple/remove', '=numbers=%s' % ids))
def update_queue(self, user: AbonStruct, queue=None):
if not isinstance(user, AbonStruct):
def update_queue(self, queue: i_structs.SubnetQueue, parent_name: str):
if not isinstance(queue, i_structs.SubnetQueue):
raise TypeError raise TypeError
if user.tariff is None:
return
if queue is None:
queue = self.find_queue('uid%d' % user.uid)
if not queue.queue_id:
queue = self.find_queue(queue.name)
if queue is None: if queue is None:
return self.add_queue(user)
return self.add_queue(queue, parent_name)
else: else:
mk_id = getattr(queue, 'queue_id')
cmd = [ cmd = [
'/queue/simple/set', '/queue/simple/set',
'=name=uid%d' % user.uid,
'=max-limit=%.3fM/%.3fM' % (user.tariff.speedOut, user.tariff.speedIn),
# FIXME: тут в разных версиях прошивки микротика или =target-addresses или =target
'=target=%s' % ','.join(str(i) for i in user.ips),
'=name=%s' % queue.name,
'=max-limit=%.3fM/%.3fM' % queue.max_limit,
# FIXME: тут в разных версиях прошивки микротика
# или =target-addresses или =target
'=target=%s' % queue.network,
'=queue=Djing_SFQ/Djing_SFQ', '=queue=Djing_SFQ/Djing_SFQ',
'=parent=%s' % parent_name,
'=burst-time=1/1' '=burst-time=1/1'
] ]
if mk_id is not None:
cmd.insert(1, '=.id=%s' % mk_id)
if queue.queue_id:
cmd.insert(1, '=.id=%s' % queue.queue_id)
r = self._exec_cmd(cmd) r = self._exec_cmd(cmd)
return r return r
@ -321,13 +336,13 @@ class MikrotikTransmitter(BaseTransmitter, ApiRos, metaclass=type('_ABC_Lazy_mcs
# Ip->firewall->address list # Ip->firewall->address list
################################################# #################################################
def add_ip(self, list_name: str, ip):
if not issubclass(ip.__class__, _BaseAddress):
def add_ip(self, list_name: str, net):
if not issubclass(net.__class__, _BaseNetwork):
raise TypeError raise TypeError
commands = ( commands = (
'/ip/firewall/address-list/add', '/ip/firewall/address-list/add',
'=list=%s' % list_name, '=list=%s' % list_name,
'=address=%s' % ip
'=address=%s' % net
) )
return self._exec_cmd(commands) return self._exec_cmd(commands)
@ -343,107 +358,67 @@ class MikrotikTransmitter(BaseTransmitter, ApiRos, metaclass=type('_ABC_Lazy_mcs
'=numbers=%s' % ','.join(ip_firewall_ids) '=numbers=%s' % ','.join(ip_firewall_ids)
)) ))
def find_ip(self, ip, list_name: str):
if not issubclass(ip.__class__, _BaseAddress):
def find_ip(self, net, list_name: str):
if not issubclass(net.__class__, _BaseNetwork):
raise TypeError raise TypeError
r = self._exec_cmd(( r = self._exec_cmd((
'/ip/firewall/address-list/print', 'where', '/ip/firewall/address-list/print', 'where',
'?list=%s' % list_name, '?list=%s' % list_name,
'?address=%s' % ip
'?address=%s' % net
)) ))
return r.get('!re') return r.get('!re')
def read_ips_iter(self, list_name: str) -> Generator:
ips = self._exec_cmd_iter((
def read_nets_iter(self, list_name: str) -> Generator:
nets = self._exec_cmd_iter((
'/ip/firewall/address-list/print', 'where', '/ip/firewall/address-list/print', 'where',
'?list=%s' % list_name, '?list=%s' % list_name,
'?dynamic=no' '?dynamic=no'
)) ))
for dat in ips:
yield ip_address(dat.get('=address')), dat.get('=.id')
for dat in nets:
yield ip_network(dat.get('=address'), strict=False), dat.get(
'=.id')
################################################# #################################################
# BaseTransmitter implementation # BaseTransmitter implementation
################################################# #################################################
def add_user_range(self, user_list: VectorAbon):
for usr in user_list:
self.add_user(usr)
def add_user_range(self, queue_list: i_structs.VectorQueue):
for q in queue_list:
self.add_user(q)
def remove_user_range(self, users: VectorAbon):
if not isinstance(users, (tuple, list, set)):
def remove_user_range(self, queues: i_structs.VectorQueue):
if not isinstance(queues, (tuple, list, set)):
raise ValueError('*users* is used twice, generator does not fit') raise ValueError('*users* is used twice, generator does not fit')
queue_ids = (usr.queue_id for usr in users if usr is not None)
queue_ids = (q.queue_id for q in queues if q)
self.remove_queue_range(queue_ids) self.remove_queue_range(queue_ids)
for user in users:
if isinstance(user, AbonStruct):
for ip in user.ips:
ip_list_entity = self.find_ip(ip, LIST_USERS_ALLOWED)
if ip_list_entity:
self.remove_ip(ip_list_entity.get('=.id'))
def add_user(self, user: AbonStruct, *args):
if user.tariff is None:
return
if not isinstance(user.tariff, TariffStruct):
for q in queues:
if isinstance(q, i_structs.SubnetQueue):
ip_list_entity = self.find_ip(q.network, LIST_USERS_ALLOWED)
if ip_list_entity:
self.remove_ip(ip_list_entity.get('=.id'))
def add_user(self, queue: i_structs.SubnetQueue, parent_name=None, *args):
try:
self.add_queue(queue, parent_name=parent_name)
except core.NasFailedResult as e:
print('Error:', e)
net = queue.network
if not issubclass(net.__class__, _BaseNetwork):
raise TypeError raise TypeError
try: try:
self.add_queue(user)
except NasFailedResult as e:
self.add_ip(LIST_USERS_ALLOWED, net)
except core.NasFailedResult as e:
print('Error:', e) print('Error:', e)
for ip in user.ips:
if not issubclass(ip.__class__, _BaseAddress):
raise TypeError
try:
self.add_ip(LIST_USERS_ALLOWED, ip)
except NasFailedResult as e:
print('Error:', e)
def remove_user(self, user: AbonStruct):
self.remove_queue(user)
def _finder(ips):
for ip in ips:
r = self.find_ip(ip, LIST_USERS_ALLOWED)
if r: yield r.get('=.id')
firewall_ip_list_ids = _finder(user.ips)
self.remove_ip_range(firewall_ip_list_ids)
def update_user(self, user: AbonStruct, *args):
# queue is instance of AbonStruct
queue = self.find_queue('uid%d' % user.uid)
for ip in user.ips:
if not issubclass(ip.__class__, _BaseAddress):
raise TypeError
nas_ip = self.find_ip(ip, LIST_USERS_ALLOWED)
if user.is_access:
if nas_ip is None:
self.add_ip(LIST_USERS_ALLOWED, ip)
else:
# если не активен - то и обновлять не надо
# но и выключить на всяк случай надо, а то вдруг был включён
if nas_ip:
# и если найден был - то удалим ip из разрешённых
self.remove_ip(nas_ip.get('=.id'))
if queue is not None:
self.remove_queue(user, queue)
queue = None
# если нет услуги то её не должно быть и в nas
if user.tariff is None:
if queue is not None:
self.remove_queue(user, queue)
return
if not user.is_access:
return
# Проверяем шейпер
if queue is None:
self.add_queue(user)
return
if queue != user:
self.update_queue(user, queue)
def remove_user(self, queue: i_structs.SubnetQueue):
self.remove_queue(queue)
r = self.find_ip(queue.network, LIST_USERS_ALLOWED)
ip_id = r.get('=.id')
self.remove_ip(ip_id)
def update_user(self, queue: i_structs.SubnetQueue, parent_name=None,
*args):
self.update_queue(queue, parent_name)
def ping(self, host, count=10) -> Optional[Tuple[int, int]]: def ping(self, host, count=10) -> Optional[Tuple[int, int]]:
r = self._exec_cmd(( r = self._exec_cmd((
@ -454,7 +429,8 @@ class MikrotikTransmitter(BaseTransmitter, ApiRos, metaclass=type('_ABC_Lazy_mcs
return return
interface = r['!re'].get('=interface') interface = r['!re'].get('=interface')
r = self._exec_cmd(( r = self._exec_cmd((
'/ping', '=address=%s' % host, '=arp-ping=yes', '=interval=100ms', '=count=%d' % count,
'/ping', '=address=%s' % host, '=arp-ping=yes', '=interval=100ms',
'=count=%d' % count,
'=interface=%s' % interface '=interface=%s' % interface
)) ))
res = r.get('!re') res = r.get('!re')
@ -462,51 +438,83 @@ class MikrotikTransmitter(BaseTransmitter, ApiRos, metaclass=type('_ABC_Lazy_mcs
received, sent = int(res.get('=received')), int(res.get('=sent')) received, sent = int(res.get('=received')), int(res.get('=sent'))
return received, sent return received, sent
def add_tariff_range(self, tariff_list: VectorTariff):
pass
def read_users(self) -> i_structs.VectorQueue:
return self.read_queue_iter()
def remove_tariff_range(self, tariff_list: VectorTariff):
pass
@staticmethod
def _build_db_queues(users_from_db: Iterator[Any]) -> Generator:
# Корневая очередь
# FIXME: Корневую очередь надо брать откуда-то
root_queue = i_structs.SubnetQueue(
name='queue-root',
network='10.0.0.0/8',
max_limit=2048,
queue_type=i_structs.SubnetQueue.QUEUE_ROOT
)
def add_tariff(self, tariff: TariffStruct):
pass
# выберем структуры подсетей
db_subnet_queues = (i_structs.SubnetQueue(
name="net_%s" % db_net.network,
network=db_net.get_network(),
max_limit=float(db_net.speed),
queue_type=i_structs.SubnetQueue.QUEUE_SUBNET
) for db_net in NetworkModel.objects.all().iterator())
queues_struct_gen = (
ab.build_agent_struct() for ab in users_from_db
if ab is not None and ab.is_access()
)
def update_tariff(self, tariff: TariffStruct):
pass
r = [q for q in queues_struct_gen if q is not None]
r.insert(0, root_queue)
r.extend(db_subnet_queues)
return r, root_queue
def remove_tariff(self, tid: int):
pass
def _queues_diff(self, users_from_db: Iterator):
queues_from_db, root_queue = self._build_db_queues(users_from_db)
queues_from_gw = tuple(self.read_queue_iter())
def read_users(self) -> VectorAbon:
all_ips = set(ip for ip, mkid in self.read_ips_iter(LIST_USERS_ALLOWED))
queues = (q for q in self.read_queue_iter() if all_ips.issuperset(q.ips))
return queues
# TODO: надо чтоб корневая очередь тоже создавалась
def lease_free(self, user: AbonStruct, lease):
queue = self.find_queue('uid%d' % user.uid)
if queue is None:
return
if len(queue.ips) > 1:
if queue is not None:
user.ips = tuple(i for i in user.ips if i != lease)
self.update_queue(user, queue)
ip = self.find_ip(lease, LIST_USERS_ALLOWED)
if ip is not None:
self.remove_ip(ip.get('=.id'))
else:
raise NasFailedResult(_('You cannot disable last session'))
db_queues_subnets = (
q for q in queues_from_db
if q.queue_type == i_structs.SubnetQueue.QUEUE_SUBNET
)
gw_queues_subnets = tuple(
q for q in queues_from_gw
if q.queue_type == i_structs.SubnetQueue.QUEUE_SUBNET
)
subnets_for_add, subnets_for_del = core.diff_set(
set(db_queues_subnets), set(gw_queues_subnets))
def lease_start(self, user: AbonStruct, lease):
if not issubclass(lease.__class__, _BaseAddress):
lease = ip_address(lease)
if not isinstance(user, AbonStruct):
raise TypeError
ip = self.find_ip(lease, LIST_USERS_ALLOWED)
if ip is None:
self.add_ip(LIST_USERS_ALLOWED, lease)
queue = self.find_queue('uid%d' % user.uid)
user.ips += lease,
if queue is None:
self.add_queue(user)
else:
self.update_queue(user, queue)
self.remove_queue_range(
(q.queue_id for q in subnets_for_del)
)
for q in subnets_for_add:
self.add_queue(q, parent_name=root_queue.name)
del subnets_for_add, subnets_for_del
db_queue_users = (
q for q in queues_from_db
if q.queue_type == i_structs.SubnetQueue.QUEUE_LEAF
)
gw_queue_users = (
q for q in queues_from_gw
if q.queue_type == i_structs.SubnetQueue.QUEUE_LEAF
)
user_q_for_add, user_q_for_del = core.diff_set(set(db_queue_users),
set(gw_queue_users))
self.remove_queue_range(
(q.queue_id for q in user_q_for_del)
)
for q in user_q_for_add:
find_filter = filter(
lambda qe: qe.network.overlaps(q.network),
gw_queues_subnets
)
parent_subnet = next(find_filter, root_queue)
self.add_queue(q, parent_name=parent_subnet.name)
def sync_nas(self, users_from_db: Iterator):
self._queues_diff(users_from_db)

110
nas_app/nas_managers/structs.py

@ -1,5 +1,5 @@
from abc import ABCMeta from abc import ABCMeta
from ipaddress import ip_address
from ipaddress import ip_network, _BaseNetwork
from typing import Iterable from typing import Iterable
@ -7,68 +7,76 @@ class BaseStruct(object, metaclass=ABCMeta):
__slots__ = () __slots__ = ()
# Как обслуживается абонент
class TariffStruct(BaseStruct):
__slots__ = ('tid', 'speedIn', 'speedOut')
class SubnetQueue(BaseStruct):
__slots__ = ('name', '_net', '_max_limit', '_queue_type',
'is_access', 'queue_id')
def __init__(self, tariff_id=0, speed_in=None, speed_out=None):
self.tid = int(tariff_id)
self.speedIn = speed_in or 0
self.speedOut = speed_out or 0
# Queue types
QUEUE_UNKNOWN = 0
QUEUE_ROOT = 1
QUEUE_SUBNET = 2
QUEUE_LEAF = 3
# Yes, if all variables is zeroed
def is_empty(self):
return self.tid == 0 and self.speedIn == 0 and self.speedOut == 0
def __eq__(self, other):
# не сравниваем id, т.к. тарифы с одинаковыми скоростями для NAS одинаковы
# Да и иногда не удобно доставать из nas id тарифы из базы
return self.speedIn == other.speedIn and self.speedOut == other.speedOut
def __str__(self):
return "Id=%d, speedIn=%.2f, speedOut=%.2f" % (self.tid, self.speedIn, self.speedOut)
# нужно чтоб хеши тарифов In10,Out20 и In20,Out10 были разными
# поэтому сначала float->str и потом хеш
def __hash__(self):
return hash(str(self.speedIn) + str(self.speedOut))
def __init__(self, name: str, network, max_limit=0.0,
queue_type=QUEUE_UNKNOWN, is_access=True, queue_id=None):
super().__init__()
self.name = name
self.network = network
self.max_limit = max_limit
self.queue_type = queue_type
self.is_access = is_access
self.queue_id = queue_id
def get_max_limit(self):
return self._max_limit
def set_max_limit(self, v):
if isinstance(v, tuple):
self._max_limit = v
elif isinstance(v, str):
s_in, s_out = v.split('/')
self._max_limit = float(s_in), float(s_out)
elif isinstance(v, (int, float)):
sp = float(v)
self._max_limit = sp, sp
else:
raise ValueError('Unexpected format for max_limit')
max_limit = property(get_max_limit, set_max_limit)
# Abon from database
class AbonStruct(BaseStruct):
__slots__ = ('uid', '_ips', 'tariff', 'is_access', 'queue_id')
def get_network(self):
return self._net
def __init__(self, uid=0, ips=None, tariff=None, is_access=True):
self.uid = int(uid or 0)
if ips is None:
self._ips = ()
def set_network(self, v):
if isinstance(v, (str, int)):
self._net = ip_network(v, strict=False)
elif issubclass(v.__class__, _BaseNetwork):
self._net = v
else: else:
self._ips = tuple(ip_address(ip) for ip in ips)
self.tariff = tariff
self.is_access = is_access
self.queue_id = 0
raise ValueError('Unexpected format for network')
def get_ips(self):
return self._ips
network = property(get_network, set_network)
def set_ips(self, v):
self._ips = set(v)
def get_queue_type(self):
return self._queue_type
ips = property(get_ips, set_ips, doc='Ip addresses')
def set_queue_type(self, v):
if not isinstance(v, int):
raise ValueError('queue_type must be int')
if v < self.QUEUE_UNKNOWN or v > self.QUEUE_LEAF:
raise IndexError('queue_type out of range')
self._queue_type = v
def __eq__(self, other):
if not isinstance(other, AbonStruct):
raise TypeError
r = self.uid == other.uid and self._ips == other._ips
r = r and self.tariff == other.tariff
return r
queue_type = property(get_queue_type, set_queue_type)
def __str__(self):
return "uid=%d, ips=[%s], tariff=%s" % (self.uid, ';'.join(str(i) for i in self._ips), self.tariff or '<No Service>')
def __eq__(self, other):
return self.network == other.network and self.max_limit == other.max_limit
def __hash__(self): def __hash__(self):
return hash(hash(self._ips) + hash(self.tariff) if self.tariff is not None else 0)
return hash(str(self.max_limit) + str(self.network))
def __repr__(self):
return "net %s" % self.network
VectorAbon = Iterable[AbonStruct]
VectorTariff = Iterable[TariffStruct]
VectorQueue = Iterable[SubnetQueue]

4
nas_app/views.py

@ -34,8 +34,8 @@ class NasCreateView(CreateView):
def form_valid(self, form): def form_valid(self, form):
r = super(NasCreateView, self).form_valid(form) r = super(NasCreateView, self).form_valid(form)
assign_perm("nas_app.change_nasmodel", self.request.user, self.object) assign_perm("nas_app.change_nasmodel", self.request.user, self.object)
assign_perm("nas_app.view_nas", self.request.user, self.object)
assign_perm("nas_app.delete_nas", self.request.user, self.object)
assign_perm("nas_app.view_nasmodel", self.request.user, self.object)
assign_perm("nas_app.delete_nasmodel", self.request.user, self.object)
self.request.user.log(self.request.META, 'cnas', '"%(title)s", %(ip)s, %(type)s' % { self.request.user.log(self.request.META, 'cnas', '"%(title)s", %(ip)s, %(type)s' % {
'title': self.object.title, 'title': self.object.title,
'ip': self.object.ip_address, 'ip': self.object.ip_address,

10
periodic.py

@ -9,7 +9,6 @@ from django.utils import timezone
from django.db import transaction from django.db import transaction
from django.db.models import signals, Count from django.db.models import signals, Count
from abonapp.models import Abon, AbonTariff, abontariff_pre_delete, PeriodicPayForId, AbonLog from abonapp.models import Abon, AbonTariff, abontariff_pre_delete, PeriodicPayForId, AbonLog
from ip_pool.models import IpLeaseModel
from nas_app.nas_managers import NasNetworkError, NasFailedResult from nas_app.nas_managers import NasNetworkError, NasFailedResult
from nas_app.models import NASModel from nas_app.models import NASModel
from djing.lib import LogicError from djing.lib import LogicError
@ -24,10 +23,8 @@ class NasSyncThread(Thread):
try: try:
tm = self.nas.get_nas_manager() tm = self.nas.get_nas_manager()
users = Abon.objects \ users = Abon.objects \
.annotate(ips_count=Count('ip_addresses')) \
.filter(is_active=True, ips_count__gt=0, nas=self.nas) \
.exclude(current_tariff=None) \
.prefetch_related('ip_addresses') \
.filter(is_active=True, nas=self.nas) \
.exclude(current_tariff=None, ip_address=None) \
.iterator() .iterator()
tm.sync_nas(users) tm.sync_nas(users)
except NasNetworkError as er: except NasNetworkError as er:
@ -102,9 +99,6 @@ def main():
for pay in ppays: for pay in ppays:
pay.payment_for_service(now=now) pay.payment_for_service(now=now)
# Remove old inactive ip leases
IpLeaseModel.objects.expired().filter(is_active=False).delete()
# sync subscribers on NAS # sync subscribers on NAS
threads = tuple(NasSyncThread(nas) for nas in NASModel.objects. threads = tuple(NasSyncThread(nas) for nas in NASModel.objects.
annotate(usercount=Count('abon')). annotate(usercount=Count('abon')).

2
searchapp/views.py

@ -21,7 +21,7 @@ def home(request):
if s: if s:
if re.match(IP_ADDR_REGEX, s): if re.match(IP_ADDR_REGEX, s):
abons = Abon.objects.filter(ip_addresses__ip=s)
abons = Abon.objects.filter(ip_address=s)
devices = Device.objects.filter(ip_address=s) devices = Device.objects.filter(ip_address=s)
else: else:
abons = Abon.objects.filter( abons = Abon.objects.filter(

12
systemd_units/djing_backup.service

@ -0,0 +1,12 @@
[Unit]
Description=Backup for djing
[Service]
Type=simple
ExecStart=/var/backups/do_backup.sh
WorkingDirectory=/var/backups
User=root
Group=root
[Install]
WantedBy=multi-user.target

11
systemd_units/djing_backup.timer

@ -0,0 +1,11 @@
[Unit]
Description=Run backup periodically
[Timer]
OnCalendar=*-*-* 8,12,14,16,19,23:15:0
Persistent=true
Unit=djing_backup.service
[Install]
WantedBy=timers.target

8
systemd_units/do_backup.sh

@ -1,16 +1,14 @@
#!/bin/bash #!/bin/bash
PATH=/usr/local/bin:/usr/bin:/usr/local/sbin:/usr/sbin
PATH=/usr/bin:/usr/sbin:/bin
cd /var/backups cd /var/backups
file="djing`date "+%Y-%m-%d_%H.%M.%S"`.sql.gz" file="djing`date "+%Y-%m-%d_%H.%M.%S"`.sql.gz"
mysql_passw=MYSQL ROOT PASSWORD
export PGPASSWORD=POSTGRES ROOT PASSWORD
echo show tables | mysql -uroot -p$mysql_passw djingdb | \
grep -v '^flowstat' | grep -v 'traflost' | grep -v '^Tables' | \
xargs mysqldump -R -Q --add-locks -uroot --password=$mysql_passw djingdb $1 | gzip -9 > $file
pg_dump -O -d djing -h localhost -U djing | gzip > $file
chmod 400 $file chmod 400 $file
./webdav_backup.py $file ./webdav_backup.py $file

Loading…
Cancel
Save