Skip to content
Snippets Groups Projects
Commit d04fe080 authored by florimondmanca's avatar florimondmanca
Browse files

add AWS S3 file storage in production, update clean_media command to be storage-agnostic

parent 0a27fcfa
No related branches found
No related tags found
No related merge requests found
...@@ -19,3 +19,39 @@ def find_file(pattern, root=os.curdir): ...@@ -19,3 +19,39 @@ def find_file(pattern, root=os.curdir):
for path in locate(pattern, root=root): for path in locate(pattern, root=root):
return path return path
raise FileNotFoundError(pattern) raise FileNotFoundError(pattern)
def walk(storage, top='/', topdown=True, onerror=None):
"""Implement os.walk using a Django storage.
Refer to the documentation of os.walk().
Inspired by: https://gist.github.com/btimby/2175107
Parameters
----------
storage : django.Storage
top : str, optional
Same role as in os.walk().
The path at which the walk should begin. Root directory by default.
topdown : bool, optional
Same role and default value as in os.walk().
onerror : function, optional
Same role and default value as in os.walk().
"""
try:
dirs, nondirs = storage.listdir(top)
except (os.error, Exception) as err:
if onerror is not None:
onerror(err)
return
if topdown:
yield top, dirs, nondirs
for name in dirs:
new_path = os.path.join(top, name)
# recursively list subdirectories
for top_, dirs_, nondirs_ in walk(storage, top=new_path):
yield top_, dirs_, nondirs_
if not topdown:
yield top, dirs, nondirs
"""Clean unusued media files.""" """Clean unusued media files."""
import os import os.path
from django.apps import apps from django.apps import apps
from django.conf import settings from django.conf import settings
from django.core.files.storage import default_storage
from django.core.management import BaseCommand from django.core.management import BaseCommand
from django.db.models import FileField, Q from django.db.models import FileField, Q
from markdownx.models import MarkdownxField
from core.file_utils import find_file from core.file_utils import walk as os_walk
from core.markdown import extract_md_file_refs from core.markdown import extract_md_file_refs
from markdownx.models import MarkdownxField
class Command(BaseCommand): class Command(BaseCommand):
...@@ -20,13 +23,21 @@ class Command(BaseCommand): ...@@ -20,13 +23,21 @@ class Command(BaseCommand):
help = "Delete all unused media files." help = "Delete all unused media files."
media_root = getattr(settings, 'MEDIA_ROOT', None) media_root = getattr(settings, 'MEDIA_ROOT', None)
top = ''
def add_arguments(self, parser):
parser.add_argument(
'--top',
dest='top',
help='Top directory in which to look for unused media files.',
)
def get_db_files(self): def get_db_files(self):
"""Retrieve all references to files in the database. """Retrieve all references to files in the database.
Returns Returns
------- -------
db_files : set of filenames db_files : set of file paths relative to the MEDIA_ROOT
""" """
all_models = apps.get_models() all_models = apps.get_models()
db_files = set() db_files = set()
...@@ -43,11 +54,16 @@ class Command(BaseCommand): ...@@ -43,11 +54,16 @@ class Command(BaseCommand):
is_null = {'{}__isnull'.format(field.name): True} is_null = {'{}__isnull'.format(field.name): True}
is_empty = {'{}__exact'.format(field.name): ''} is_empty = {'{}__exact'.format(field.name): ''}
filters &= Q(**is_null) | Q(**is_empty) filters &= Q(**is_null) | Q(**is_empty)
def get_file_fields(field):
for obj in model.objects.exclude(filters):
yield getattr(obj, field)
for field in file_fields: for field in file_fields:
field_files = (model.objects.exclude(filters) # field_file.name is the path relative to the media root
.values_list(field, flat=True) file_paths = [field_file.name
.distinct()) for field_file in get_file_fields(field)]
db_files.update(field_files) db_files.update(file_paths)
# MarkdownxFields can contain references to files in the form # MarkdownxFields can contain references to files in the form
# of [](xxx) or ![](xxx). # of [](xxx) or ![](xxx).
...@@ -62,41 +78,36 @@ class Command(BaseCommand): ...@@ -62,41 +78,36 @@ class Command(BaseCommand):
return db_files return db_files
def get_physical_files(self): def get_storage_files(self):
"""Retrieve the set of physical media files. """Retrieve the set of media files stored in default_storage.
Returns Returns
------- -------
physical_files : set of filenames storage_files : set of file paths relative to the MEDIA_ROOT
""" """
physical_files = set() storage_files = set()
if not self.media_root: if not self.media_root:
return physical_files return storage_files
# Get all files from the MEDIA_ROOT, recursively # Directory where to search for storage files.
for dir_root, dirs, files in os.walk(self.media_root): top_root = os.path.join(self.media_root, self.top)
# Get all files from the top root, recursively
for dir_root, dirs, files in os_walk(default_storage, top_root):
# dir_root is the absolute path except the filename
# files contains filenames
dir_relative_to_media_root = dir_root.replace(self.media_root, '')
# remove leading slash
dir_relative_to_media_root = dir_relative_to_media_root.lstrip('/')
for file_ in files: for file_ in files:
physical_files.add(file_) file_path = os.path.join(dir_relative_to_media_root, file_)
storage_files.add(file_path)
return physical_files return storage_files
def handle(self, *args, **options):
"""Find unused media files and delete them."""
db_files = self.get_db_files()
physical_files = self.get_physical_files()
# Delete physical files that have no references in the DB.
# NOTE: DB files that are not physical files will not be included.
# This means that potential URLs detected in Markdown fields
# are not considered as deletables.
deletables = physical_files - db_files
if deletables:
self.stdout.write(
self.style.NOTICE('Unused media files were detected:'))
self.stdout.write('\n'.join(f_ for f_ in deletables))
def clean_files(self, deletables):
"""Remove a list of files from default_storage."""
deleted = 0 deleted = 0
# Record files not found for safety measure. # Record files not found for safety measure.
# Normally, all deletables should be existing files. # Normally, all deletables should be existing files.
...@@ -105,17 +116,44 @@ class Command(BaseCommand): ...@@ -105,17 +116,44 @@ class Command(BaseCommand):
# Delete files # Delete files
for file_ in deletables: for file_ in deletables:
try: try:
os.remove(find_file(file_, self.media_root)) default_storage.delete(file_)
deleted += 1 deleted += 1
except FileNotFoundError as e: except FileNotFoundError as e:
not_found += 1 not_found += 1
# Delete all empty folders, bottom-up # Delete all empty folders
walk = os.walk(self.media_root, topdown=False) for dir_root, dirs, files in os_walk(default_storage, self.media_root,
for relative_root, dirs, files in walk: topdown=False):
for dir_ in dirs: for dir_ in dirs:
if not os.listdir(os.path.join(relative_root, dir_)): # build the path relative to the media_root
os.rmdir(os.path.join(relative_root, dir_)) dir_path = os.path.join(dir_root, dir_)
dir_path = dir_path.replace(self.media_root, '').lstrip('/')
# check the contents of the directory
dirs_here, non_dirs_here = default_storage.listdir(dir_path)
if not dirs_here and not non_dirs_here:
default_storage.delete(dir_path)
return deleted, not_found
def handle(self, *args, **options):
"""Find unused media files and delete them."""
self.top = options.get('top', '')
db_files = self.get_db_files()
storage_files = self.get_storage_files()
# Delete physical files that have no references in the DB.
# NOTE: DB files that are not physical files will not be included.
# This means that potential URLs detected in Markdown fields
# are not considered as deletables, which is an expected behavior.
deletables = storage_files - db_files
if deletables:
self.stdout.write(
self.style.NOTICE('Unused media files were detected:'))
self.stdout.write('\n'.join(f_ for f_ in deletables))
deleted, not_found = self.clean_files(deletables)
self.stdout.write( self.stdout.write(
self.style.SUCCESS('Removed {} unused media file(s).' self.style.SUCCESS('Removed {} unused media file(s).'
......
This is just an example text file.
"""
Django settings for oser_backend project.
Base settings common to all environments.
"""
import os
from django.contrib.messages import constants as messages
import dj_database_url
import pymdownx.emoji
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
dn = os.path.dirname
BASE_DIR = dn(dn(dn(os.path.abspath(__file__))))
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/1.11/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
# One way to do this is to store it in an environment variable on the server
SECRET_KEY = os.environ.get('SECRET_KEY',
'odfuioTvdfvkdhvjeT9659dbnkcn2332fk564jvdf034')
DEBUG = False
ALLOWED_HOSTS = ['localhost']
ADMINS = (
('admin', 'admin@oser-cs.fr'),
)
ADMIN_INITIAL_PASSWORD = 'admin' # to be changed after first login
# Application definition
DJANGO_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'whitenoise.runserver_nostatic',
'django.contrib.staticfiles',
'django.forms',
]
THIRD_PARTY_APPS = [
# Markdown integration
'markdownx',
# Django REST Framework (DRF)
'rest_framework',
'rest_framework.authtoken',
# DRY REST permissions (rules-based API permissions)
# https://github.com/dbkaplan/dry-rest-permissions
'dry_rest_permissions',
# CORS headers for Frontend integration
'corsheaders',
# Sortable models in Admin
'adminsortable2',
# Django Guardian: per object permissions
# https://github.com/django-guardian/django-guardian
'guardian',
]
PROJECT_APPS = [
'core.apps.CoreConfig',
'users.apps.UsersConfig',
'tutoring.apps.TutoringConfig',
'api.apps.ApiConfig',
'showcase_site.apps.ShowcaseSiteConfig',
'visits.apps.VisitsConfig',
]
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + PROJECT_APPS
MIDDLEWARE = [
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.locale.LocaleMiddleware',
'corsheaders.middleware.CorsMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.security.SecurityMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
CORS_ORIGIN_ALLOW_ALL = True
ROOT_URLCONF = 'oser_backend.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [os.path.join(BASE_DIR, 'templates')],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
FORM_RENDERER = 'django.forms.renderers.TemplatesSetting'
WSGI_APPLICATION = 'oser_backend.wsgi.application'
# Flash messages classes
MESSAGE_TAGS = {
messages.INFO: 'alert alert-info alert-dismissible fade show',
messages.SUCCESS: 'alert alert-success alert-dismissible fade show',
messages.WARNING: 'alert alert-warning alert-dismissible fade show',
messages.ERROR: 'alert alert-danger alert-dismissible fade show',
}
# Django rest framework
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework.authentication.TokenAuthentication',
# v Enable session authentication in the browsable API
'rest_framework.authentication.SessionAuthentication',
],
}
# Pymdown-extensions Emoji configuration
extension_configs = {
'emoji_index': pymdownx.emoji.twemoji,
'emoji_generator': pymdownx.emoji.to_png,
'alt': 'short',
'options': {
'attributes': {
'align': 'absmiddle',
'height': '20px',
'width': '20px'
},
'image_path': 'https://assets-cdn.github.com/images/icons/emoji/unicode/',
'non_standard_image_path': 'https://assets-cdn.github.com/images/icons/emoji/'
}
}
# Markdownx settings
MARKDOWNX_MARKDOWN_EXTENSIONS = [
'pymdownx.emoji',
]
MARKDOWNX_MARKDOWN_EXTENSION_CONFIGS = {
'pymdownx.emoji': extension_configs,
}
# Database
DATABASES = {
'default': dj_database_url.config(
default='postgres://postgres:postgres@localhost:5432/oser_backend_db'),
}
# Security: SSL and HTTPS
# SECURE_SSL_REDIRECT = True # redirect all to HTTPS
# SESSION_COOKIE_SECURE = True
# CSRF_COOKIE_SECURE = True
# Authentication
AUTH_USER_MODEL = 'users.User'
AUTHENTICATION_BACKENDS = [
'django.contrib.auth.backends.ModelBackend', # default
'guardian.backends.ObjectPermissionBackend',
]
# Password validation
# https://docs.djangoproject.com/en/1.11/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
# Internationalization
# https://docs.djangoproject.com/en/2.0/topics/i18n/
LANGUAGE_CODE = 'fr-fr'
TIME_ZONE = 'Europe/Paris'
USE_I18N = True
USE_L10N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/2.0/howto/static-files/
STATIC_URL = '/static/'
STATIC_FILES_DIRS = [
os.path.join(BASE_DIR, 'staticfiles'),
]
STATIC_ROOT = os.path.join(BASE_DIR, 'static')
# User-uploaded media files
MEDIA_URL = '/media/'
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
from .default import * """
Django settings for oser_backend project.
Base settings common to all environments.
"""
import os
from django.contrib.messages import constants as messages
import dj_database_url
import pymdownx.emoji
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
dn = os.path.dirname
BASE_DIR = dn(dn(dn(os.path.abspath(__file__))))
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/1.11/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
# One way to do this is to store it in an environment variable on the server
SECRET_KEY = os.environ.get('SECRET_KEY',
'odfuioTvdfvkdhvjeT9659dbnkcn2332fk564jvdf034')
DEBUG = True DEBUG = True
ALLOWED_HOSTS = ['localhost']
ADMINS = (
('admin', 'admin@oser-cs.fr'),
)
ADMIN_INITIAL_PASSWORD = 'admin' # to be changed after first login
# Application definition
DJANGO_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'whitenoise.runserver_nostatic',
'django.contrib.staticfiles',
'django.forms',
]
THIRD_PARTY_APPS = [
# Markdown integration
'markdownx',
# Django REST Framework (DRF)
'rest_framework',
'rest_framework.authtoken',
# DRY REST permissions (rules-based API permissions)
# https://github.com/dbkaplan/dry-rest-permissions
'dry_rest_permissions',
# CORS headers for Frontend integration
'corsheaders',
# Sortable models in Admin
'adminsortable2',
# Django Guardian: per object permissions
# https://github.com/django-guardian/django-guardian
'guardian',
# Extra Django file storage backends
'storages',
]
PROJECT_APPS = [
'core.apps.CoreConfig',
'users.apps.UsersConfig',
'tutoring.apps.TutoringConfig',
'api.apps.ApiConfig',
'showcase_site.apps.ShowcaseSiteConfig',
'visits.apps.VisitsConfig',
]
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + PROJECT_APPS
MIDDLEWARE = [
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.locale.LocaleMiddleware',
'corsheaders.middleware.CorsMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.security.SecurityMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
CORS_ORIGIN_ALLOW_ALL = True
ROOT_URLCONF = 'oser_backend.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [os.path.join(BASE_DIR, 'templates')],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
FORM_RENDERER = 'django.forms.renderers.TemplatesSetting'
WSGI_APPLICATION = 'oser_backend.wsgi.application'
# Flash messages classes
MESSAGE_TAGS = {
messages.INFO: 'alert alert-info alert-dismissible fade show',
messages.SUCCESS: 'alert alert-success alert-dismissible fade show',
messages.WARNING: 'alert alert-warning alert-dismissible fade show',
messages.ERROR: 'alert alert-danger alert-dismissible fade show',
}
# Django rest framework
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework.authentication.TokenAuthentication',
# v Enable session authentication in the browsable API
'rest_framework.authentication.SessionAuthentication',
],
}
# Pymdown-extensions Emoji configuration
extension_configs = {
'emoji_index': pymdownx.emoji.twemoji,
'emoji_generator': pymdownx.emoji.to_png,
'alt': 'short',
'options': {
'attributes': {
'align': 'absmiddle',
'height': '20px',
'width': '20px'
},
'image_path': 'https://assets-cdn.github.com/images/icons/emoji/unicode/',
'non_standard_image_path': 'https://assets-cdn.github.com/images/icons/emoji/'
}
}
# Markdownx settings
MARKDOWNX_MARKDOWN_EXTENSIONS = [
'pymdownx.emoji',
]
MARKDOWNX_MARKDOWN_EXTENSION_CONFIGS = {
'pymdownx.emoji': extension_configs,
}
# Database
DATABASES = {
'default': dj_database_url.config(
default='postgres://postgres:postgres@localhost:5432/oser_backend_db'),
}
# Security: SSL and HTTPS
# SECURE_SSL_REDIRECT = True # redirect all to HTTPS
# SESSION_COOKIE_SECURE = True
# CSRF_COOKIE_SECURE = True
# Authentication
AUTH_USER_MODEL = 'users.User'
AUTHENTICATION_BACKENDS = [
'django.contrib.auth.backends.ModelBackend', # default
'guardian.backends.ObjectPermissionBackend',
]
# Password validation
# https://docs.djangoproject.com/en/1.11/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
# Storage
# Explicitly set default file storage to filesystem in development
DEFAULT_FILE_STORAGE = 'django.core.files.storage.FileSystemStorage'
# Internationalization
# https://docs.djangoproject.com/en/2.0/topics/i18n/
LANGUAGE_CODE = 'fr-fr'
TIME_ZONE = 'Europe/Paris'
USE_I18N = True
USE_L10N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/2.0/howto/static-files/
STATIC_URL = '/static/'
STATIC_FILES_DIRS = [
os.path.join(BASE_DIR, 'staticfiles'),
]
STATIC_ROOT = os.path.join(BASE_DIR, 'static')
# User-uploaded media files
MEDIA_URL = '/media/'
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
import os import os
import django_heroku import django_heroku
from .default import * from .dev import *
DEBUG = True DEBUG = False
ALLOWED_HOSTS = ['florimondmanca.pythonanywhere.com', 'localhost', ALLOWED_HOSTS = ['florimondmanca.pythonanywhere.com', 'localhost',
'*.herokuapp.com'] '*.herokuapp.com']
# AWS S3 Storage config
# Use Amazon S3 storage for media files
DEFAULT_FILE_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage'
# Also send static files to Amazon S3
STATICFILES_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage'
# Credentials
AWS_ACCESS_KEY_ID = os.environ.get('AWS_ACCESS_KEY_ID')
AWS_SECRET_ACCESS_KEY = os.environ.get('AWS_SECRET_ACCESS_KEY')
AWS_STORAGE_BUCKET_NAME = 'oser_backend_files'
AWS_S3_OBJECT_PARAMETERS = {
'CacheControl': 'max-age=86400',
}
# Activate automatic Heroku settings configuration # Activate automatic Heroku settings configuration
django_heroku.settings(locals()) django_heroku.settings(locals())
"""General URL Configuration.""" """General URL Configuration."""
from django.conf import settings
from django.conf.urls import url from django.conf.urls import url
from django.conf.urls.static import static
from django.contrib import admin from django.contrib import admin
from django.urls import include from django.urls import include
from django.views.generic import RedirectView from django.views.generic import RedirectView
...@@ -22,3 +24,8 @@ urlpatterns = [ ...@@ -22,3 +24,8 @@ urlpatterns = [
# Markdown 3rd party app # Markdown 3rd party app
url(r'^markdownx/', include('markdownx.urls')), url(r'^markdownx/', include('markdownx.urls')),
] ]
# Serve media files in development
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL,
document_root=settings.MEDIA_ROOT)
"""Showcase site models.""" """Showcase site models."""
from django.utils.text import slugify
from django.db import models from django.db import models
from django.shortcuts import reverse from django.shortcuts import reverse
from django.utils.text import slugify
from dry_rest_permissions.generics import authenticated_users from dry_rest_permissions.generics import authenticated_users
from markdownx.models import MarkdownxField from markdownx.models import MarkdownxField
# Create your models here. # Create your models here.
...@@ -57,7 +59,8 @@ class Article(models.Model): ...@@ -57,7 +59,8 @@ class Article(models.Model):
'contenu', 'contenu',
help_text="Contenu complet de l'article") help_text="Contenu complet de l'article")
published = models.DateTimeField('date de publication', auto_now_add=True) published = models.DateTimeField('date de publication', auto_now_add=True)
image = models.ImageField('illustration', blank=True, null=True) image = models.ImageField('illustration', blank=True, null=True,
upload_to='articles/')
pinned = models.BooleanField('épinglé', default=False, blank=True) pinned = models.BooleanField('épinglé', default=False, blank=True)
# ^blank=True to allow True of False value (otherwise # ^blank=True to allow True of False value (otherwise
# validation would force pinned to be True) # validation would force pinned to be True)
...@@ -171,6 +174,7 @@ class Partner(models.Model): ...@@ -171,6 +174,7 @@ class Partner(models.Model):
"Dimensions recommandées : hauteur = 320px. " "Dimensions recommandées : hauteur = 320px. "
), ),
null=True, null=True,
upload_to='partners/',
) )
premium = models.BooleanField( premium = models.BooleanField(
'partenaire privilégié', default=False, 'partenaire privilégié', default=False,
......
import os.path
from django.test import TestCase
from django.core.files.storage import default_storage
from django.core.files import File
from django.core.management import call_command
from visits.factory import VisitFactory
from visits.models import Visit
from core.management.commands.utils import DataLoader
class CleanMediaTest(TestCase):
"""Test the clean_media command."""
ref_file_name = 'visit-factsheet.pdf'
safe_location = 'you_will_not_find_me'
non_ref_file_name = 'example.txt'
non_ref_file_path = os.path.join(safe_location, non_ref_file_name)
def setUp(self):
# create objects in DB with references to files
with DataLoader().load(self.ref_file_name) as fact_sheet:
visit = VisitFactory.create(fact_sheet=fact_sheet)
self.ref_file = visit.fact_sheet # FieldFile object
# add files that are not referenced in DB
with DataLoader().load(self.non_ref_file_name) as example_file:
self.non_ref_file = File(example_file)
default_storage.save(self.non_ref_file_path, self.non_ref_file)
def test_command(self):
"""Call the command to check no error occurs."""
ref_file_dir = Visit._meta.get_field('fact_sheet').upload_to
ref_file_path = os.path.join(ref_file_dir, self.ref_file_name)
# check that files are well in the storage
self.assertTrue(default_storage.exists(ref_file_path))
self.assertTrue(default_storage.exists(self.non_ref_file_path))
# call the command
call_command('clean_media', top=self.safe_location, verbosity=0)
# file under test_files/ without reference must have been deleted
self.assertFalse(default_storage.exists(self.non_ref_file_path))
# file with ref in DB must not have been deleted
self.assertTrue(default_storage.exists(ref_file_path))
# safe location must have been emptied and deleted
self.assertFalse(default_storage.exists(self.safe_location))
...@@ -162,10 +162,12 @@ class Visit(models.Model): ...@@ -162,10 +162,12 @@ class Visit(models.Model):
help_text=( help_text=(
"Une illustration représentative de la sortie. " "Une illustration représentative de la sortie. "
"Dimensions : ???x???" "Dimensions : ???x???"
)) ),
upload_to='visits/images/')
fact_sheet = models.FileField( fact_sheet = models.FileField(
'fiche sortie', blank=True, null=True, 'fiche sortie', blank=True, null=True,
help_text="Formats supportés : PDF") help_text="Formats supportés : PDF",
upload_to='visits/fact_sheets/')
participants = models.ManyToManyField('users.User', participants = models.ManyToManyField('users.User',
through='VisitParticipant') through='VisitParticipant')
organizers_group = models.OneToOneField('auth.Group', organizers_group = models.OneToOneField('auth.Group',
......
...@@ -14,3 +14,4 @@ dj-database-url ...@@ -14,3 +14,4 @@ dj-database-url
whitenoise whitenoise
django-heroku django-heroku
gunicorn gunicorn
django-storages
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment