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
#
[flake8]
exclude = .tox, .pytest_cache, *.egg-info, */migrations/*, volumes
exclude = .pytest_cache, .tox, dist, htmlcov, */migrations/*, volumes
#ignore = E402
max-line-length = 119

Wyświetl plik

@ -43,15 +43,15 @@ update: check-poetry ## update the sources and installation
poetry update
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 flake8 .
fix-code-style: ## Fix code formatting
poetry run flynt -e "volumes" --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 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 "./htmlcov/*" ! -path "*/volumes/*" 2>/dev/null`
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
poetry run tox --listenvs
@ -86,8 +86,8 @@ createsuperuser: ## Create super user
./manage.sh createsuperuser
messages: ## Make and compile locales message files
./manage.sh makemessages --all --no-location --no-obsolete
./manage.sh compilemessages
./manage.sh makemessages --all --no-location --no-obsolete --ignore=htmlcov --ignore=.tox --ignore=volumes
./manage.sh compilemessages -v 0
##############################################################################

Wyświetl plik

@ -94,7 +94,7 @@ build-backend = "poetry.masonry.api"
[tool.autopep8]
# https://github.com/hhatto/autopep8#pyprojecttoml
max_line_length = 120
exclude = "*/migrations/*"
exclude="*/htmlcov/*,*/migrations/*,*/volumes/*"
[tool.isort]
@ -102,7 +102,7 @@ exclude = "*/migrations/*"
atomic=true
line_length=120
case_sensitive=false
skip_glob=["*/migrations/*","*/volumes/*"]
skip_glob=["*/htmlcov/*","*/migrations/*","*/volumes/*"]
multi_line_output=3
include_trailing_comma=true
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
minversion = "6.0"
DJANGO_SETTINGS_MODULE="inventory_project.settings.tests"
norecursedirs = ".* .git __pycache__ coverage* dist volumes"
norecursedirs = ".* .git __pycache__ coverage* dist htmlcov volumes"
# sometimes helpfull "addopts" arguments:
# -vv
# --verbose

Wyświetl plik

@ -3,6 +3,7 @@ from adminsortable2.admin import SortableInlineAdminMixin
from django.contrib import admin
from django.contrib.admin.views.main import ChangeList
from django.template.loader import render_to_string
from django.utils.html import format_html
from django.utils.translation import ugettext_lazy as _
from import_export.admin import ImportExportMixin
from import_export.resources import ModelResource
@ -10,12 +11,10 @@ from import_export.resources import ModelResource
from inventory.admin.base import BaseUserAdmin
from inventory.forms import ItemModelModelForm
from inventory.models import ItemLinkModel, ItemModel
from inventory.models.item import ItemImageModel
class ItemLinkModelInline(SortableInlineAdminMixin, admin.TabularInline):
model = ItemLinkModel
extra = 1
class UserInlineMixin:
def get_queryset(self, request):
qs = super().get_queryset(request)
@ -26,8 +25,31 @@ class ItemLinkModelInline(SortableInlineAdminMixin, admin.TabularInline):
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:
model = ItemModel
@ -112,7 +134,7 @@ class ItemModelAdmin(ImportExportMixin, BaseUserAdmin):
)}),
)
readonly_fields = ('id', 'create_dt', 'update_dt', 'user')
inlines = (ItemLinkModelInline,)
inlines = (ItemImageModelInline, ItemLinkModelInline)
def get_changelist(self, request, **kwargs):
self.user = request.user

Plik binarny nie jest wyświetlany.

Wyświetl plik

@ -7,8 +7,8 @@ msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2020-10-26 19:24+0100\n"
"PO-Revision-Date: 2020-10-17 18:05+0200\n"
"POT-Creation-Date: 2020-11-15 13:09+0100\n"
"PO-Revision-Date: 2020-11-15 13:14+0100\n"
"Last-Translator: Jens Diemer\n"
"Language-Team: \n"
"Language: de\n"
@ -166,6 +166,26 @@ msgstr "Link"
msgid "ItemLinkModel.verbose_name_plural"
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"
msgstr "Name"
@ -208,6 +228,10 @@ msgstr "Standort"
msgid "LocationModel.verbose_name_plural"
msgstr "Standorte"
#, python-format
msgid "Image \"%(path)s\" does not exist"
msgstr ""
msgid "German"
msgstr ""

Plik binarny nie jest wyświetlany.

Wyświetl plik

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

Wyświetl plik

@ -1,13 +1,21 @@
import logging
from pathlib import Path
import tagulous.models
from bx_py_utils.filename import clean_filename
from ckeditor_uploader.fields import RichTextUploadingField
from django.db import models
from django.urls import reverse
from django.utils.crypto import get_random_string
from django.utils.translation import ugettext_lazy as _
from inventory.models.base import BaseModel
from inventory.models.links import BaseLink
logger = logging.getLogger(__name__)
class ItemQuerySet(models.QuerySet):
def sort(self):
return self.order_by('kind', 'producer', 'name')
@ -167,3 +175,58 @@ class ItemLinkModel(BaseLink):
verbose_name = _('ItemLinkModel.verbose_name')
verbose_name_plural = _('ItemLinkModel.verbose_name_plural')
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.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'
@ -16,15 +16,21 @@ def get_permissions(*models):
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)
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
permissions = get_permissions(ItemModel, ItemLinkModel, LocationModel)
permissions = get_permissions(ItemImageModel, ItemLinkModel, ItemModel, LocationModel)
existing_permissions = normal_user_group.permissions.all()
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.views.generic import RedirectView
from inventory.views.media_files import UserMediaView
admin.autodiscover()
@ -13,12 +15,12 @@ urlpatterns = [ # Don't use i18n_patterns() here
url(r'^$', RedirectView.as_view(url='/admin/')),
path('ckeditor/', include('ckeditor_uploader.urls')), # TODO: check permissions?
path('media/user_<int:user_id>/<path:path>', UserMediaView.as_view())
]
if settings.SERVE_FILES:
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: