~homeworkprod/byceps

2bdbcfd481ca9e7786b6abecd98f493ed7d63ebb — Jochen Kupperschmidt 1 year, 7 months ago e79b239
Implement news image upload
M byceps/blueprints/admin/news/forms.py => byceps/blueprints/admin/news/forms.py +8 -4
@@ 8,7 8,7 @@ byceps.blueprints.admin.news.forms

import re

from wtforms import StringField, TextAreaField
from wtforms import FileField, StringField, TextAreaField
from wtforms.validators import InputRequired, Length, Optional, Regexp

from ....util.l10n import LocalizedForm


@@ 22,14 22,18 @@ class ChannelCreateForm(LocalizedForm):
    url_prefix = StringField('URL-Präfix', [InputRequired(), Length(max=80)])


class ImageCreateForm(LocalizedForm):
    filename = StringField('Dateiname', [InputRequired(), Length(max=80)])
class _ImageFormBase(LocalizedForm):
    alt_text = StringField('Alternativtext', [InputRequired()])
    caption = StringField('Bildunterschrift', [Optional()])
    attribution = StringField('Bildquelle', [Optional()])


class ImageUpdateForm(ImageCreateForm):
class ImageCreateForm(_ImageFormBase):
    image = FileField('Bilddatei', [InputRequired()])
    filename = StringField('Dateiname', [InputRequired(), Length(max=80)])


class ImageUpdateForm(_ImageFormBase):
    pass



M byceps/blueprints/admin/news/templates/admin/news/image_create_form.html => byceps/blueprints/admin/news/templates/admin/news/image_create_form.html +11 -3
@@ 1,5 1,5 @@
{% extends 'layout/admin/base.html' %}
{% from 'macros/forms.html' import form_buttons, form_field, form_fieldset %}
{% from 'macros/forms.html' import form_buttons, form_field, form_fieldset, form_supplement %}
{% from 'macros/icons.html' import render_icon %}
{% set current_page = 'news_admin' %}
{% set current_page_brand = brand %}


@@ 16,9 16,17 @@
  </nav>
  <h1>{{ render_icon('add') }} {{ title }}</h1>

  <form action="{{ url_for('.image_create', item_id=item.id) }}" method="post">
  <form action="{{ url_for('.image_create', item_id=item.id) }}" method="post" enctype="multipart/form-data" class="disable-submit-button-on-submit">
    {% call form_fieldset() %}
      {{ form_field(form.filename, maxlength=80, required='required', autofocus='autofocus') }}
      {{ form_field(form.image, maxlength=150000, accept='image/*', required='required', autofocus='autofocus') }}
      {%- call form_supplement() %}
        {%- filter dim %}
          Erlaubte Formate: {{ allowed_types|sort|join(', ') }}<br>
          Maximale Bildgröße: {{ maximum_dimensions|join(' &times; ')|safe }} Pixel<br>
          Maximale Dateigröße: 150 KB
        {%- endfilter %}
      {%- endcall %}
      {{ form_field(form.filename, maxlength=80, required='required') }}
      {{ form_field(form.alt_text, required='required') }}
      {{ form_field(form.caption) }}
      {{ form_field(form.attribution) }}

M byceps/blueprints/admin/news/templates/admin/news/image_update_form.html => byceps/blueprints/admin/news/templates/admin/news/image_update_form.html +1 -2
@@ 18,8 18,7 @@

  <form action="{{ url_for('.image_update', image_id=image.id) }}" method="post">
    {% call form_fieldset() %}
      {{ form_field(form.filename, maxlength=80, required='required', autofocus='autofocus') }}
      {{ form_field(form.alt_text, required='required') }}
      {{ form_field(form.alt_text, required='required', autofocus='autofocus') }}
      {{ form_field(form.caption) }}
      {{ form_field(form.attribution) }}
    {% endcall %}

M byceps/blueprints/admin/news/views.py => byceps/blueprints/admin/news/views.py +24 -5
@@ 11,6 11,7 @@ from datetime import date
from flask import abort, g, request

from ....services.brand import service as brand_service
from ....services.image import service as image_service
from ....services.news import channel_service as news_channel_service
from ....services.news import image_service as news_image_service
from ....services.news import service as news_item_service


@@ 137,9 138,15 @@ def image_create_form(item_id, erroneous_form=None):

    form = erroneous_form if erroneous_form else ImageCreateForm()

    image_type_names = image_service.get_image_type_names(
        news_image_service.ALLOWED_IMAGE_TYPES
    )

    return {
        'item': item,
        'form': form,
        'allowed_types': image_type_names,
        'maximum_dimensions': news_image_service.MAXIMUM_DIMENSIONS,
    }




