Merge pull request #24 from jedie/user-images

Store Images to Items
pull/25/head
Jens Diemer 2020-11-15 14:39:57 +01:00 zatwierdzone przez GitHub
commit 4b81a41b84
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 4AEE18F83AFDEB23
19 zmienionych plików z 344 dodań i 25 usunięć

Wyświetl plik

@ -2,6 +2,6 @@
# Move to pyproject.toml after: https://gitlab.com/pycqa/flake8/-/issues/428 # Move to pyproject.toml after: https://gitlab.com/pycqa/flake8/-/issues/428
# #
[flake8] [flake8]
exclude = .tox, .pytest_cache, *.egg-info, */migrations/*, volumes exclude = .pytest_cache, .tox, dist, htmlcov, */migrations/*, volumes
#ignore = E402 #ignore = E402
max-line-length = 119 max-line-length = 119

Wyświetl plik

@ -43,15 +43,15 @@ update: check-poetry ## update the sources and installation
poetry update poetry update
lint: ## Run code formatters and linter lint: ## Run code formatters and linter
poetry run flynt -e "volumes" --fail-on-change --line_length=${MAX_LINE_LENGTH} . poetry run flynt -e "volumes" -e "htmlcov" --fail-on-change --line_length=${MAX_LINE_LENGTH} .
poetry run isort --check-only . poetry run isort --check-only .
poetry run flake8 . poetry run flake8 .
fix-code-style: ## Fix code formatting fix-code-style: ## Fix code formatting
poetry run flynt -e "volumes" --line_length=${MAX_LINE_LENGTH} . poetry run flynt -e "volumes" -e "htmlcov" --line_length=${MAX_LINE_LENGTH} .
poetry run pyupgrade --exit-zero-even-if-changed --py3-plus --py36-plus --py37-plus `find . -name "*.py" -type f ! -path "./.tox/*" ! -path "./volumes/*" 2>/dev/null` poetry run pyupgrade --exit-zero-even-if-changed --py3-plus --py36-plus --py37-plus `find . -name "*.py" -type f ! -path "./.tox/*" ! -path "./htmlcov/*" ! -path "*/volumes/*" 2>/dev/null`
poetry run isort . poetry run isort .
poetry run autopep8 --exclude="volumes,migrations" --aggressive --aggressive --in-place --recursive . poetry run autopep8 --aggressive --aggressive --in-place --recursive .
tox-listenvs: check-poetry ## List all tox test environments tox-listenvs: check-poetry ## List all tox test environments
poetry run tox --listenvs poetry run tox --listenvs
@ -86,8 +86,8 @@ createsuperuser: ## Create super user
./manage.sh createsuperuser ./manage.sh createsuperuser
messages: ## Make and compile locales message files messages: ## Make and compile locales message files
./manage.sh makemessages --all --no-location --no-obsolete ./manage.sh makemessages --all --no-location --no-obsolete --ignore=htmlcov --ignore=.tox --ignore=volumes
./manage.sh compilemessages ./manage.sh compilemessages -v 0
############################################################################## ##############################################################################

Wyświetl plik

@ -94,7 +94,7 @@ build-backend = "poetry.masonry.api"
[tool.autopep8] [tool.autopep8]
# https://github.com/hhatto/autopep8#pyprojecttoml # https://github.com/hhatto/autopep8#pyprojecttoml
max_line_length = 120 max_line_length = 120
exclude = "*/migrations/*" exclude="*/htmlcov/*,*/migrations/*,*/volumes/*"
[tool.isort] [tool.isort]
@ -102,7 +102,7 @@ exclude = "*/migrations/*"
atomic=true atomic=true
line_length=120 line_length=120
case_sensitive=false case_sensitive=false
skip_glob=["*/migrations/*","*/volumes/*"] skip_glob=["*/htmlcov/*","*/migrations/*","*/volumes/*"]
multi_line_output=3 multi_line_output=3
include_trailing_comma=true include_trailing_comma=true
known_first_party=["inventory","inventory_project","inventory_tests"] known_first_party=["inventory","inventory_project","inventory_tests"]
@ -116,7 +116,7 @@ lines_after_imports=2
# https://docs.pytest.org/en/latest/customize.html#pyproject-toml # https://docs.pytest.org/en/latest/customize.html#pyproject-toml
minversion = "6.0" minversion = "6.0"
DJANGO_SETTINGS_MODULE="inventory_project.settings.tests" DJANGO_SETTINGS_MODULE="inventory_project.settings.tests"
norecursedirs = ".* .git __pycache__ coverage* dist volumes" norecursedirs = ".* .git __pycache__ coverage* dist htmlcov volumes"
# sometimes helpfull "addopts" arguments: # sometimes helpfull "addopts" arguments:
# -vv # -vv
# --verbose # --verbose

Wyświetl plik

@ -3,6 +3,7 @@ from adminsortable2.admin import SortableInlineAdminMixin
from django.contrib import admin from django.contrib import admin
from django.contrib.admin.views.main import ChangeList from django.contrib.admin.views.main import ChangeList
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.utils.html import format_html
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from import_export.admin import ImportExportMixin from import_export.admin import ImportExportMixin
from import_export.resources import ModelResource from import_export.resources import ModelResource
@ -10,12 +11,10 @@ from import_export.resources import ModelResource
from inventory.admin.base import BaseUserAdmin from inventory.admin.base import BaseUserAdmin
from inventory.forms import ItemModelModelForm from inventory.forms import ItemModelModelForm
from inventory.models import ItemLinkModel, ItemModel from inventory.models import ItemLinkModel, ItemModel
from inventory.models.item import ItemImageModel
class ItemLinkModelInline(SortableInlineAdminMixin, admin.TabularInline): class UserInlineMixin:
model = ItemLinkModel
extra = 1
def get_queryset(self, request): def get_queryset(self, request):
qs = super().get_queryset(request) qs = super().get_queryset(request)
@ -26,8 +25,31 @@ class ItemLinkModelInline(SortableInlineAdminMixin, admin.TabularInline):
return qs return qs
class ItemModelResource(ModelResource): class ItemLinkModelInline(UserInlineMixin, SortableInlineAdminMixin, admin.TabularInline):
model = ItemLinkModel
extra = 0
class ItemImageModelInline(UserInlineMixin, SortableInlineAdminMixin, admin.TabularInline):
def preview(self, instance):
return format_html(
(
'<a href="{url}" title="{name}"'
' target="_blank" class="image_file_input_preview">'
'<img style="width:9em;" src="{url}"></a>'
),
url=instance.image.url,
name=instance.name,
)
model = ItemImageModel
extra = 0
fields = (
'position', 'preview', 'image', 'name', 'tags'
)
readonly_fields = ('preview',)
class ItemModelResource(ModelResource):
class Meta: class Meta:
model = ItemModel model = ItemModel
@ -112,7 +134,7 @@ class ItemModelAdmin(ImportExportMixin, BaseUserAdmin):
)}), )}),
) )
readonly_fields = ('id', 'create_dt', 'update_dt', 'user') readonly_fields = ('id', 'create_dt', 'update_dt', 'user')
inlines = (ItemLinkModelInline,) inlines = (ItemImageModelInline, ItemLinkModelInline)
def get_changelist(self, request, **kwargs): def get_changelist(self, request, **kwargs):
self.user = request.user self.user = request.user

Plik binarny nie jest wyświetlany.

Wyświetl plik

@ -7,8 +7,8 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: \n" "Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2020-10-26 19:24+0100\n" "POT-Creation-Date: 2020-11-15 13:09+0100\n"
"PO-Revision-Date: 2020-10-17 18:05+0200\n" "PO-Revision-Date: 2020-11-15 13:14+0100\n"
"Last-Translator: Jens Diemer\n" "Last-Translator: Jens Diemer\n"
"Language-Team: \n" "Language-Team: \n"
"Language: de\n" "Language: de\n"
@ -166,6 +166,26 @@ msgstr "Link"
msgid "ItemLinkModel.verbose_name_plural" msgid "ItemLinkModel.verbose_name_plural"
msgstr "Links" msgstr "Links"
msgid "ItemImageModel.image.verbose_name"
msgstr "Bild"
msgid "ItemImageModel.image.help_text"
msgstr ""
msgid "ItemImageModel.name.verbose_name"
msgstr "Name"
msgid "ItemImageModel.name.help_text"
msgstr ""
"Optionalen Namen passend zum Bild (Wird automatisch aus dem Dateinamen "
"gesetzt)"
msgid "ItemImageModel.verbose_name"
msgstr "Bild"
msgid "ItemImageModel.verbose_name_plural"
msgstr "Bilder"
msgid "BaseLink.name.verbose_name" msgid "BaseLink.name.verbose_name"
msgstr "Name" msgstr "Name"
@ -208,6 +228,10 @@ msgstr "Standort"
msgid "LocationModel.verbose_name_plural" msgid "LocationModel.verbose_name_plural"
msgstr "Standorte" msgstr "Standorte"
#, python-format
msgid "Image \"%(path)s\" does not exist"
msgstr ""
msgid "German" msgid "German"
msgstr "" msgstr ""

Plik binarny nie jest wyświetlany.

Wyświetl plik

@ -7,8 +7,8 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: \n" "Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2020-10-26 19:24+0100\n" "POT-Creation-Date: 2020-11-15 13:09+0100\n"
"PO-Revision-Date: 2020-10-17 19:12+0200\n" "PO-Revision-Date: 2020-11-15 13:15+0100\n"
"Last-Translator: Jens Diemer\n" "Last-Translator: Jens Diemer\n"
"Language-Team: \n" "Language-Team: \n"
"Language: en\n" "Language: en\n"
@ -164,6 +164,24 @@ msgstr "Link"
msgid "ItemLinkModel.verbose_name_plural" msgid "ItemLinkModel.verbose_name_plural"
msgstr "Links" msgstr "Links"
msgid "ItemImageModel.image.verbose_name"
msgstr "Image"
msgid "ItemImageModel.image.help_text"
msgstr " "
msgid "ItemImageModel.name.verbose_name"
msgstr "Name"
msgid "ItemImageModel.name.help_text"
msgstr ""
msgid "ItemImageModel.verbose_name"
msgstr "Image"
msgid "ItemImageModel.verbose_name_plural"
msgstr "Images"
msgid "BaseLink.name.verbose_name" msgid "BaseLink.name.verbose_name"
msgstr "Name" msgstr "Name"
@ -206,6 +224,10 @@ msgstr "Location"
msgid "LocationModel.verbose_name_plural" msgid "LocationModel.verbose_name_plural"
msgstr "Locations" msgstr "Locations"
#, python-format
msgid "Image \"%(path)s\" does not exist"
msgstr ""
msgid "German" msgid "German"
msgstr "" msgstr ""

Wyświetl plik

@ -0,0 +1,55 @@
# Generated by Django 2.2.17 on 2020-11-15 11:09
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import inventory.models.item
import tagulous.models.fields
import tagulous.models.models
import uuid
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('inventory', '0003_auto_20201024_1830'),
]
operations = [
migrations.CreateModel(
name='Tagulous_ItemImageModel_tags',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255, unique=True)),
('slug', models.SlugField()),
('count', models.IntegerField(default=0, help_text='Internal counter of how many times this tag is in use')),
('protected', models.BooleanField(default=False, help_text='Will not be deleted when the count reaches 0')),
],
options={
'ordering': ('name',),
'abstract': False,
'unique_together': {('slug',)},
},
bases=(tagulous.models.models.BaseTagModel, models.Model),
),
migrations.CreateModel(
name='ItemImageModel',
fields=[
('create_dt', models.DateTimeField(blank=True, editable=False, help_text='ModelTimetrackingMixin.create_dt.help_text', null=True, verbose_name='ModelTimetrackingMixin.create_dt.verbose_name')),
('update_dt', models.DateTimeField(blank=True, editable=False, help_text='ModelTimetrackingMixin.update_dt.help_text', null=True, verbose_name='ModelTimetrackingMixin.update_dt.verbose_name')),
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='BaseModel.id.help_text', primary_key=True, serialize=False, verbose_name='BaseModel.id.verbose_name')),
('image', models.ImageField(help_text='ItemImageModel.image.help_text', upload_to=inventory.models.item.user_directory_path, verbose_name='ItemImageModel.image.verbose_name')),
('name', models.CharField(blank=True, help_text='ItemImageModel.name.help_text', max_length=255, null=True, verbose_name='ItemImageModel.name.verbose_name')),
('position', models.PositiveSmallIntegerField(default=0)),
('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='inventory.ItemModel')),
('tags', tagulous.models.fields.TagField(_set_tag_meta=True, blank=True, case_sensitive=False, force_lowercase=False, help_text='BaseModel.tags.help_text', max_count=10, space_delimiter=False, to='inventory.Tagulous_ItemImageModel_tags', verbose_name='BaseModel.tags.verbose_name')),
('user', models.ForeignKey(editable=False, help_text='BaseModel.user.help_text', on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='BaseModel.user.verbose_name')),
],
options={
'verbose_name': 'ItemImageModel.verbose_name',
'verbose_name_plural': 'ItemImageModel.verbose_name_plural',
'ordering': ('position',),
},
),
]

Wyświetl plik

@ -1,2 +1,2 @@
from inventory.models.item import ItemLinkModel, ItemModel # noqa from inventory.models.item import ItemImageModel, ItemLinkModel, ItemModel # noqa
from inventory.models.location import LocationModel # noqa from inventory.models.location import LocationModel # noqa

Wyświetl plik

@ -1,13 +1,21 @@
import logging
from pathlib import Path
import tagulous.models import tagulous.models
from bx_py_utils.filename import clean_filename
from ckeditor_uploader.fields import RichTextUploadingField from ckeditor_uploader.fields import RichTextUploadingField
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
from django.utils.crypto import get_random_string
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from inventory.models.base import BaseModel from inventory.models.base import BaseModel
from inventory.models.links import BaseLink from inventory.models.links import BaseLink
logger = logging.getLogger(__name__)
class ItemQuerySet(models.QuerySet): class ItemQuerySet(models.QuerySet):
def sort(self): def sort(self):
return self.order_by('kind', 'producer', 'name') return self.order_by('kind', 'producer', 'name')
@ -167,3 +175,58 @@ class ItemLinkModel(BaseLink):
verbose_name = _('ItemLinkModel.verbose_name') verbose_name = _('ItemLinkModel.verbose_name')
verbose_name_plural = _('ItemLinkModel.verbose_name_plural') verbose_name_plural = _('ItemLinkModel.verbose_name_plural')
ordering = ('position',) ordering = ('position',)
def user_directory_path(instance, filename):
"""
Upload to /MEDIA_ROOT/...
"""
random_string = get_random_string()
filename = clean_filename(filename)
filename = f'user_{instance.user.id}/{random_string}/{filename}'
logger.info(f'Upload filename: {filename!r}')
return filename
class ItemImageModel(BaseModel):
"""
Store Images to Items
"""
image = models.ImageField(
upload_to=user_directory_path,
verbose_name=_('ItemImageModel.image.verbose_name'),
help_text=_('ItemImageModel.image.help_text')
)
name = models.CharField(
null=True, blank=True,
max_length=255,
verbose_name=_('ItemImageModel.name.verbose_name'),
help_text=_('ItemImageModel.name.help_text')
)
item = models.ForeignKey(
ItemModel, on_delete=models.CASCADE
)
position = models.PositiveSmallIntegerField(
# Note: Will be set in admin via adminsortable2
# The JavaScript which performs the sorting is 1-indexed !
default=0, blank=False, null=False
)
def __str__(self):
return self.name or self.image.name
def full_clean(self, **kwargs):
if not self.name:
filename = Path(self.image.name).name
self.name = clean_filename(filename)
if self.user_id is None:
# inherit owner of this link from item instance
self.user_id = self.item.user_id
return super().full_clean(**kwargs)
class Meta:
verbose_name = _('ItemImageModel.verbose_name')
verbose_name_plural = _('ItemImageModel.verbose_name_plural')
ordering = ('position',)

Wyświetl plik

@ -1,7 +1,7 @@
from django.contrib.auth.models import Group, Permission from django.contrib.auth.models import Group, Permission
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from inventory.models import ItemLinkModel, ItemModel, LocationModel from inventory.models import ItemImageModel, ItemLinkModel, ItemModel, LocationModel
NORMAL_USER_GROUP_NAME = 'normal user' NORMAL_USER_GROUP_NAME = 'normal user'
@ -16,15 +16,21 @@ def get_permissions(*models):
def get_or_create_normal_user_group(): def get_or_create_normal_user_group():
"""
Will be called by:
inventory.signals.post_migrate_callback()
"""
return Group.objects.get_or_create(name=NORMAL_USER_GROUP_NAME) return Group.objects.get_or_create(name=NORMAL_USER_GROUP_NAME)
def setup_normal_user_permissions(normal_user_group): def setup_normal_user_permissions(normal_user_group):
""" """
Setup PyInventory "normal user" permissions Setup PyInventory "normal user" permissions.
Will be called by:
inventory.signals.post_migrate_callback()
""" """
assert normal_user_group.name == NORMAL_USER_GROUP_NAME assert normal_user_group.name == NORMAL_USER_GROUP_NAME
permissions = get_permissions(ItemModel, ItemLinkModel, LocationModel) permissions = get_permissions(ItemImageModel, ItemLinkModel, ItemModel, LocationModel)
existing_permissions = normal_user_group.permissions.all() existing_permissions = normal_user_group.permissions.all()
if set(permissions) != set(existing_permissions): if set(permissions) != set(existing_permissions):

Wyświetl plik

Wyświetl plik

@ -0,0 +1,15 @@
from django.contrib.auth.models import User
from model_bakery import baker
from inventory.permissions import get_or_create_normal_user_group
def get_normal_pyinventory_user(**baker_kwargs):
pyinventory_user_group = get_or_create_normal_user_group()[0]
pyinventory_user = baker.make(
User,
is_staff=True, is_active=True, is_superuser=False,
**baker_kwargs
)
pyinventory_user.groups.set([pyinventory_user_group])
return pyinventory_user

Wyświetl plik

@ -0,0 +1,63 @@
import tempfile
from unittest import mock
from django.http import FileResponse
from django.test import TestCase, override_settings
from model_bakery import baker
from inventory.models import ItemImageModel
from inventory.tests.fixtures.users import get_normal_pyinventory_user
class ItemImagesTestCase(TestCase):
def test_basics(self):
pyinventory_user1 = get_normal_pyinventory_user(id=1)
pyinventory_user2 = get_normal_pyinventory_user(id=2)
with tempfile.TemporaryDirectory() as tmpdir, override_settings(MEDIA_ROOT=tmpdir):
print(tmpdir)
with self.assertLogs('inventory') as logs:
with mock.patch('inventory.models.item.get_random_string', return_value='DrgCCsMrdIBJ'):
image_instance = baker.make(
ItemImageModel,
user=pyinventory_user1,
_create_files=True
)
assert image_instance.image is not None
url = image_instance.image.url
# url = f'/media/{image_instance.image}'
assert url == '/media/user_1/DrgCCsMrdIBJ/mock_img.jpeg'
assert logs.output == [
"INFO:inventory.models.item:"
"Upload filename: 'user_1/DrgCCsMrdIBJ/mock_img.jpeg'"
]
# Anonymous user can't access:
with self.assertLogs('inventory') as logs, self.assertLogs('django'):
response = self.client.get(url)
assert response.status_code == 403
assert logs.output == [
'ERROR:inventory.views.media_files:Anonymous try to access files from: 1'
]
# Wrong user should not access:
self.client.force_login(user=pyinventory_user2)
with self.assertLogs('inventory') as logs, self.assertLogs('django'):
response = self.client.get(url)
assert response.status_code == 403
assert logs.output == [
'ERROR:inventory.views.media_files:Wrong user ID: 2 is not 1'
]
# The right user should access:
self.client.force_login(user=pyinventory_user1)
response = self.client.get(url)
assert response.status_code == 200
assert isinstance(response, FileResponse)
assert response.getvalue() == image_instance.image.read()

Wyświetl plik

@ -0,0 +1,47 @@
import logging
from django.conf import settings
from django.core.exceptions import PermissionDenied
from django.http import Http404
from django.utils.translation import gettext as _
from django.views.generic.base import View
from django.views.static import serve
from inventory.models import ItemImageModel
logger = logging.getLogger(__name__)
class UserMediaView(View):
"""
Serve MEDIA_URL files, but check the current user:
"""
def get(self, request, user_id, path):
media_path = f'user_{user_id}/{path}'
if not request.user.is_superuser:
if request.user.id != user_id:
# A user tries to access a file from a other use?
if request.user.id is None:
logger.error(f'Anonymous try to access files from: {user_id!r}')
else:
logger.error(f'Wrong user ID: {request.user.id!r} is not {user_id!r}')
raise PermissionDenied
# Check if the image really exists:
qs = ItemImageModel.objects.filter(
user_id=request.user.id,
image=media_path
)
if not qs.exists():
raise Http404(_('Image "%(path)s" does not exist') % {'path': media_path})
# Send the file to the user:
return serve(
request,
path=media_path,
document_root=settings.MEDIA_ROOT,
show_indexes=False
)

Wyświetl plik

@ -4,6 +4,8 @@ from django.contrib import admin
from django.urls import path from django.urls import path
from django.views.generic import RedirectView from django.views.generic import RedirectView
from inventory.views.media_files import UserMediaView
admin.autodiscover() admin.autodiscover()
@ -13,12 +15,12 @@ urlpatterns = [ # Don't use i18n_patterns() here
url(r'^$', RedirectView.as_view(url='/admin/')), url(r'^$', RedirectView.as_view(url='/admin/')),
path('ckeditor/', include('ckeditor_uploader.urls')), # TODO: check permissions? path('ckeditor/', include('ckeditor_uploader.urls')), # TODO: check permissions?
path('media/user_<int:user_id>/<path:path>', UserMediaView.as_view())
] ]
if settings.SERVE_FILES: if settings.SERVE_FILES:
urlpatterns += static.static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) urlpatterns += static.static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
urlpatterns += static.static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
if settings.DEBUG: if settings.DEBUG: