rewrite a bit stats for later updates; rename played into (raw_)on_air
This commit is contained in:
parent
e162b652b8
commit
dcee776c03
|
@ -51,13 +51,14 @@ Python modules:
|
||||||
* `Pillow`: `aircox.cms` (needed by `wagtail`)
|
* `Pillow`: `aircox.cms` (needed by `wagtail`)
|
||||||
* Django's required database modules (remember to install `mysqlclient` if you plan to use a MySql database server)
|
* Django's required database modules (remember to install `mysqlclient` if you plan to use a MySql database server)
|
||||||
|
|
||||||
External applications:
|
External applications & modules:
|
||||||
* `liquidsoap`: `aircox` (generation of the audio streams)
|
* `liquidsoap`: `aircox` (generation of the audio streams)
|
||||||
* `sox`: `aircox` (check sounds quality and metadatas)
|
* `sox`: `aircox` (check sounds quality and metadatas)
|
||||||
* `gunicorn`: WSGI server to be used in production (installed along as dependency)
|
* `gunicorn`: WSGI server to be used in production (installed along as dependency)
|
||||||
* `supervisord`: supervisor
|
* `supervisord`: supervisor
|
||||||
* note there might be external dependencies for python's Pillow too
|
* note there might be external dependencies for python's Pillow too
|
||||||
* sqlite, mysql or any database library that you need to run a database, that is supported by Django (+ eventual python deps)
|
* sqlite, mysql or any database library that you need to run a database, that is supported by Django (+ eventual python deps)
|
||||||
|
* gzip: archive logs
|
||||||
|
|
||||||
### Setup environment
|
### Setup environment
|
||||||
All scripts and files assumes that:
|
All scripts and files assumes that:
|
||||||
|
|
|
@ -131,10 +131,6 @@ class Monitor:
|
||||||
if not current_sound or not current_source:
|
if not current_sound or not current_source:
|
||||||
return
|
return
|
||||||
|
|
||||||
# last log can be anything, so we need to keep track of the last
|
|
||||||
# sound log too
|
|
||||||
# sound on air can be of a diffusion or a stream.
|
|
||||||
|
|
||||||
log = self.last_log
|
log = self.last_log
|
||||||
|
|
||||||
# sound on air changed
|
# sound on air changed
|
||||||
|
@ -142,7 +138,7 @@ class Monitor:
|
||||||
(log.sound and log.sound.path != current_sound):
|
(log.sound and log.sound.path != current_sound):
|
||||||
sound = Sound.objects.filter(path = current_sound).first()
|
sound = Sound.objects.filter(path = current_sound).first()
|
||||||
|
|
||||||
# find diff
|
# find an eventual diff
|
||||||
last_diff = self.last_diff_start
|
last_diff = self.last_diff_start
|
||||||
diff = None
|
diff = None
|
||||||
if not last_diff.is_expired():
|
if not last_diff.is_expired():
|
||||||
|
@ -157,11 +153,11 @@ class Monitor:
|
||||||
date = tz.now(),
|
date = tz.now(),
|
||||||
sound = sound,
|
sound = sound,
|
||||||
diffusion = diff,
|
diffusion = diff,
|
||||||
# keep sound path (if sound is removed, we keep that info)
|
# if sound is removed, we keep sound path info
|
||||||
comment = current_sound,
|
comment = current_sound,
|
||||||
)
|
)
|
||||||
|
|
||||||
# tracks -- only for sound's
|
# tracks -- only for streams
|
||||||
if not log.diffusion:
|
if not log.diffusion:
|
||||||
self.trace_sound_tracks(log)
|
self.trace_sound_tracks(log)
|
||||||
|
|
||||||
|
@ -219,7 +215,7 @@ class Monitor:
|
||||||
type = Diffusion.Type.normal,
|
type = Diffusion.Type.normal,
|
||||||
sound__type = Sound.Type.archive,
|
sound__type = Sound.Type.archive,
|
||||||
)
|
)
|
||||||
logs = station.played(diffusion__isnull = False)
|
logs = station.raw_on_air(diffusion__isnull = False)
|
||||||
|
|
||||||
date = tz.now() - datetime.timedelta(seconds = self.cancel_timeout)
|
date = tz.now() - datetime.timedelta(seconds = self.cancel_timeout)
|
||||||
for diff in diffs:
|
for diff in diffs:
|
||||||
|
@ -244,7 +240,7 @@ class Monitor:
|
||||||
station = self.station
|
station = self.station
|
||||||
now = tz.now()
|
now = tz.now()
|
||||||
|
|
||||||
log = station.played(diffusion__isnull = False) \
|
log = station.raw_on_air(diffusion__isnull = False) \
|
||||||
.select_related('diffusion') \
|
.select_related('diffusion') \
|
||||||
.order_by('date').last()
|
.order_by('date').last()
|
||||||
if not log or not log.diffusion.is_date_in_range(now):
|
if not log or not log.diffusion.is_date_in_range(now):
|
||||||
|
@ -252,7 +248,7 @@ class Monitor:
|
||||||
return None, []
|
return None, []
|
||||||
|
|
||||||
# last sound source change: end of file reached or forced to stop
|
# last sound source change: end of file reached or forced to stop
|
||||||
sounds = station.played(sound__isnull = False) \
|
sounds = station.raw_on_air(sound__isnull = False) \
|
||||||
.filter(date__gte = log.date) \
|
.filter(date__gte = log.date) \
|
||||||
.order_by('date')
|
.order_by('date')
|
||||||
|
|
||||||
|
|
|
@ -229,11 +229,11 @@ class Station(Nameable):
|
||||||
self.__prepare_controls()
|
self.__prepare_controls()
|
||||||
return self.__streamer
|
return self.__streamer
|
||||||
|
|
||||||
def played(self, *args, **kwargs):
|
def raw_on_air(self, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
Call Log.objects.played for this station
|
Forward call to Log.objects.on_air for this station
|
||||||
"""
|
"""
|
||||||
return Log.objects.played(self, *args, **kwargs)
|
return Log.objects.on_air(self, *args, **kwargs)
|
||||||
|
|
||||||
def on_air(self, date = None, count = 0):
|
def on_air(self, date = None, count = 0):
|
||||||
"""
|
"""
|
||||||
|
@ -244,11 +244,10 @@ class Station(Nameable):
|
||||||
* count: number of items to retrieve if not zero;
|
* count: number of items to retrieve if not zero;
|
||||||
|
|
||||||
If date is not specified, count MUST be set to a non-zero value.
|
If date is not specified, count MUST be set to a non-zero value.
|
||||||
Be careful with what you which for: the result is a plain list.
|
|
||||||
|
|
||||||
It is different from Station.played method since it filters out
|
It is different from Station.raw_on_air method since it filters
|
||||||
elements that should have not been on air, such as a stream that
|
out elements that should have not been on air, such as a stream
|
||||||
has been played when there was a live diffusion.
|
that has been played when there was a live diffusion.
|
||||||
"""
|
"""
|
||||||
# FIXME: as an iterator?
|
# FIXME: as an iterator?
|
||||||
# TODO argument to get sound instead of tracks
|
# TODO argument to get sound instead of tracks
|
||||||
|
@ -264,7 +263,9 @@ class Station(Nameable):
|
||||||
|
|
||||||
if date:
|
if date:
|
||||||
logs = Log.objects.at(self, date)
|
logs = Log.objects.at(self, date)
|
||||||
diffs = Diffusion.objects.at(self, date, type = Diffusion.Type.normal) \
|
diffs = Diffusion.objects \
|
||||||
|
.at(self, date,
|
||||||
|
type = Diffusion.Type.normal) \
|
||||||
.order_by('-start')
|
.order_by('-start')
|
||||||
else:
|
else:
|
||||||
logs = Log.objects
|
logs = Log.objects
|
||||||
|
@ -274,7 +275,7 @@ class Station(Nameable):
|
||||||
|
|
||||||
q = models.Q(diffusion__isnull = False) | \
|
q = models.Q(diffusion__isnull = False) | \
|
||||||
models.Q(track__isnull = False)
|
models.Q(track__isnull = False)
|
||||||
logs = logs.filter(q).order_by('-date')
|
logs = logs.filter(q, type = Log.Type.on_air).order_by('-date')
|
||||||
|
|
||||||
# filter out tracks played when there was a diffusion
|
# filter out tracks played when there was a diffusion
|
||||||
n = 0
|
n = 0
|
||||||
|
@ -508,6 +509,7 @@ class Schedule(models.Model):
|
||||||
'self',
|
'self',
|
||||||
verbose_name = _('initial schedule'),
|
verbose_name = _('initial schedule'),
|
||||||
blank = True, null = True,
|
blank = True, null = True,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
help_text = 'this schedule is a rerun of this one',
|
help_text = 'this schedule is a rerun of this one',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -793,12 +795,14 @@ class Diffusion(models.Model):
|
||||||
'self',
|
'self',
|
||||||
verbose_name = _('initial diffusion'),
|
verbose_name = _('initial diffusion'),
|
||||||
blank = True, null = True,
|
blank = True, null = True,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
help_text = _('the diffusion is a rerun of this one')
|
help_text = _('the diffusion is a rerun of this one')
|
||||||
)
|
)
|
||||||
# port = models.ForeignKey(
|
# port = models.ForeignKey(
|
||||||
# 'self',
|
# 'self',
|
||||||
# verbose_name = _('port'),
|
# verbose_name = _('port'),
|
||||||
# blank = True, null = True,
|
# blank = True, null = True,
|
||||||
|
# on_delete=models.SET_NULL,
|
||||||
# help_text = _('use this input port'),
|
# help_text = _('use this input port'),
|
||||||
# )
|
# )
|
||||||
conflicts = models.ManyToManyField(
|
conflicts = models.ManyToManyField(
|
||||||
|
@ -934,6 +938,7 @@ class Sound(Nameable):
|
||||||
'Diffusion',
|
'Diffusion',
|
||||||
verbose_name = _('diffusion'),
|
verbose_name = _('diffusion'),
|
||||||
blank = True, null = True,
|
blank = True, null = True,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
help_text = _('initial diffusion related it')
|
help_text = _('initial diffusion related it')
|
||||||
)
|
)
|
||||||
type = models.SmallIntegerField(
|
type = models.SmallIntegerField(
|
||||||
|
@ -1214,16 +1219,13 @@ class LogManager(models.Manager):
|
||||||
return self.station(station, qs) if station else qs
|
return self.station(station, qs) if station else qs
|
||||||
|
|
||||||
# TODO: rename on_air + rename Station.on_air into sth like regular_on_air
|
# TODO: rename on_air + rename Station.on_air into sth like regular_on_air
|
||||||
def played(self, station, archives = True, date = None, **kwargs):
|
def on_air(self, station, date = None, **kwargs):
|
||||||
"""
|
"""
|
||||||
Return a queryset of the played elements' log for the given
|
Return a queryset of the played elements' log for the given
|
||||||
station and model. This queryset is ordered by date ascending
|
station and model. This queryset is ordered by date ascending
|
||||||
|
|
||||||
* station: related station
|
* station: return logs occuring on this station
|
||||||
* archives: if false, exclude log of diffusion's archives from
|
* date: only return logs that occured at this date
|
||||||
the queryset;
|
|
||||||
* date: get played logs at the given date only
|
|
||||||
* include_live: include diffusion that have no archive
|
|
||||||
* kwargs: extra filter kwargs
|
* kwargs: extra filter kwargs
|
||||||
"""
|
"""
|
||||||
if date:
|
if date:
|
||||||
|
@ -1231,15 +1233,7 @@ class LogManager(models.Manager):
|
||||||
else:
|
else:
|
||||||
qs = self
|
qs = self
|
||||||
|
|
||||||
qs = qs.filter(
|
qs = qs.filter(type = Log.Type.on_air, **kwargs)
|
||||||
type__in = (Log.Type.start, Log.Type.on_air), **kwargs
|
|
||||||
)
|
|
||||||
|
|
||||||
if not archives and station.dealer:
|
|
||||||
qs = qs.exclude(
|
|
||||||
source = station.dealer.id,
|
|
||||||
sound__isnull = False
|
|
||||||
)
|
|
||||||
return qs.order_by('date')
|
return qs.order_by('date')
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
@ -1300,10 +1294,11 @@ class LogManager(models.Manager):
|
||||||
for log in qs
|
for log in qs
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Note: since we use Yaml, we can just append new logs when file
|
||||||
|
# exists yet <3
|
||||||
with gzip.open(path, 'ab') as archive:
|
with gzip.open(path, 'ab') as archive:
|
||||||
data = yaml.dump(logs).encode('utf8')
|
data = yaml.dump(logs).encode('utf8')
|
||||||
archive.write(data)
|
archive.write(data)
|
||||||
# TODO: delete logs
|
|
||||||
|
|
||||||
if not keep:
|
if not keep:
|
||||||
qs.delete()
|
qs.delete()
|
||||||
|
@ -1385,18 +1380,21 @@ class Log(models.Model):
|
||||||
verbose_name = _('Diffusion'),
|
verbose_name = _('Diffusion'),
|
||||||
blank = True, null = True,
|
blank = True, null = True,
|
||||||
db_index = True,
|
db_index = True,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
)
|
)
|
||||||
sound = models.ForeignKey(
|
sound = models.ForeignKey(
|
||||||
Sound,
|
Sound,
|
||||||
verbose_name = _('Sound'),
|
verbose_name = _('Sound'),
|
||||||
blank = True, null = True,
|
blank = True, null = True,
|
||||||
db_index = True,
|
db_index = True,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
)
|
)
|
||||||
track = models.ForeignKey(
|
track = models.ForeignKey(
|
||||||
Track,
|
Track,
|
||||||
verbose_name = _('Track'),
|
verbose_name = _('Track'),
|
||||||
blank = True, null = True,
|
blank = True, null = True,
|
||||||
db_index = True,
|
db_index = True,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
)
|
)
|
||||||
|
|
||||||
objects = LogManager()
|
objects = LogManager()
|
||||||
|
|
|
@ -26,7 +26,6 @@
|
||||||
</form>
|
</form>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|
||||||
{% for stats in statistics %}
|
{% for stats in statistics %}
|
||||||
<section class="station">
|
<section class="station">
|
||||||
<header>
|
<header>
|
||||||
|
@ -54,7 +53,7 @@
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
{% for track in item.tracks %}
|
{% for track in item.tracks %}
|
||||||
<tr class="subdata">
|
<tr class="subdata" tags="{{ track.tags.all|join:', '}}">
|
||||||
<td>{{ track.date|time:"H:i" }}</td>
|
<td>{{ track.date|time:"H:i" }}</td>
|
||||||
<td>{% trans "Track" %}</td>
|
<td>{% trans "Track" %}</td>
|
||||||
<td>{{ track.artist }} -- <emph>{{ track.title }}</emph> {{ track.version }}</td>
|
<td>{{ track.artist }} -- <emph>{{ track.title }}</emph> {{ track.version }}</td>
|
||||||
|
@ -75,6 +74,23 @@
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
</th>
|
</th>
|
||||||
|
<th>
|
||||||
|
<script>
|
||||||
|
var tracks = document.querySelectorAll('.subdata[tags]');
|
||||||
|
var tags = {}
|
||||||
|
for(var i = 0; i < tracks.length; i++) {
|
||||||
|
var tags_ = tracks[i].getAttribute('tags').split(', ');
|
||||||
|
for(var j = 0; j < tags_.length; j++) {
|
||||||
|
var tag = tags_[j];
|
||||||
|
tags[tag] = (tags[tag] || 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for(var tag in tags) {
|
||||||
|
document.write('<span>' + tag + ': <b>' + tags[tag] + '</b><br>');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</th>
|
||||||
<th>{% for tag, count, average in stats.tags %}
|
<th>{% for tag, count, average in stats.tags %}
|
||||||
<span>{{ tag }}: <b>{{ average|floatformat }}%</b> ({{ count }})<br>
|
<span>{{ tag }}: <b>{{ average|floatformat }}%</b> ({{ count }})<br>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
129
aircox/views.py
129
aircox/views.py
|
@ -1,3 +1,4 @@
|
||||||
|
import os
|
||||||
import json
|
import json
|
||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
|
@ -10,6 +11,7 @@ from django.utils.translation import ugettext as _, ugettext_lazy
|
||||||
from django.utils import timezone as tz
|
from django.utils import timezone as tz
|
||||||
|
|
||||||
import aircox.models as models
|
import aircox.models as models
|
||||||
|
import aircox.settings as settings
|
||||||
|
|
||||||
|
|
||||||
class Stations:
|
class Stations:
|
||||||
|
@ -136,40 +138,25 @@ class Monitor(View,TemplateResponseMixin,LoginRequiredMixin):
|
||||||
|
|
||||||
|
|
||||||
class StatisticsView(View,TemplateResponseMixin,LoginRequiredMixin):
|
class StatisticsView(View,TemplateResponseMixin,LoginRequiredMixin):
|
||||||
|
"""
|
||||||
|
View for statistics.
|
||||||
|
"""
|
||||||
|
# we cannot manipulate queryset, since we have to be able to read from archives
|
||||||
template_name = 'aircox/controllers/stats.html'
|
template_name = 'aircox/controllers/stats.html'
|
||||||
|
|
||||||
class Taggable:
|
class Item:
|
||||||
tags = None
|
|
||||||
|
|
||||||
def add_tags(self, qs):
|
|
||||||
if not self.tags:
|
|
||||||
self.tags = {}
|
|
||||||
|
|
||||||
qs = qs.values('tags__name').annotate(count = Count('tags__name')) \
|
|
||||||
.order_by('tags__name')
|
|
||||||
for q in qs:
|
|
||||||
key = q['tags__name']
|
|
||||||
if not key:
|
|
||||||
continue
|
|
||||||
|
|
||||||
value = q['count']
|
|
||||||
if key not in self.tags:
|
|
||||||
self.tags[key] = value
|
|
||||||
else:
|
|
||||||
self.tags[key] += value
|
|
||||||
|
|
||||||
class Item (Taggable):
|
|
||||||
type = ''
|
|
||||||
date = None
|
date = None
|
||||||
|
end = None
|
||||||
name = None
|
name = None
|
||||||
related = None
|
related = None
|
||||||
tracks = None
|
tracks = None
|
||||||
tags = None
|
tags = None
|
||||||
|
col = None
|
||||||
|
|
||||||
def __init__(self, **kwargs):
|
def __init__(self, **kwargs):
|
||||||
self.__dict__.update(kwargs)
|
self.__dict__.update(kwargs)
|
||||||
|
|
||||||
class Stats (Taggable):
|
class Stats:
|
||||||
station = None
|
station = None
|
||||||
date = None
|
date = None
|
||||||
items = None
|
items = None
|
||||||
|
@ -180,11 +167,30 @@ class StatisticsView(View,TemplateResponseMixin,LoginRequiredMixin):
|
||||||
- tracks_count: total count of tracks
|
- tracks_count: total count of tracks
|
||||||
"""
|
"""
|
||||||
count = 0
|
count = 0
|
||||||
|
#rows = None
|
||||||
|
|
||||||
def __init__(self, **kwargs):
|
def __init__(self, **kwargs):
|
||||||
self.items = []
|
self.items = []
|
||||||
|
# self.rows = []
|
||||||
self.__dict__.update(kwargs)
|
self.__dict__.update(kwargs)
|
||||||
|
|
||||||
|
# Note: one row contains a column for diffusions and one for streams
|
||||||
|
#def append(self, log):
|
||||||
|
# if log.col == 0:
|
||||||
|
# self.rows.append((log, []))
|
||||||
|
# return
|
||||||
|
#
|
||||||
|
# if self.rows:
|
||||||
|
# row = self.rows[len(self.rows)-1]
|
||||||
|
# last = row[0] or row[1][len(row[1])-1]
|
||||||
|
# if last.date < log.date < last.end:
|
||||||
|
# row[1].append(log)
|
||||||
|
# return
|
||||||
|
#
|
||||||
|
# # all other cases: new row
|
||||||
|
# self.rows.append((None, [log]))
|
||||||
|
|
||||||
|
|
||||||
def get_stats(self, station, date):
|
def get_stats(self, station, date):
|
||||||
"""
|
"""
|
||||||
Return statistics for the given station and date.
|
Return statistics for the given station and date.
|
||||||
|
@ -192,53 +198,50 @@ class StatisticsView(View,TemplateResponseMixin,LoginRequiredMixin):
|
||||||
stats = self.Stats(station = station, date = date,
|
stats = self.Stats(station = station, date = date,
|
||||||
items = [], tags = {})
|
items = [], tags = {})
|
||||||
|
|
||||||
last_item = None
|
qs = station.raw_on_air(date = date) \
|
||||||
for elm in reversed(station.on_air(date)):
|
.prefetch_related('diffusion', 'sound', 'track',
|
||||||
qs = None
|
'track__tags')
|
||||||
|
sound_log = None
|
||||||
|
for log in qs:
|
||||||
|
rel = None
|
||||||
item = None
|
item = None
|
||||||
if type(elm) == models.Diffusion:
|
|
||||||
qs = models.Track.objects.get_for(object = elm)
|
if log.diffusion:
|
||||||
|
rel = log.diffusion
|
||||||
item = self.Item(
|
item = self.Item(
|
||||||
|
name = rel.program.name,
|
||||||
type = _('Diffusion'),
|
type = _('Diffusion'),
|
||||||
date = elm.date,
|
col = 0,
|
||||||
name = elm.program.name,
|
tracks = models.Track.objects.get_for(object = rel)
|
||||||
related = elm,
|
.prefetch_related('tags'),
|
||||||
tracks = qs[:]
|
|
||||||
)
|
)
|
||||||
item.add_tags(qs)
|
sound_log = None
|
||||||
stats.items.append(item)
|
elif log.sound:
|
||||||
stats.count += len(item.tracks)
|
rel = log.sound
|
||||||
else:
|
item = self.Item(
|
||||||
# type is Track (related object of a track is a sound)
|
name = rel.program.name + ': ' + os.path.basename(rel.path),
|
||||||
stream = elm.related.related
|
type = _('Stream'),
|
||||||
qs = models.Track.objects.filter(pk = elm.related.pk)
|
col = 1,
|
||||||
|
tracks = [],
|
||||||
|
)
|
||||||
|
sound_log = item
|
||||||
|
elif log.track:
|
||||||
|
# append to last sound log
|
||||||
|
if not sound_log:
|
||||||
|
# TODO: create item ? should never happen
|
||||||
|
continue
|
||||||
|
|
||||||
if last_item and last_item.related == stream:
|
sound_log.tracks.append(log.track)
|
||||||
item = last_item
|
sound_log.end = log.end
|
||||||
else:
|
sound_log
|
||||||
item = self.Item(
|
continue
|
||||||
type = _('Stream'),
|
|
||||||
date = elm.date,
|
|
||||||
name = stream.path,
|
|
||||||
related = stream,
|
|
||||||
tracks = []
|
|
||||||
)
|
|
||||||
stats.items.append(item)
|
|
||||||
|
|
||||||
elm.related.date = elm.date
|
item.date = log.date
|
||||||
item.tracks.append(elm.related)
|
item.end = log.end
|
||||||
item.date = min(elm.date, item.date)
|
item.related = rel
|
||||||
item.add_tags(qs)
|
# stats.append(item)
|
||||||
stats.count += 1
|
stats.items.append(item)
|
||||||
|
|
||||||
last_item = item
|
|
||||||
stats.add_tags(qs)
|
|
||||||
|
|
||||||
stats.tags = [
|
|
||||||
(name, count, count / stats.count * 100)
|
|
||||||
for name, count in stats.tags.items()
|
|
||||||
]
|
|
||||||
stats.tags.sort(key=lambda s: s[0])
|
|
||||||
return stats
|
return stats
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
|
|
|
@ -3,6 +3,7 @@ Django>=1.10.3
|
||||||
django-taggit>=0.18.3
|
django-taggit>=0.18.3
|
||||||
watchdog>=0.8.3
|
watchdog>=0.8.3
|
||||||
psutil>=5.0.1
|
psutil>=5.0.1
|
||||||
|
pyyaml>=3.12
|
||||||
dateutils>=0.6.6
|
dateutils>=0.6.6
|
||||||
bleach>=1.4.3
|
bleach>=1.4.3
|
||||||
django-htmlmin>=0.10.0
|
django-htmlmin>=0.10.0
|
||||||
|
|
Loading…
Reference in New Issue
Block a user