@@ 149,20 156,30 @@ def image_create(item_id):
    """Create a news image."""
    item = _get_item_or_404(item_id)

    form = ImageCreateForm(request.form)
    # Make `InputRequired` work on `FileField`.
    form_fields = request.form.copy()
    if request.files:
        form_fields.update(request.files)

    form = ImageCreateForm(form_fields)
    if not form.validate():
        return image_create_form(item.id, form)

    creator_id = g.current_user.id
    image = request.files.get('image')
    filename = form.filename.data.strip()
    alt_text = form.alt_text.data.strip()
    caption = form.caption.data.strip()
    attribution = form.attribution.data.strip()

    if not image or not image.filename:
        abort(400, 'No file to upload has been specified.')

    try:
        image = news_image_service.create_image(
            creator_id,
            item.id,
            image.stream,
            filename,
            alt_text=alt_text,
            caption=caption,


@@ 170,8 187,12 @@ def image_create(item_id):
        )
    except UserIdRejected as e:
        abort(400, 'Invalid creator ID')
    except image_service.ImageTypeProhibited as e:
        abort(400, str(e))
    except FileExistsError:
        abort(409, 'File already exists, not overwriting.')

    flash_success(f'Das Newsbild "{image.filename}" wurde hinzugefügt.')
    flash_success(f'Das Newsbild #{image.number} wurde hinzugefügt.')

    return redirect_to('.item_view', item_id=image.item_id)



@@ 203,20 224,18 @@ def image_update(image_id):
    if not form.validate():
        return image_update_form(image.id, form)

    filename = form.filename.data.strip()
    alt_text = form.alt_text.data.strip()
    caption = form.caption.data.strip()
    attribution = form.attribution.data.strip()

    news_image_service.update_image(
        image.id,
        filename,
        alt_text=alt_text,
        caption=caption,
        attribution=attribution,
    )

    flash_success(f'Das Newsbild "{image.filename}" wurde aktualisiert.')
    flash_success(f'Das Newsbild #{image.number} wurde aktualisiert.')

    return redirect_to('.item_view', item_id=image.item_id)


M byceps/services/news/image_service.py => byceps/services/news/image_service.py +51 -5
@@ 6,11 6,16 @@ byceps.services.news.image_service
:License: Modified BSD, see LICENSE for details.
"""

from typing import Optional
from typing import BinaryIO, Optional

from flask import current_app

from ...database import db
from ...typing import UserID
from ...util import upload
from ...util.image.models import Dimensions, ImageType

from ..image import service as image_service
from ..user import service as user_service

from .models.image import Image as DbImage


@@ 18,16 23,30 @@ from . import service as item_service
from .transfer.models import ChannelID, Image, ImageID, ItemID


ALLOWED_IMAGE_TYPES = frozenset([
    ImageType.jpeg,
    ImageType.png,
])


MAXIMUM_DIMENSIONS = Dimensions(1280, 1280)


def create_image(
    creator_id: UserID,
    item_id: ItemID,
    stream: BinaryIO,
    filename: str,
    *,
    alt_text: Optional[str] = None,
    caption: Optional[str] = None,
    attribution: Optional[str] = None,
) -> Image:
    """Create an image for a news item."""
    """Create an image for a news item.

    Raise `ImageTypeProhibited` if the stream data is not of one the
    allowed types.
    """
    creator = user_service.find_active_user(creator_id)
    if creator is None:
        raise user_service.UserIdRejected(creator_id)


@@ 36,6 55,10 @@ def create_image(
    if item is None:
        raise ValueError(f'Unknown news item ID "{item_id}".')

    image_type = image_service.determine_image_type(stream, ALLOWED_IMAGE_TYPES)
    image_dimensions = image_service.determine_dimensions(stream)
    _check_image_dimensions(image_dimensions)

    number = _get_next_available_number(item.id)

    image = DbImage(


@@ 51,7 74,32 @@ def create_image(
    db.session.add(image)
    db.session.commit()

    return _db_entity_to_image(image, item.channel_id)
    path = (
        current_app.config['PATH_DATA']
        / 'global'
        / 'news_channels'
        / item.channel.id
        / filename
    )

    # Create parent path if it doesn't exist.
    parent_path = path.resolve().parent
    if not parent_path.exists():
        parent_path.mkdir(parents=True)

    upload.store(stream, path)  # Might raise `FileExistsError`.

    return _db_entity_to_image(image, item.channel.id)


def _check_image_dimensions(image_dimensions: Dimensions) -> None:
    """Raise exception if image dimensions exceed defined maximum."""
    too_large = image_dimensions > MAXIMUM_DIMENSIONS
    if too_large:
        raise ValueError(
            'Image dimensions must not exceed '
            f'{MAXIMUM_DIMENSIONS.width} x {MAXIMUM_DIMENSIONS.height} pixels.'
        )


def _find_highest_number(item_id: ItemID) -> Optional[int]:


@@ 76,7 124,6 @@ def _get_next_available_number(item_id: ItemID) -> int:

def update_image(
    image_id: ImageID,
    filename: str,
    *,
    alt_text: Optional[str] = None,
    caption: Optional[str] = None,


@@ 88,7 135,6 @@ def update_image(
    if image is None:
        raise ValueError(f'Unknown news image ID "{image_id}".')

    image.filename = filename
    image.alt_text = alt_text
    image.caption = caption
    image.attribution = attribution