28 changed files with 190 additions and 605 deletions
-
66abonapp/templates/abonapp/charts.html
-
5abonapp/templates/abonapp/ext.htm
-
3abonapp/urls.py
-
71abonapp/views.py
-
27chatbot/email_bot.py
-
5chatbot/send_func.py
-
28devapp/views.py
-
6djing/__init__.py
-
9djing/celery.py
-
10djing/settings.py
-
56djing/tasks.py
-
2djing/urls.py
-
7ip_pool/models.py
-
115msg_app/models.py
-
4requirements.txt
-
0statistics/__init__.py
-
0statistics/admin.py
-
5statistics/apps.py
-
53statistics/fields.py
-
29statistics/migrations/0001_initial.py
-
19statistics/migrations/0002_auto_20180808_1236.py
-
79statistics/migrations/0003_auto_20180814_1921.py
-
0statistics/migrations/__init__.py
-
132statistics/models.py
-
37statistics/templates/statistics/index.html
-
9statistics/urls.py
-
9statistics/views.py
-
9taskapp/handle.py
@ -1,66 +0,0 @@ |
|||||
{% extends request.is_ajax|yesno:'nullcont.htm,abonapp/ext.htm' %} |
|
||||
{% load i18n %} |
|
||||
{% block content %} |
|
||||
|
|
||||
<div class="row"> |
|
||||
<div class="col-sm-12"> |
|
||||
<div class="panel panel-default"> |
|
||||
<div class="panel-heading"> |
|
||||
<h3 class="panel-title">{% blocktrans with wantdate_d=wantdate|date:'j E Y' %}Graph of use by {{ wantdate_d }}{% endblocktrans %}</h3> |
|
||||
</div> |
|
||||
<div class="panel-body"> |
|
||||
{% if charts_data %} |
|
||||
<div id="chrt"></div> |
|
||||
<script type="text/javascript"> |
|
||||
$(document).ready(function ($) { |
|
||||
new Chartist.Line('#chrt', { |
|
||||
series: [ |
|
||||
{ |
|
||||
data: [ |
|
||||
{{ charts_data }} |
|
||||
] |
|
||||
} |
|
||||
] |
|
||||
}, { |
|
||||
height: '600px', |
|
||||
showArea: true, |
|
||||
showLine: false, |
|
||||
showPoint: false, |
|
||||
high: {{ high }}, |
|
||||
axisX: { |
|
||||
type: Chartist.FixedScaleAxis, |
|
||||
divisor: 12, |
|
||||
labelInterpolationFnc: function (value) { |
|
||||
return moment(value).format('HH:mm:ss'); |
|
||||
} |
|
||||
}, |
|
||||
lineSmooth: Chartist.Interpolation.cardinal({ |
|
||||
tension: 0 |
|
||||
}) |
|
||||
}); |
|
||||
}); |
|
||||
</script> |
|
||||
{% else %} |
|
||||
<h2>{% trans 'Static info was Not found' %}</h2> |
|
||||
{% endif %} |
|
||||
<form action="{% url 'abonapp:charts' group.pk abon.username %}" method="get" class="input-group"> |
|
||||
<span class="input-group-btn"> |
|
||||
<button class="btn btn-default" type="submit"> |
|
||||
<span class="glyphicon glyphicon-calendar"></span> {% trans 'Show graph by date' %} |
|
||||
</button> |
|
||||
</span> |
|
||||
<input type="text" class="form-control" placeholder="{% trans 'Choose a date' %}" id="date_choose" name="wantdate" value="{{ wantdate|date:'dmY' }}"> |
|
||||
</form> |
|
||||
<script type="text/javascript"> |
|
||||
$(document).ready(function ($) { |
|
||||
$('#date_choose').datetimepicker({ |
|
||||
format: 'DDMMYYYY' |
|
||||
}); |
|
||||
}); |
|
||||
</script> |
|
||||
</div> |
|
||||
</div> |
|
||||
</div> |
|
||||
</div> |
|
||||
|
|
||||
{% endblock %} |
|
||||
@ -1,27 +0,0 @@ |
|||||
from _socket import gaierror |
|
||||
from smtplib import SMTPException |
|
||||
from django.core.mail import EmailMultiAlternatives |
|
||||
from django.utils.html import strip_tags |
|
||||
from django.conf import settings |
|
||||
|
|
||||
from chatbot.models import ChatException |
|
||||
|
|
||||
|
|
||||
def send_notify(msg_text, account, tag='none'): |
|
||||
try: |
|
||||
# MessageQueue.objects.push(msg=msg_text, user=account, tag=tag) |
|
||||
target_email = account.email |
|
||||
text_content = strip_tags(msg_text) |
|
||||
|
|
||||
msg = EmailMultiAlternatives( |
|
||||
subject=getattr(settings, 'COMPANY_NAME', 'Djing notify'), |
|
||||
body=text_content, |
|
||||
from_email=getattr(settings, 'DEFAULT_FROM_EMAIL'), |
|
||||
to=(target_email,) |
|
||||
) |
|
||||
msg.attach_alternative(msg_text, 'text/html') |
|
||||
msg.send() |
|
||||
except SMTPException as e: |
|
||||
raise ChatException('SMTPException: %s' % e) |
|
||||
except gaierror as e: |
|
||||
raise ChatException('Socket error: %s' % e) |
|
||||
@ -1,5 +0,0 @@ |
|||||
# send via email |
|
||||
from .email_bot import send_notify |
|
||||
|
|
||||
# for Telegram |
|
||||
# from chatbot.telebot import send_notify |
|
||||
@ -0,0 +1,9 @@ |
|||||
|
import os |
||||
|
from celery import Celery |
||||
|
|
||||
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "djing.settings") |
||||
|
app = Celery('djing', broker='redis://localhost:6379/0') |
||||
|
app.config_from_object('django.conf:settings', namespace='CELERY') |
||||
|
|
||||
|
# Load task modules from all registered Django app configs. |
||||
|
app.autodiscover_tasks() |
||||
@ -0,0 +1,56 @@ |
|||||
|
import logging |
||||
|
from _socket import gaierror |
||||
|
from smtplib import SMTPException |
||||
|
from typing import Iterable |
||||
|
|
||||
|
from accounts_app.models import UserProfile |
||||
|
from django.conf import settings |
||||
|
from django.core.mail import EmailMultiAlternatives |
||||
|
from django.utils.html import strip_tags |
||||
|
from celery import shared_task |
||||
|
|
||||
|
|
||||
|
@shared_task |
||||
|
def send_email_notify(msg_text: str, account_id: int): |
||||
|
try: |
||||
|
account = UserProfile.objects.get(pk=account_id) |
||||
|
target_email = account.email |
||||
|
text_content = strip_tags(msg_text) |
||||
|
|
||||
|
msg = EmailMultiAlternatives( |
||||
|
subject=getattr(settings, 'COMPANY_NAME', 'Djing notify'), |
||||
|
body=text_content, |
||||
|
from_email=getattr(settings, 'DEFAULT_FROM_EMAIL'), |
||||
|
to=(target_email,) |
||||
|
) |
||||
|
msg.attach_alternative(msg_text, 'text/html') |
||||
|
msg.send() |
||||
|
except SMTPException as e: |
||||
|
logging.error('SMTPException: %s' % e) |
||||
|
except gaierror as e: |
||||
|
logging.error('Socket error: %s' % e) |
||||
|
except UserProfile.DoesNotExist: |
||||
|
logging.error('UserProfile with pk=%d not found' % account_id) |
||||
|
|
||||
|
|
||||
|
@shared_task |
||||
|
def multicast_email_notify(msg_text: str, account_ids: Iterable): |
||||
|
text_content = strip_tags(msg_text) |
||||
|
for acc_id in account_ids: |
||||
|
try: |
||||
|
account = UserProfile.objects.get(pk=acc_id) |
||||
|
target_email = account.email |
||||
|
msg = EmailMultiAlternatives( |
||||
|
subject=getattr(settings, 'COMPANY_NAME', 'Djing notify'), |
||||
|
body=text_content, |
||||
|
from_email=getattr(settings, 'DEFAULT_FROM_EMAIL'), |
||||
|
to=(target_email,) |
||||
|
) |
||||
|
msg.attach_alternative(msg_text, 'text/html') |
||||
|
msg.send() |
||||
|
except SMTPException as e: |
||||
|
logging.error('SMTPException: %s' % e) |
||||
|
except gaierror as e: |
||||
|
logging.error('Socket error: %s' % e) |
||||
|
except UserProfile.DoesNotExist: |
||||
|
logging.error('UserProfile with pk=%d not found' % acc_id) |
||||
@ -1,5 +0,0 @@ |
|||||
from django.apps import AppConfig |
|
||||
|
|
||||
|
|
||||
class StatisticsConfig(AppConfig): |
|
||||
name = 'statistics' |
|
||||
@ -1,53 +0,0 @@ |
|||||
# |
|
||||
# Get from https://github.com/Niklas9/django-unixdatetimefield |
|
||||
# |
|
||||
import datetime |
|
||||
import time |
|
||||
|
|
||||
import django.db.models as models |
|
||||
|
|
||||
|
|
||||
class UnixDateTimeField(models.DateTimeField): |
|
||||
# TODO(niklas9): |
|
||||
# * should we take care of transforming between time zones in any way here ? |
|
||||
# * get default datetime format from settings ? |
|
||||
DEFAULT_DATETIME_FMT = '%Y-%m-%d %H:%M:%S' |
|
||||
TZ_CONST = '+' |
|
||||
# TODO(niklas9): |
|
||||
# * metaclass below just for Django < 1.9, fix a if stmt for it? |
|
||||
# __metaclass__ = models.SubfieldBase |
|
||||
description = "Unix timestamp integer to datetime object" |
|
||||
|
|
||||
def get_internal_type(self): |
|
||||
return 'PositiveIntegerField' |
|
||||
|
|
||||
def to_python(self, val): |
|
||||
if val is None or isinstance(val, datetime.datetime): |
|
||||
return val |
|
||||
if isinstance(val, datetime.date): |
|
||||
return datetime.datetime(val.year, val.month, val.day) |
|
||||
elif self._is_string(val): |
|
||||
# TODO(niklas9): |
|
||||
# * not addressing time zone support as todo above for now |
|
||||
if self.TZ_CONST in val: |
|
||||
val = val.split(self.TZ_CONST)[0] |
|
||||
return datetime.datetime.strptime(val, self.DEFAULT_DATETIME_FMT) |
|
||||
else: |
|
||||
return datetime.datetime.fromtimestamp(float(val)) |
|
||||
|
|
||||
@staticmethod |
|
||||
def _is_string(val): |
|
||||
return isinstance(val, str) |
|
||||
|
|
||||
def get_db_prep_value(self, val, *args, **kwargs): |
|
||||
if val is None: |
|
||||
if self.default == models.fields.NOT_PROVIDED: return None |
|
||||
return self.default |
|
||||
return int(time.mktime(val.timetuple())) |
|
||||
|
|
||||
def value_to_string(self, obj): |
|
||||
val = self._get_val_from_obj(obj) |
|
||||
return self.to_python(val).strftime(self.DEFAULT_DATETIME_FMT) |
|
||||
|
|
||||
def from_db_value(self, val, expression, connection): |
|
||||
return self.to_python(val) |
|
||||
@ -1,29 +0,0 @@ |
|||||
# -*- coding: utf-8 -*- |
|
||||
# Generated by Django 1.11 on 2018-02-26 00:20 |
|
||||
from __future__ import unicode_literals |
|
||||
|
|
||||
from django.db import migrations, models |
|
||||
from djing.fields import MyGenericIPAddressField |
|
||||
import statistics.fields |
|
||||
|
|
||||
|
|
||||
class Migration(migrations.Migration): |
|
||||
initial = True |
|
||||
|
|
||||
dependencies = [ |
|
||||
] |
|
||||
|
|
||||
operations = [ |
|
||||
migrations.CreateModel( |
|
||||
name='StatCache', |
|
||||
fields=[ |
|
||||
('last_time', statistics.fields.UnixDateTimeField()), |
|
||||
('ip', MyGenericIPAddressField(max_length=8, primary_key=True, protocol='ipv4', serialize=False)), |
|
||||
('octets', models.PositiveIntegerField(default=0)), |
|
||||
('packets', models.PositiveIntegerField(default=0)), |
|
||||
], |
|
||||
options={ |
|
||||
'db_table': 'flowcache', |
|
||||
}, |
|
||||
), |
|
||||
] |
|
||||
@ -1,19 +0,0 @@ |
|||||
# -*- coding: utf-8 -*- |
|
||||
# Generated by Django 1.11 on 2018-08-08 12:36 |
|
||||
from __future__ import unicode_literals |
|
||||
|
|
||||
from django.db import migrations |
|
||||
|
|
||||
|
|
||||
class Migration(migrations.Migration): |
|
||||
|
|
||||
dependencies = [ |
|
||||
('statistics', '0001_initial'), |
|
||||
] |
|
||||
|
|
||||
operations = [ |
|
||||
migrations.AlterModelOptions( |
|
||||
name='statcache', |
|
||||
options={'ordering': ('-last_time',)}, |
|
||||
), |
|
||||
] |
|
||||
@ -1,79 +0,0 @@ |
|||||
# Generated by Django 2.1 on 2018-09-22 14:30 |
|
||||
from django.core.exceptions import ImproperlyConfigured |
|
||||
from django.db import migrations, connection, models |
|
||||
from statistics.fields import UnixDateTimeField |
|
||||
|
|
||||
|
|
||||
# def psql_migr(apps, _): |
|
||||
# pass |
|
||||
|
|
||||
|
|
||||
class Migration(migrations.Migration): |
|
||||
|
|
||||
dependencies = [ |
|
||||
('abonapp', '0005_current_tariff'), |
|
||||
('statistics', '0002_auto_20180808_1236'), |
|
||||
] |
|
||||
|
|
||||
operations = [ |
|
||||
migrations.AlterModelOptions( |
|
||||
name='statcache', |
|
||||
options={'ordering': ('-last_time',)}, |
|
||||
), |
|
||||
] |
|
||||
|
|
||||
|
|
||||
db_e = connection.settings_dict.get('ENGINE') |
|
||||
if db_e is None: |
|
||||
raise ImproperlyConfigured('Database ENGINE is not set') |
|
||||
# if 'postgresql' in db_e: |
|
||||
# # Postgres |
|
||||
Migration.operations.insert(0, migrations.RunPython(psql_migr)) |
|
||||
if 'mysql' in db_e: |
|
||||
Migration.operations.insert(0, migrations.RunSQL( |
|
||||
( |
|
||||
"DROP TABLE `flowcache`;", |
|
||||
"CREATE TABLE `flowcache` ( " |
|
||||
" `last_time` INT(10) UNSIGNED NOT NULL, " |
|
||||
" `abon_id` INT(11) DEFAULT NULL UNIQUE, " |
|
||||
" `octets` INT(10) UNSIGNED NOT NULL, " |
|
||||
" `packets` INT(10) UNSIGNED NOT NULL, " |
|
||||
" KEY `flowcache_abon_id_91e1085d` (`abon_id`) " |
|
||||
") ENGINE = MEMORY DEFAULT CHARSET = utf8;" |
|
||||
), |
|
||||
state_operations=[ |
|
||||
migrations.DeleteModel(name='statcache'), |
|
||||
migrations.CreateModel( |
|
||||
name='statcache', |
|
||||
fields=[ |
|
||||
('last_time', UnixDateTimeField()), |
|
||||
('abon', models.OneToOneField('abonapp.Abon', on_delete=models.CASCADE, primary_key=True)), |
|
||||
('octets', models.PositiveIntegerField(default=0)), |
|
||||
('packets', models.PositiveIntegerField(default=0)) |
|
||||
], |
|
||||
options={ |
|
||||
'db_table': 'flowcache', |
|
||||
}, |
|
||||
) |
|
||||
] |
|
||||
)) |
|
||||
else: |
|
||||
Migration.operations.extend( |
|
||||
( |
|
||||
migrations.DeleteModel(name='statcache'), |
|
||||
migrations.CreateModel( |
|
||||
name='statcache', |
|
||||
fields=[ |
|
||||
('last_time', UnixDateTimeField()), |
|
||||
('abon', models.OneToOneField('abonapp.Abon', on_delete=models.CASCADE, primary_key=True)), |
|
||||
('octets', models.PositiveIntegerField(default=0)), |
|
||||
('packets', models.PositiveIntegerField(default=0)) |
|
||||
], |
|
||||
options={ |
|
||||
'db_table': 'flowcache', |
|
||||
'ordering': ('-last_time',), |
|
||||
#'db_tablespace': 'ram' |
|
||||
}, |
|
||||
) |
|
||||
) |
|
||||
) |
|
||||
@ -1,132 +0,0 @@ |
|||||
import math |
|
||||
from datetime import datetime, timedelta, date, time |
|
||||
from django.db import models, connection, ProgrammingError |
|
||||
from django.utils.timezone import now |
|
||||
|
|
||||
from djing.fields import MyGenericIPAddressField |
|
||||
from .fields import UnixDateTimeField |
|
||||
|
|
||||
|
|
||||
def get_dates(): |
|
||||
tables = connection.introspection.table_names() |
|
||||
tables = (t.replace('flowstat_', '') for t in tables if t.startswith('flowstat_')) |
|
||||
return tuple(datetime.strptime(t, '%d%m%Y').date() for t in tables) |
|
||||
|
|
||||
|
|
||||
class StatManager(models.Manager): |
|
||||
def chart(self, user, count_of_parts=12, want_date=date.today()): |
|
||||
def byte_to_mbit(x): |
|
||||
return ((x / 60) * 8) / 2 ** 20 |
|
||||
|
|
||||
def split_list(lst, chunk_count): |
|
||||
chunk_size = len(lst) // chunk_count |
|
||||
if chunk_size == 0: |
|
||||
chunk_size = 1 |
|
||||
return tuple(lst[i:i + chunk_size] for i in range(0, len(lst), chunk_size)) |
|
||||
|
|
||||
def avarage(elements): |
|
||||
return sum(elements) / len(elements) |
|
||||
|
|
||||
try: |
|
||||
charts_data = self.filter(abon=user) |
|
||||
charts_times = tuple(cd.cur_time.timestamp() * 1000 for cd in charts_data) |
|
||||
charts_octets = tuple(cd.octets for cd in charts_data) |
|
||||
if len(charts_octets) > 0 and len(charts_octets) == len(charts_times): |
|
||||
charts_octets = split_list(charts_octets, count_of_parts) |
|
||||
charts_octets = (byte_to_mbit(avarage(c)) for c in charts_octets) |
|
||||
|
|
||||
charts_times = split_list(charts_times, count_of_parts) |
|
||||
charts_times = tuple(avarage(t) for t in charts_times) |
|
||||
|
|
||||
charts_data = zip(charts_times, charts_octets) |
|
||||
charts_data = ["{x: new Date(%d), y: %.2f}" % (cd[0], cd[1]) for cd in charts_data] |
|
||||
midnight = datetime.combine(want_date, time.min) |
|
||||
charts_data.append("{x:new Date(%d),y:0}" % (int(charts_times[-1:][0]) + 1)) |
|
||||
charts_data.append("{x:new Date(%d),y:0}" % (int((midnight + timedelta(days=1)).timestamp()) * 1000)) |
|
||||
return charts_data |
|
||||
else: |
|
||||
return |
|
||||
except ProgrammingError as e: |
|
||||
if "Table 'djing_db_n.flowstat" in str(e): |
|
||||
return |
|
||||
|
|
||||
|
|
||||
class StatElem(models.Model): |
|
||||
cur_time = UnixDateTimeField(primary_key=True) |
|
||||
abon = models.ForeignKey('abonapp.Abon', on_delete=models.CASCADE, null=True, default=None, blank=True) |
|
||||
ip = MyGenericIPAddressField() |
|
||||
octets = models.PositiveIntegerField(default=0) |
|
||||
packets = models.PositiveIntegerField(default=0) |
|
||||
|
|
||||
objects = StatManager() |
|
||||
|
|
||||
# ReadOnly |
|
||||
def save(self, *args, **kwargs): |
|
||||
pass |
|
||||
|
|
||||
# ReadOnly |
|
||||
def delete(self, *args, **kwargs): |
|
||||
pass |
|
||||
|
|
||||
@property |
|
||||
def table_name(self): |
|
||||
return self._meta.db_table |
|
||||
|
|
||||
def delete_month(self): |
|
||||
cursor = connection.cursor() |
|
||||
table_name = self._meta.db_table |
|
||||
sql = "DROP TABLE %s;" % table_name |
|
||||
cursor.execute(sql) |
|
||||
|
|
||||
@staticmethod |
|
||||
def percentile(N, percent, key=lambda x: x): |
|
||||
""" |
|
||||
Find the percentile of a list of values. |
|
||||
|
|
||||
@parameter N - is a list of values. Note N MUST BE already sorted. |
|
||||
@parameter percent - a float value from 0.0 to 1.0. |
|
||||
@parameter key - optional key function to compute value from each element of N. |
|
||||
|
|
||||
@return - the percentile of the values |
|
||||
""" |
|
||||
if not N: |
|
||||
return None |
|
||||
k = (len(N) - 1) * percent |
|
||||
f = math.floor(k) |
|
||||
c = math.ceil(k) |
|
||||
if f == c: |
|
||||
return key(N[int(k)]) |
|
||||
d0 = key(N[int(f)]) * (c - k) |
|
||||
d1 = key(N[int(c)]) * (k - f) |
|
||||
return d0 + d1 |
|
||||
|
|
||||
class Meta: |
|
||||
abstract = True |
|
||||
|
|
||||
|
|
||||
def getModel(want_date=now()): |
|
||||
class DynamicStatElem(StatElem): |
|
||||
class Meta: |
|
||||
db_table = 'flowstat_%s' % want_date.strftime("%d%m%Y") |
|
||||
abstract = False |
|
||||
|
|
||||
return DynamicStatElem |
|
||||
|
|
||||
|
|
||||
class StatCache(models.Model): |
|
||||
last_time = UnixDateTimeField() |
|
||||
# ip = MyGenericIPAddressField(primary_key=True) |
|
||||
abon = models.OneToOneField('abonapp.Abon', on_delete=models.CASCADE, primary_key=True) |
|
||||
octets = models.PositiveIntegerField(default=0) |
|
||||
packets = models.PositiveIntegerField(default=0) |
|
||||
|
|
||||
def is_online(self): |
|
||||
return self.last_time > now() - timedelta(minutes=55) |
|
||||
|
|
||||
def is_today(self): |
|
||||
return date.today() == self.last_time.date() |
|
||||
|
|
||||
class Meta: |
|
||||
db_table = 'flowcache' |
|
||||
ordering = ('-last_time',) |
|
||||
# db_tablespace = 'ram' |
|
||||
@ -1,37 +0,0 @@ |
|||||
{% extends 'base.html' %} |
|
||||
{% block main %} |
|
||||
<link href="/static/css/chartist.min.css" rel="stylesheet" type="text/css"/> |
|
||||
<script src="/static/js/chartist.min.js"></script> |
|
||||
<script> |
|
||||
|
|
||||
var chart = new Chartist.Line('#maincontent', { |
|
||||
labels: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'], |
|
||||
series: [ |
|
||||
[1, 5, 2, 5, 4, 3], |
|
||||
[2, 3, 4, 8, 1, 2], |
|
||||
[5, 4, 3, 2, 1, 0.5] |
|
||||
] |
|
||||
}, { |
|
||||
low: 0, |
|
||||
showArea: true, |
|
||||
showPoint: false, |
|
||||
fullWidth: true, |
|
||||
height: 500 |
|
||||
}); |
|
||||
|
|
||||
chart.on('draw', function (data) { |
|
||||
if (data.type === 'line' || data.type === 'area') { |
|
||||
data.element.animate({ |
|
||||
d: { |
|
||||
begin: 2000 * data.index, |
|
||||
dur: 2000, |
|
||||
from: data.path.clone().scale(1, 0).translate(0, data.chartRect.height()).stringify(), |
|
||||
to: data.path.clone().stringify(), |
|
||||
easing: Chartist.Svg.Easing.easeOutQuint |
|
||||
} |
|
||||
}); |
|
||||
} |
|
||||
}); |
|
||||
|
|
||||
</script> |
|
||||
{% endblock %} |
|
||||
@ -1,9 +0,0 @@ |
|||||
from django.urls import path |
|
||||
|
|
||||
from . import views |
|
||||
|
|
||||
app_name = 'statistics' |
|
||||
|
|
||||
urlpatterns = [ |
|
||||
path('', views.home, name='home'), |
|
||||
] |
|
||||
@ -1,9 +0,0 @@ |
|||||
from django.shortcuts import render |
|
||||
from django.contrib.auth.decorators import login_required |
|
||||
from djing.lib.decorators import only_admins |
|
||||
|
|
||||
|
|
||||
@login_required |
|
||||
@only_admins |
|
||||
def home(request): |
|
||||
return render(request, 'statistics/index.html') |
|
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue