diff --git a/django_material_demo/.env.example b/django_material_demo/.env.example
index 900e367..cb4642c 100644
--- a/django_material_demo/.env.example
+++ b/django_material_demo/.env.example
@@ -7,3 +7,13 @@ POSTGRES_USER=postgres
POSTGRES_PASSWORD=GENERATE_SECRET[postgres_password]
POSTGRES_HOST=db
POSTGRES_PORT=5432
+
+FILE_STORAGE_IMPL=local_storage
+
+AWS_STORAGE_BUCKET_NAME=django-material-demo
+AWS_S3_REGION_NAME=[AWS_S3_REGION_NAME]
+AWS_S3_ACCESS_KEY_ID=[AWS_S3_ACCESS_KEY_ID]
+AWS_S3_SECRET_ACCESS_KEY=[AWS_S3_SECRET_ACCESS_KEY]
+
+AWS_S3_FILE_OVERWRITE=true
+AWS_LOCATION=media
diff --git a/django_material_demo/cms/polls/templates/cms_polls/menu.html b/django_material_demo/cms/polls/templates/cms_polls/menu.html
index a07ef12..c5f1022 100644
--- a/django_material_demo/cms/polls/templates/cms_polls/menu.html
+++ b/django_material_demo/cms/polls/templates/cms_polls/menu.html
@@ -1,7 +1,6 @@
{% comment %} https://materializecss.com/sidenav.html {% endcomment %}
- User List
- - File List
- Question List
- Vote List
-
diff --git a/django_material_demo/cms/polls/urls.py b/django_material_demo/cms/polls/urls.py
index bd5b9e5..842440c 100644
--- a/django_material_demo/cms/polls/urls.py
+++ b/django_material_demo/cms/polls/urls.py
@@ -1,4 +1,4 @@
-from .views import file, question, user, vote
+from .views import question, user, vote
from django.urls import include, path
from django.views.generic.base import RedirectView
@@ -12,7 +12,6 @@
user.PasswordChangeDoneView.as_view(),
name="password_change_done",),
- path('file/', include(file.FileViewSet().urls)),
path('question/', include(question.QuestionViewSet().urls)),
path('vote/', include(vote.VoteViewSet().urls)),
]
diff --git a/django_material_demo/cms/polls/views/file.py b/django_material_demo/cms/polls/views/file.py
deleted file mode 100644
index 7fa0dde..0000000
--- a/django_material_demo/cms/polls/views/file.py
+++ /dev/null
@@ -1,13 +0,0 @@
-from material.frontend.views import ModelViewSet
-from polls.models import File
-
-
-class FileViewSet(ModelViewSet):
- model = File
- list_display = ['file_name', 'file_type', 'file_size', 'storage_loc']
-
- def has_add_permission(self, request, obj=None):
- return False
-
- def has_change_permission(self, request, obj=None):
- return False
diff --git a/django_material_demo/cms/polls/views/question.py b/django_material_demo/cms/polls/views/question.py
index 2df9f9d..602cdfc 100644
--- a/django_material_demo/cms/polls/views/question.py
+++ b/django_material_demo/cms/polls/views/question.py
@@ -1,19 +1,27 @@
from django import forms
+from django.conf import settings
from django.core.exceptions import NON_FIELD_ERRORS, ValidationError
-from django.forms import BaseInlineFormSet, BooleanField, ModelForm
+from django.forms import (BaseInlineFormSet, BooleanField, FileField,
+ ImageField, ModelForm)
from django.forms.widgets import CheckboxInput
+from django.utils.safestring import mark_safe
from django_filters import CharFilter
from library.django_superform import InlineFormSetField, SuperModelForm
from material import Fieldset, Layout, Row
-from material.frontend.views import (CreateModelView, ListModelView,
- ModelViewSet, UpdateModelView)
+from material.frontend.views import (CreateModelView, DetailModelView,
+ ListModelView, ModelViewSet,
+ UpdateModelView)
from polls.models import Attachment, Choice, Question, QuestionFollower
from ...utils import (FieldDataMixin, FormSetForm, GetParamAsFormDataMixin,
- ListFilterView, NestedModelFormField, SearchAndFilterSet)
+ ListFilterView, NestedModelFormField, SearchAndFilterSet,
+ get_html_list)
class AttachmentsForm(FormSetForm):
+ file = FileField(label="Attachment",
+ max_length=settings.FILE_UPLOAD_MAX_MEMORY_SIZE)
+
layout = Layout('file')
parent_instance_field = 'question'
@@ -91,6 +99,8 @@ class Media:
class QuestionForm(SuperModelForm):
+ thumbnail = ImageField(required=False, label='Thumbnail',
+ max_length=settings.FILE_UPLOAD_MAX_MEMORY_SIZE)
max_vote_count_control = NestedModelFormField(MaxVoteCountForm)
# Formset fields
@@ -267,8 +277,40 @@ class QuestionListView(ListModelView, ListFilterView):
filterset_class = QuestionFilter
+class QuestionDetailView(DetailModelView):
+ def get_object_data(self):
+ question = super().get_object()
+ thumbnail_name = Question._meta.get_field('thumbnail')
+ thumbnail_name = thumbnail_name.verbose_name.title()
+ for item in super().get_object_data():
+ if item[0] == thumbnail_name:
+ # Skip if no image
+ if item[1]:
+ #TODO: replace with template
+ image_html = mark_safe(
+ f"")
+ yield (item[0], image_html)
+ else:
+ yield (item[0], 'None')
+ else:
+ yield item
+
+ attachments = question.attachment_set.all()
+ if len(attachments):
+ #TODO: replace with template
+ attachments = [
+ mark_safe(f"{x.file.name}")
+ for x in attachments]
+ html_list = get_html_list(attachments)
+ yield ('Attachments', html_list)
+ else:
+ yield ('Attachments', 'None')
+
+
class QuestionViewSet(ModelViewSet):
model = Question
create_view_class = QuestionCreateView
update_view_class = QuestionUpdateView
list_view_class = QuestionListView
+ detail_view_class = QuestionDetailView
diff --git a/django_material_demo/cms/utils/__init__.py b/django_material_demo/cms/utils/__init__.py
index 25b16e8..5efd655 100644
--- a/django_material_demo/cms/utils/__init__.py
+++ b/django_material_demo/cms/utils/__init__.py
@@ -3,6 +3,13 @@
from .modules import ModuleNamespaceMixin
from .views import ListFilterView, SearchAndFilterSet, get_html_list
-__all__ = [('FieldDataMixin', 'FormSetForm', 'GetParamAsFormDataMixin',
- 'NestedModelFormField', 'ModuleNamespaceMixin',
- 'ListFilterView', 'SearchAndFilterSet', 'get_html_list')]
+__all__ = (
+ 'FieldDataMixin',
+ 'FormSetForm',
+ 'GetParamAsFormDataMixin',
+ 'NestedModelFormField',
+ 'ModuleNamespaceMixin',
+ 'ListFilterView',
+ 'SearchAndFilterSet',
+ 'get_html_list',
+)
diff --git a/django_material_demo/cms/utils/views.py b/django_material_demo/cms/utils/views.py
index 6dca189..a1ff1ef 100644
--- a/django_material_demo/cms/utils/views.py
+++ b/django_material_demo/cms/utils/views.py
@@ -16,6 +16,7 @@ def get_html_list(arr):
if len(arr) == 0:
return ''
+ #TODO: replace with template
value_list = [conditional_escape(x) for x in arr]
value_list_html = mark_safe(
'
'
diff --git a/django_material_demo/django_material_demo/settings.py b/django_material_demo/django_material_demo/settings.py
index d4dc67c..caafa4c 100644
--- a/django_material_demo/django_material_demo/settings.py
+++ b/django_material_demo/django_material_demo/settings.py
@@ -52,6 +52,7 @@
'django.contrib.messages',
'django.contrib.staticfiles',
'django_filters',
+ 'storages',
]
MIDDLEWARE = [
@@ -163,6 +164,29 @@
STATIC_URL = 'static/'
+MEDIA_ROOT = 'media/'
+MEDIA_URL = 'media/'
+
+FILE_STORAGE_IMPL = str(os.getenv('FILE_STORAGE_IMPL'))
+
+if FILE_STORAGE_IMPL.lower() == 's3':
+ DEFAULT_FILE_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage'
+else:
+ # use django.core.files.storage.FileSystemStorage
+ # as defined in django/conf/global_settings.py
+ pass
+
+# Amazon S3 settings
+# https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html
+
+AWS_STORAGE_BUCKET_NAME = str(os.getenv('AWS_STORAGE_BUCKET_NAME'))
+AWS_S3_REGION_NAME = str(os.getenv('AWS_S3_REGION_NAME'))
+AWS_S3_ACCESS_KEY_ID = str(os.getenv('AWS_S3_ACCESS_KEY_ID'))
+AWS_S3_SECRET_ACCESS_KEY = str(os.getenv('AWS_S3_SECRET_ACCESS_KEY'))
+AWS_S3_FILE_OVERWRITE = (str(os.getenv('AWS_S3_FILE_OVERWRITE')).lower()
+ in ['true', 'yes', '1'])
+AWS_LOCATION = str(os.getenv('AWS_LOCATION'))
+
# Default primary key field type
# https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field
diff --git a/django_material_demo/django_material_demo/urls.py b/django_material_demo/django_material_demo/urls.py
index cf17366..61c448d 100644
--- a/django_material_demo/django_material_demo/urls.py
+++ b/django_material_demo/django_material_demo/urls.py
@@ -13,6 +13,8 @@
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
+from django.conf import settings
+from django.conf.urls.static import static
from django.contrib import admin
from django.contrib.auth.views import LoginView
from django.urls import include, path
@@ -29,4 +31,4 @@
# reserve `polls` namespace for polls urls in CMS
path('polls/', include('polls.urls', namespace='app_polls')),
path('cms/', include(frontend_urls)),
-]
+] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
diff --git a/django_material_demo/polls/admin.py b/django_material_demo/polls/admin.py
index 64f1d61..7b05b4a 100644
--- a/django_material_demo/polls/admin.py
+++ b/django_material_demo/polls/admin.py
@@ -1,23 +1,6 @@
from django.contrib import admin
-from django.conf import settings
-from django.contrib.auth import get_user_model
-from .models import Attachment, Choice, File, Question, User, UserFollower, Vote
-
-
-@admin.register(File)
-class FileAdmin(admin.ModelAdmin):
- readonly_fields = [
- 'file_id',
- 'storage_loc',
- 'file_name',
- 'file_type',
- 'file_size',
- ]
-
- list_display = ['file_name', 'file_type', 'file_size', 'storage_loc']
- list_filter = ['file_type', 'storage_loc']
- search_fields = ['file_name']
+from .models import Attachment, Choice, Question, User, UserFollower, Vote
class FollowedQuestion(admin.TabularInline):
diff --git a/django_material_demo/polls/migrations/0012_add_temp_fields.py b/django_material_demo/polls/migrations/0012_add_temp_fields.py
new file mode 100644
index 0000000..7c21e3a
--- /dev/null
+++ b/django_material_demo/polls/migrations/0012_add_temp_fields.py
@@ -0,0 +1,68 @@
+# Generated by Django 4.1 on 2022-09-22 11:17
+
+from django.core.files.storage import get_storage_class
+from django.db import migrations, models
+
+
+STORAGE = get_storage_class()()
+# Should match with same variable in migration 0014
+DUMMY_FILE_NAME = 'DUMMY_FILE_NAME'
+
+
+def copy_file_fk(apps, schema_editor):
+ question_model = apps.get_model('polls', 'Question')
+ attachment_model = apps.get_model('polls', 'Attachment')
+ for question in question_model.objects.all():
+ if question.thumbnail:
+ question.thumbnail_copy = question.thumbnail
+ question.save()
+ for attachment in attachment_model.objects.all():
+ attachment.file_copy = attachment.file
+ attachment.save()
+ pass
+
+
+def delete_dummy_file(name):
+ STORAGE.delete(name)
+
+
+def restore_file_fk(apps, schema_editor):
+ file_model = apps.get_model('polls', 'File')
+ question_model = apps.get_model('polls', 'Question')
+ attachment_model = apps.get_model('polls', 'Attachment')
+
+ for question in question_model.objects.all():
+ delete_dummy_file(str(question.thumbnail_copy.pk))
+ if question.thumbnail_copy.file_name == DUMMY_FILE_NAME:
+ question.thumbnail = None
+ question.thumbnail_copy = None
+ else:
+ question.thumbnail = question.thumbnail_copy
+ question.save()
+ for attachment in attachment_model.objects.all():
+ delete_dummy_file(str(attachment.file_copy.pk))
+ attachment.file = attachment.file_copy
+ attachment.save()
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('polls', '0011_alter_attachment_options_alter_choice_options_and_more'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='attachment',
+ name='file_copy',
+ field=models.ForeignKey(
+ 'polls.file', on_delete=models.DO_NOTHING, null=True, blank=True),
+ ),
+ migrations.AddField(
+ model_name='question',
+ name='thumbnail_copy',
+ field=models.ForeignKey(
+ 'polls.file', on_delete=models.DO_NOTHING, null=True, blank=True),
+ ),
+ migrations.RunPython(copy_file_fk, restore_file_fk),
+ ]
diff --git a/django_material_demo/polls/migrations/0013_alter_attachment_file_alter_question_thumbnail.py b/django_material_demo/polls/migrations/0013_alter_attachment_file_alter_question_thumbnail.py
new file mode 100644
index 0000000..14c262e
--- /dev/null
+++ b/django_material_demo/polls/migrations/0013_alter_attachment_file_alter_question_thumbnail.py
@@ -0,0 +1,23 @@
+# Generated by Django 4.1 on 2022-09-23 09:52
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('polls', '0012_add_temp_fields'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='attachment',
+ name='file',
+ field=models.FileField(blank=True, null=True, upload_to=''),
+ ),
+ migrations.AlterField(
+ model_name='question',
+ name='thumbnail',
+ field=models.FileField(blank=True, null=True, upload_to=''),
+ ),
+ ]
diff --git a/django_material_demo/polls/migrations/0014_remove_file_model_and_more.py b/django_material_demo/polls/migrations/0014_remove_file_model_and_more.py
new file mode 100644
index 0000000..7dd7751
--- /dev/null
+++ b/django_material_demo/polls/migrations/0014_remove_file_model_and_more.py
@@ -0,0 +1,81 @@
+# Generated by Django 4.1 on 2022-09-23 09:52
+
+import io
+
+from django.core.files import File as DjangoFile
+from django.core.files.storage import get_storage_class
+from django.db import migrations
+
+
+STORAGE = get_storage_class()()
+# Should match with same variable in migration 0012
+DUMMY_FILE_NAME = 'DUMMY_FILE_NAME'
+
+
+def migrate_files(apps, schema_editor):
+ question_model = apps.get_model('polls', 'Question')
+ attachment_model = apps.get_model('polls', 'Attachment')
+
+ for question in question_model.objects.all():
+ if question.thumbnail:
+ file = question.thumbnail_copy
+ question.thumbnail = file.file_name
+ question.save()
+ for attachment in attachment_model.objects.all():
+ file = attachment.file_copy
+ attachment.file = file.file_name
+ attachment.save()
+ pass
+
+
+def create_dummy_file(file_instance):
+ return DjangoFile(io.StringIO(), name=str(file_instance.pk))
+
+
+def migrate_back_files(apps, schema_editor):
+ file_model = apps.get_model('polls', 'File')
+ question_model = apps.get_model('polls', 'Question')
+ attachment_model = apps.get_model('polls', 'Attachment')
+
+ file_data_default = {
+ 'file_id': 'DEFAULT_0123456789abcdef',
+ 'file_size': '123456',
+ 'storage_loc': 'local_storage',
+ 'file_type': 'application/octet-stream'
+ }
+ for question in question_model.objects.all():
+ file_name = question.thumbnail or DUMMY_FILE_NAME
+ file = file_model.objects.create(
+ file_name=file_name, **file_data_default)
+ question.thumbnail_copy = file
+ question.thumbnail = create_dummy_file(file)
+ question.save()
+ for attachment in attachment_model.objects.all():
+ file = file_model.objects.create(
+ file_name=attachment.file, **file_data_default)
+ attachment.file_copy = file
+ attachment.file = create_dummy_file(file)
+ attachment.save()
+ pass
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('polls', '0013_alter_attachment_file_alter_question_thumbnail'),
+ ]
+
+ operations = [
+ migrations.RunPython(migrate_files, migrate_back_files),
+ migrations.RemoveField(
+ model_name='attachment',
+ name='file_copy',
+ ),
+ migrations.RemoveField(
+ model_name='question',
+ name='thumbnail_copy',
+ ),
+ migrations.DeleteModel(
+ name='File',
+ ),
+ ]
diff --git a/django_material_demo/polls/models.py b/django_material_demo/polls/models.py
index a8d3d49..018bb58 100644
--- a/django_material_demo/polls/models.py
+++ b/django_material_demo/polls/models.py
@@ -4,20 +4,6 @@
from django.utils import timezone
-class File(models.Model):
- file_id = models.TextField()
- storage_loc = models.TextField('storage location')
- file_name = models.CharField(max_length=100)
- file_type = models.TextField()
- file_size = models.IntegerField()
-
- class Meta:
- ordering = ['file_name', 'id']
-
- def __str__(self):
- return self.file_name
-
-
class User(models.Model):
account = models.OneToOneField(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
@@ -80,8 +66,7 @@ class Question(models.Model):
question_text = models.CharField(max_length=200)
total_vote_count = models.IntegerField(default=0)
- thumbnail = models.ForeignKey(
- File, on_delete=models.CASCADE, null=True, blank=True)
+ thumbnail = models.FileField(blank=True, null=True)
creator = models.ForeignKey(
User, on_delete=models.CASCADE, related_name='question_creates',
@@ -173,7 +158,7 @@ def choice_text(self):
class Attachment(models.Model):
question = models.ForeignKey(Question, on_delete=models.CASCADE)
- file = models.OneToOneField(File, on_delete=models.CASCADE)
+ file = models.FileField(blank=True, null=True)
class Meta:
ordering = ['file', 'id']
diff --git a/django_material_demo/polls/static/css/style.css b/django_material_demo/polls/static/css/style.css
index b64ac0a..fb286b2 100644
--- a/django_material_demo/polls/static/css/style.css
+++ b/django_material_demo/polls/static/css/style.css
@@ -8,3 +8,8 @@
.input-field .button-group > * {
margin: 4px;
}
+
+img.thumbnail {
+ max-width: 48px;
+ max-height: 48px;
+}
diff --git a/django_material_demo/polls/templates/polls/home.html b/django_material_demo/polls/templates/polls/home.html
index f4e5cc3..32270e6 100644
--- a/django_material_demo/polls/templates/polls/home.html
+++ b/django_material_demo/polls/templates/polls/home.html
@@ -1,4 +1,7 @@
+{% load static %}
+
{% include './includes/top_nav.html' %}
+
Home
@@ -9,7 +12,14 @@
{% if latest_question_list %}
{% else %}
diff --git a/django_material_demo/polls/templates/polls/question.html b/django_material_demo/polls/templates/polls/question.html
index e413e7d..dcfcc0a 100644
--- a/django_material_demo/polls/templates/polls/question.html
+++ b/django_material_demo/polls/templates/polls/question.html
@@ -1,4 +1,7 @@
+{% load static %}
+
{% include './includes/top_nav.html' %}
+
Home >
@@ -8,7 +11,13 @@
diff --git a/django_material_demo/requirements.txt b/django_material_demo/requirements.txt
index cb2b3dd..342d173 100644
--- a/django_material_demo/requirements.txt
+++ b/django_material_demo/requirements.txt
@@ -1,9 +1,16 @@
asgiref==3.5.2
+boto3==1.24.80
+botocore==1.27.80
Django==4.1
django-filter==22.1
django-material==1.10.0
+django-storages==1.13.1
+jmespath==1.0.1
+Pillow==9.2.0
psycopg2==2.9.3
+python-dateutil==2.8.2
python-dotenv==0.20.0
PyYAML==6.0
+s3transfer==0.6.0
six==1.16.0
sqlparse==0.4.2