WIP - Sound.file instead of Sound.path; fix issues with player; program.path is now relative
This commit is contained in:
parent
d17d6831dd
commit
e3b744be70
|
@ -28,7 +28,7 @@ class SoundInline(admin.TabularInline):
|
|||
max_num = 0
|
||||
|
||||
def audio(self, obj):
|
||||
return mark_safe('<audio src="{}" controls></audio>'.format(obj.url()))
|
||||
return mark_safe('<audio src="{}" controls></audio>'.format(obj.file.url))
|
||||
audio.short_descripton = _('Audio')
|
||||
|
||||
def get_queryset(self, request):
|
||||
|
@ -46,10 +46,10 @@ class SoundAdmin(admin.ModelAdmin):
|
|||
|
||||
search_fields = ['name', 'program__title']
|
||||
fieldsets = [
|
||||
(None, {'fields': ['name', 'path', 'type', 'program', 'episode']}),
|
||||
(None, {'fields': ['name', 'file', 'type', 'program', 'episode']}),
|
||||
(None, {'fields': ['duration', 'is_public', 'is_good_quality', 'mtime']}),
|
||||
]
|
||||
readonly_fields = ('path', 'duration',)
|
||||
readonly_fields = ('file', 'duration',)
|
||||
inlines = [SoundTrackInline]
|
||||
|
||||
def related(self, obj):
|
||||
|
@ -59,7 +59,7 @@ class SoundAdmin(admin.ModelAdmin):
|
|||
related.short_description = _('Program / Episode')
|
||||
|
||||
def audio(self, obj):
|
||||
return mark_safe('<audio src="{}" controls></audio>'.format(obj.url()))
|
||||
return mark_safe('<audio src="{}" controls></audio>'.format(obj.file.url))
|
||||
audio.short_descripton = _('Audio')
|
||||
|
||||
|
||||
|
|
|
@ -40,7 +40,7 @@ class AdminSite(admin.AdminSite):
|
|||
return context
|
||||
|
||||
def get_urls(self):
|
||||
urls = super().get_urls() + [
|
||||
urls = [
|
||||
path('api/', include((self.router.urls, 'api'))),
|
||||
path('tools/statistics/',
|
||||
self.admin_view(StatisticsView.as_view()),
|
||||
|
@ -48,7 +48,7 @@ class AdminSite(admin.AdminSite):
|
|||
path('tools/statistics/<date:date>/',
|
||||
self.admin_view(StatisticsView.as_view()),
|
||||
name='tools-stats'),
|
||||
] + self.extra_urls
|
||||
] + self.extra_urls + super().get_urls()
|
||||
return urls
|
||||
|
||||
def get_tools(self):
|
||||
|
|
|
@ -128,7 +128,7 @@ class Command(BaseCommand):
|
|||
def handle(self, path, *args, **options):
|
||||
# FIXME: absolute/relative path of sounds vs given path
|
||||
if options.get('sound'):
|
||||
sound = Sound.objects.filter(path__icontains=options.get('sound'))\
|
||||
sound = Sound.objects.filter(file__icontains=options.get('sound'))\
|
||||
.first()
|
||||
else:
|
||||
path_, ext = os.path.splitext(path)
|
||||
|
|
|
@ -36,6 +36,7 @@ import mutagen
|
|||
from watchdog.observers import Observer
|
||||
from watchdog.events import PatternMatchingEventHandler, FileModifiedEvent
|
||||
|
||||
from django.conf import settings as conf
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.utils import timezone as tz
|
||||
from django.utils.translation import gettext as _
|
||||
|
@ -64,12 +65,16 @@ class SoundFile:
|
|||
def __init__(self, path):
|
||||
self.path = path
|
||||
|
||||
@property
|
||||
def sound_path(self):
|
||||
return self.path.replace(conf.MEDIA_ROOT + '/', '')
|
||||
|
||||
def sync(self, sound=None, program=None, deleted=False, **kwargs):
|
||||
"""
|
||||
Update related sound model and save it.
|
||||
"""
|
||||
if deleted:
|
||||
sound = Sound.objects.filter(path=self.path).first()
|
||||
sound = Sound.objects.filter(file=self.path).first()
|
||||
if sound:
|
||||
sound.type = sound.TYPE_REMOVED
|
||||
sound.check_on_file()
|
||||
|
@ -78,13 +83,13 @@ class SoundFile:
|
|||
|
||||
# FIXME: sound.program as not null
|
||||
program = kwargs['program'] = Program.get_from_path(self.path)
|
||||
sound, created = Sound.objects.get_or_create(path=self.path, defaults=kwargs) \
|
||||
sound, created = Sound.objects.get_or_create(file=self.sound_path, defaults=kwargs) \
|
||||
if not sound else (sound, False)
|
||||
self.sound = sound
|
||||
|
||||
sound.program = program
|
||||
if created or sound.check_on_file():
|
||||
logger.info('sound is new or have been modified -> %s', self.path)
|
||||
logger.info('sound is new or have been modified -> %s', self.sound_path)
|
||||
self.read_path()
|
||||
sound.name = self.path_info.get('name')
|
||||
|
||||
|
@ -153,7 +158,7 @@ class SoundFile:
|
|||
if not diffusion:
|
||||
return None
|
||||
|
||||
logger.info('%s <--> %s', self.sound.path, str(diffusion.episode))
|
||||
logger.info('%s <--> %s', self.sound.file.name, str(diffusion.episode))
|
||||
self.sound.episode = diffusion.episode
|
||||
return diffusion
|
||||
|
||||
|
@ -172,7 +177,7 @@ class SoundFile:
|
|||
return
|
||||
|
||||
# import playlist
|
||||
path = os.path.splitext(self.sound.path)[0] + '.csv'
|
||||
path = os.path.splitext(self.sound.file.path)[0] + '.csv'
|
||||
if os.path.exists(path):
|
||||
PlaylistImport(path, sound=sound).run()
|
||||
# use metadata
|
||||
|
@ -227,7 +232,7 @@ class MonitorHandler(PatternMatchingEventHandler):
|
|||
def on_moved(self, event):
|
||||
logger.info('sound moved: %s -> %s', event.src_path, event.dest_path)
|
||||
def moved(event, sound_kwargs):
|
||||
sound = Sound.objects.filter(path=event.src_path)
|
||||
sound = Sound.objects.filter(file=event.src_path)
|
||||
sound_file = SoundFile(event.dest_path) if not sound else sound
|
||||
sound_file.sync(**sound_kwargs)
|
||||
self.pool.submit(moved, event, self.sound_kwargs)
|
||||
|
@ -268,7 +273,8 @@ class Command(BaseCommand):
|
|||
program, settings.AIRCOX_SOUND_EXCERPTS_SUBDIR,
|
||||
type=Sound.TYPE_EXCERPT,
|
||||
)
|
||||
dirs.append(os.path.join(program.path))
|
||||
dirs.append(os.path.join(program.abspath))
|
||||
return dirs
|
||||
|
||||
def scan_for_program(self, program, subdir, **sound_kwargs):
|
||||
"""
|
||||
|
@ -279,7 +285,7 @@ class Command(BaseCommand):
|
|||
if not program.ensure_dir(subdir):
|
||||
return
|
||||
|
||||
subdir = os.path.join(program.path, subdir)
|
||||
subdir = os.path.join(program.abspath, subdir)
|
||||
sounds = []
|
||||
|
||||
# sounds in directory
|
||||
|
@ -293,7 +299,7 @@ class Command(BaseCommand):
|
|||
sounds.append(sound_file.sound.pk)
|
||||
|
||||
# sounds in db & unchecked
|
||||
sounds = Sound.objects.filter(path__startswith=subdir). \
|
||||
sounds = Sound.objects.filter(file__startswith=subdir). \
|
||||
exclude(pk__in=sounds)
|
||||
self.check_sounds(sounds, program=program)
|
||||
|
||||
|
@ -302,7 +308,7 @@ class Command(BaseCommand):
|
|||
# check files
|
||||
for sound in qs:
|
||||
if sound.check_on_file():
|
||||
SoundFile(sound.path).sync(sound=sound, **sync_kwargs)
|
||||
SoundFile(sound.file.path).sync(sound=sound, **sync_kwargs)
|
||||
|
||||
def monitor(self):
|
||||
""" Run in monitor mode """
|
||||
|
|
|
@ -160,7 +160,7 @@ class Command (BaseCommand):
|
|||
self.bad = []
|
||||
self.good = []
|
||||
for sound in self.sounds:
|
||||
logger.info('analyse ' + sound.path)
|
||||
logger.info('analyse ' + sound.file.name)
|
||||
sound.analyse()
|
||||
sound.check(attr, minmax[0], minmax[1])
|
||||
if sound.bad:
|
||||
|
@ -171,6 +171,6 @@ class Command (BaseCommand):
|
|||
# resume
|
||||
if options.get('resume'):
|
||||
for sound in self.good:
|
||||
logger.info('\033[92m+ %s\033[0m', sound.path)
|
||||
logger.info('\033[92m+ %s\033[0m', sound.file.name)
|
||||
for sound in self.bad:
|
||||
logger.info('\033[91m+ %s\033[0m', sound.path)
|
||||
logger.info('\033[91m+ %s\033[0m', sound.file.name)
|
||||
|
|
|
@ -7,6 +7,7 @@ import os
|
|||
import shutil
|
||||
|
||||
import pytz
|
||||
from django.conf import settings as conf
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.db.models import F, Q
|
||||
|
@ -73,14 +74,17 @@ class Program(Page):
|
|||
self.slug.replace('-', '_'))
|
||||
|
||||
@property
|
||||
def archives_path(self):
|
||||
return os.path.join(self.path, settings.AIRCOX_SOUND_ARCHIVES_SUBDIR)
|
||||
def abspath(self):
|
||||
""" Return absolute path to program's dir """
|
||||
return os.path.join(conf.MEDIA_ROOT, self.path)
|
||||
|
||||
@property
|
||||
def excerpts_path(self):
|
||||
return os.path.join(
|
||||
self.path, settings.AIRCOX_SOUND_ARCHIVES_SUBDIR
|
||||
)
|
||||
def archives_path(self, abs=False):
|
||||
return os.path.join(abs and self.abspath or self.path,
|
||||
settings.AIRCOX_SOUND_ARCHIVES_SUBDIR)
|
||||
|
||||
def excerpts_path(self, abs=False):
|
||||
return os.path.join(abs and self.abspath or self.path,
|
||||
settings.AIRCOX_SOUND_ARCHIVES_SUBDIR)
|
||||
|
||||
def __init__(self, *kargs, **kwargs):
|
||||
super().__init__(*kargs, **kwargs)
|
||||
|
@ -94,6 +98,8 @@ class Program(Page):
|
|||
Return a Program from the given path. We assume the path has been
|
||||
given in a previous time by this model (Program.path getter).
|
||||
"""
|
||||
if path.startswith(conf.MEDIA_ROOT):
|
||||
path = path.replace(conf.MEDIA_ROOT + '/', '')
|
||||
path = path.replace(settings.AIRCOX_PROGRAMS_DIR, '')
|
||||
|
||||
while path[0] == '/':
|
||||
|
@ -107,10 +113,9 @@ class Program(Page):
|
|||
Make sur the program's dir exists (and optionally subdir). Return True
|
||||
if the dir (or subdir) exists.
|
||||
"""
|
||||
path = os.path.join(self.path, subdir) if subdir else \
|
||||
self.path
|
||||
path = os.path.join(self.abspath, subdir) if subdir else \
|
||||
self.abspath
|
||||
os.makedirs(path, exist_ok=True)
|
||||
|
||||
return os.path.exists(path)
|
||||
|
||||
class Meta:
|
||||
|
@ -127,14 +132,15 @@ class Program(Page):
|
|||
|
||||
# TODO: move in signals
|
||||
path_ = getattr(self, '__initial_path', None)
|
||||
abspath = os.path.join(conf.MEDIA_ROOT, path_)
|
||||
if path_ is not None and path_ != self.path and \
|
||||
os.path.exists(path_) and not os.path.exists(self.path):
|
||||
os.path.exists(abspath) and not os.path.exists(self.abspath):
|
||||
logger.info('program #%s\'s dir changed to %s - update it.',
|
||||
self.id, self.title)
|
||||
|
||||
shutil.move(path_, self.path)
|
||||
shutil.move(abspath, self.abspath)
|
||||
Sound.objects.filter(path__startswith=path_) \
|
||||
.update(path=Concat('path', Substr(F('path'), len(path_))))
|
||||
.update(file=Concat('file', Substr(F('file'), len(path_))))
|
||||
|
||||
|
||||
class ProgramChildQuerySet(PageQuerySet):
|
||||
|
|
|
@ -4,7 +4,8 @@ import os
|
|||
|
||||
from django.conf import settings as main_settings
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
from django.db.models import Q, Value as V
|
||||
from django.db.models.functions import Concat
|
||||
from django.utils import timezone as tz
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
@ -54,7 +55,9 @@ class SoundQuerySet(models.QuerySet):
|
|||
self = self.archive()
|
||||
if order_by:
|
||||
self = self.order_by('path')
|
||||
return self.filter(path__isnull=False).values_list('path', flat=True)
|
||||
return self.filter(file__isnull=False) \
|
||||
.annotate(file_path=Concat(V(conf.MEDIA_ROOT), 'file')) \
|
||||
.values_list('file_path', flat=True)
|
||||
|
||||
def search(self, query):
|
||||
return self.filter(
|
||||
|
@ -94,21 +97,15 @@ class Sound(models.Model):
|
|||
position = models.PositiveSmallIntegerField(
|
||||
_('order'), default=0, help_text=_('position in the playlist'),
|
||||
)
|
||||
# FIXME: url() does not use the same directory than here
|
||||
# should we use FileField for more reliability?
|
||||
path = models.FilePathField(
|
||||
_('file'),
|
||||
path=settings.AIRCOX_PROGRAMS_DIR,
|
||||
match=r'(' + '|'.join(settings.AIRCOX_SOUND_FILE_EXT)
|
||||
.replace('.', r'\.') + ')$',
|
||||
recursive=True, max_length=255,
|
||||
blank=True, null=True, unique=True,
|
||||
|
||||
def _upload_to(self, filename):
|
||||
subdir = AIRCOX_SOUND_ARCHIVES_SUBDIR if self.type == self.TYPE_ARCHIVE else \
|
||||
AIRCOX_SOUND_EXCERPTS_SUBDIR
|
||||
return os.path.join(o.program.path, subdir, filename)
|
||||
|
||||
file = models.FileField(
|
||||
_('file'), upload_to=_upload_to
|
||||
)
|
||||
#embed = models.TextField(
|
||||
# _('embed'),
|
||||
# blank=True, null=True,
|
||||
# help_text=_('HTML code to embed a sound from an external plateform'),
|
||||
#)
|
||||
duration = models.TimeField(
|
||||
_('duration'),
|
||||
blank=True, null=True,
|
||||
|
@ -134,8 +131,12 @@ class Sound(models.Model):
|
|||
verbose_name = _('Sound')
|
||||
verbose_name_plural = _('Sounds')
|
||||
|
||||
@property
|
||||
def url(self):
|
||||
return self.file and self.file.url
|
||||
|
||||
def __str__(self):
|
||||
return '/'.join(self.path.split('/')[-3:])
|
||||
return '/'.join(self.file.path.split('/')[-3:])
|
||||
|
||||
def save(self, check=True, *args, **kwargs):
|
||||
if self.episode is not None and self.program is None:
|
||||
|
@ -145,17 +146,12 @@ class Sound(models.Model):
|
|||
self.__check_name()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def url(self):
|
||||
""" Return an url to the file. """
|
||||
path = self.path.replace(main_settings.MEDIA_ROOT, '', 1)
|
||||
return (main_settings.MEDIA_URL + path).replace('//','/')
|
||||
|
||||
# TODO: rename get_file_mtime(self)
|
||||
def get_mtime(self):
|
||||
"""
|
||||
Get the last modification date from file
|
||||
"""
|
||||
mtime = os.stat(self.path).st_mtime
|
||||
mtime = os.stat(self.file.path).st_mtime
|
||||
mtime = tz.datetime.fromtimestamp(mtime)
|
||||
mtime = mtime.replace(microsecond=0)
|
||||
return tz.make_aware(mtime, tz.get_current_timezone())
|
||||
|
@ -163,7 +159,7 @@ class Sound(models.Model):
|
|||
def file_exists(self):
|
||||
""" Return true if the file still exists. """
|
||||
|
||||
return os.path.exists(self.path)
|
||||
return os.path.exists(self.file.path)
|
||||
|
||||
def check_on_file(self):
|
||||
"""
|
||||
|
@ -173,7 +169,7 @@ class Sound(models.Model):
|
|||
if not self.file_exists():
|
||||
if self.type == self.TYPE_REMOVED:
|
||||
return
|
||||
logger.info('sound %s: has been removed', self.path)
|
||||
logger.info('sound %s: has been removed', self.file.name)
|
||||
self.type = self.TYPE_REMOVED
|
||||
return True
|
||||
|
||||
|
@ -183,7 +179,7 @@ class Sound(models.Model):
|
|||
if self.type == self.TYPE_REMOVED and self.program:
|
||||
changed = True
|
||||
self.type = self.TYPE_ARCHIVE \
|
||||
if self.path.startswith(self.program.archives_path) else \
|
||||
if self.file.path.startswith(self.program.archives_path) else \
|
||||
self.TYPE_EXCERPT
|
||||
|
||||
# check mtime -> reset quality if changed (assume file changed)
|
||||
|
@ -193,15 +189,15 @@ class Sound(models.Model):
|
|||
self.mtime = mtime
|
||||
self.is_good_quality = None
|
||||
logger.info('sound %s: m_time has changed. Reset quality info',
|
||||
self.path)
|
||||
self.file.name)
|
||||
return True
|
||||
|
||||
return changed
|
||||
|
||||
def __check_name(self):
|
||||
if not self.name and self.path:
|
||||
if not self.name and self.file and self.file.path:
|
||||
# FIXME: later, remove date?
|
||||
self.name = os.path.basename(self.path)
|
||||
self.name = os.path.basename(self.file.name)
|
||||
self.name = os.path.splitext(self.name)[0]
|
||||
self.name = self.name.replace('_', ' ')
|
||||
|
||||
|
|
|
@ -55,7 +55,6 @@ class LogInfoSerializer(serializers.Serializer):
|
|||
|
||||
class SoundSerializer(serializers.ModelSerializer):
|
||||
# serializers.HyperlinkedIdentityField(view_name='sound', format='html')
|
||||
|
||||
class Meta:
|
||||
model = Sound
|
||||
fields = ['pk', 'name', 'program', 'episode', 'type',
|
||||
|
@ -64,7 +63,7 @@ class SoundSerializer(serializers.ModelSerializer):
|
|||
def get_field_names(self, *args):
|
||||
names = super().get_field_names(*args)
|
||||
if 'request' in self.context and self.context['request'].user.is_staff:
|
||||
names.append('path')
|
||||
names.append('file')
|
||||
return names
|
||||
|
||||
class PodcastSerializer(serializers.ModelSerializer):
|
||||
|
|
|
@ -87,8 +87,7 @@ ensure('AIRCOX_DEFAULT_USER_GROUPS', {
|
|||
|
||||
# Directory for the programs data
|
||||
# TODO: rename to PROGRAMS_ROOT
|
||||
ensure('AIRCOX_PROGRAMS_DIR',
|
||||
os.path.join(settings.MEDIA_ROOT, 'programs'))
|
||||
ensure('AIRCOX_PROGRAMS_DIR', 'programs')
|
||||
|
||||
|
||||
########################################################################
|
||||
|
@ -152,8 +151,3 @@ ensure('AIRCOX_IMPORT_PLAYLIST_CSV_DELIMITER', ';')
|
|||
ensure('AIRCOX_IMPORT_PLAYLIST_CSV_TEXT_QUOTE', '"')
|
||||
|
||||
|
||||
if settings.MEDIA_ROOT not in AIRCOX_PROGRAMS_DIR:
|
||||
# PROGRAMS_DIR must be in MEDIA_ROOT for easy files url resolution
|
||||
# later should this restriction disappear.
|
||||
raise ValueError("settings: AIRCOX_PROGRAMS_DIR must be in MEDIA_ROOT")
|
||||
|
||||
|
|
File diff suppressed because one or more lines are too long
|
@ -8,7 +8,7 @@ List item for a podcast.
|
|||
{% if object.embed %}
|
||||
{{ object.embed|safe }}
|
||||
{% else %}
|
||||
<audio src="{{ object.url }}" controls>
|
||||
<audio src="{{ object.file.url }}" controls>
|
||||
{% endif %}
|
||||
{% endcomment %}
|
||||
<a-sound-item :data="{{ object|json }}" :player="player"
|
||||
|
|
|
@ -187,5 +187,5 @@ class QueueSourceViewSet(SourceViewSet):
|
|||
sound = get_object_or_404(self.get_sound_queryset(),
|
||||
pk=request.data['sound_id'])
|
||||
return self._run(
|
||||
pk, lambda s: s.push(sound.path) if sound.path else None)
|
||||
pk, lambda s: s.push(sound.file.path) if sound.file.path else None)
|
||||
|
||||
|
|
|
@ -12,6 +12,7 @@ const App = {
|
|||
|
||||
export const PlayerApp = {
|
||||
el: '#player',
|
||||
delimiters: ['[[', ']]'],
|
||||
components: {...components},
|
||||
}
|
||||
|
||||
|
|
|
@ -219,7 +219,7 @@ export default {
|
|||
|
||||
/// Push items to playlist (by name)
|
||||
push(playlist, ...items) {
|
||||
return this.$refs[playlist].push(...items);
|
||||
return this.sets[playlist].push(...items);
|
||||
},
|
||||
|
||||
/// Push and play items
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
:key="index">
|
||||
<a :class="index == selectedIndex ? 'is-active' : ''">
|
||||
<ASoundItem
|
||||
:data="item" :index="index" :player="player" :set="set"
|
||||
:data="item" :index="index" :set="set" :player="player_"
|
||||
@togglePlay="togglePlay(index)"
|
||||
:actions="actions">
|
||||
<template v-slot:actions="{}">
|
||||
|
@ -38,7 +38,8 @@ export default {
|
|||
},
|
||||
|
||||
computed: {
|
||||
self() { return this; }
|
||||
self() { return this; },
|
||||
player_() { return this.player || window.aircox.player },
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
@ -50,8 +51,8 @@ export default {
|
|||
},
|
||||
|
||||
togglePlay(index) {
|
||||
if(this.player.isPlaying(this.set.get(index)))
|
||||
this.player.pause();
|
||||
if(this.player_.isPlaying(this.set.get(index)))
|
||||
this.player_.pause();
|
||||
else
|
||||
this.select(index)
|
||||
},
|
||||
|
|
|
@ -35,6 +35,11 @@ window.aircox = {
|
|||
init(props=null, {config=null, builder=null, initBuilder=true,
|
||||
initPlayer=true, hotReload=false}={})
|
||||
{
|
||||
if(initPlayer) {
|
||||
let playerBuilder = this.playerBuilder
|
||||
playerBuilder.mount()
|
||||
}
|
||||
|
||||
if(initBuilder) {
|
||||
builder = builder || this.builder
|
||||
this.builder = builder
|
||||
|
@ -46,11 +51,6 @@ window.aircox = {
|
|||
if(hotReload)
|
||||
builder.enableHotReload(hotReload)
|
||||
}
|
||||
|
||||
if(initPlayer) {
|
||||
let playerBuilder = this.playerBuilder
|
||||
playerBuilder.mount()
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
@ -1,31 +1,19 @@
|
|||
asgiref==3.2.10
|
||||
bleach>=3.3.0
|
||||
Django==3.1.6
|
||||
django-admin-sortable2==0.7.7
|
||||
django-ckeditor==6.0.0
|
||||
django-content-editor==3.0.6
|
||||
django-filer==2.0.2
|
||||
django-filter==2.3.0
|
||||
django-honeypot==0.9.0
|
||||
django-js-asset==1.2.2
|
||||
django-model-utils==4.0.0
|
||||
django-mptt==0.11.0
|
||||
django-polymorphic==3.0.0
|
||||
django-taggit==1.3.0
|
||||
djangorestframework==3.11.2
|
||||
easy-thumbnails==2.7
|
||||
gunicorn==20.0.4
|
||||
mutagen==1.45.1
|
||||
packaging==20.4
|
||||
pathtools==0.1.2
|
||||
Pillow==8.1.1
|
||||
psutil==5.7.2
|
||||
pyparsing==2.4.7
|
||||
pytz==2020.1
|
||||
Django~=3.0
|
||||
djangorestframework~=3.13
|
||||
django-model-utils>=4.2
|
||||
django-filter~=21.1
|
||||
|
||||
django-filer~=2.1
|
||||
django-honeypot~=1.0
|
||||
django-taggit~=2.1
|
||||
django-admin-sortable2~=1.0
|
||||
django-ckeditor~=6.2
|
||||
bleach~=4.1
|
||||
easy-thumbnails~=2.8
|
||||
tzlocal~=4.1
|
||||
|
||||
mutagen~=1.45
|
||||
Pillow~=9.0
|
||||
psutil~=5.9
|
||||
PyYAML==5.4
|
||||
six==1.14.0
|
||||
sqlparse==0.3.1
|
||||
tzlocal==2.1
|
||||
Unidecode==1.1.1
|
||||
watchdog==0.10.3
|
||||
webencodings==0.5.1
|
||||
watchdog~=2.1
|
||||
|
|
Loading…
Reference in New Issue
Block a user