byceps.blueprints.admin.news.views.image_create()   B
last analyzed

Complexity

Conditions 7

Size

Total Lines 48
Code Lines 35

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 40.9185

Importance

Changes 0
Metric Value
eloc 35
dl 0
loc 48
ccs 3
cts 26
cp 0.1154
rs 7.64
c 0
b 0
f 0
cc 7
nop 1
crap 40.9185
1
"""
2
byceps.blueprints.admin.news.views
3
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
4
5
:Copyright: 2014-2024 Jochen Kupperschmidt
6
:License: Revised BSD (see `LICENSE` file for details)
7
"""
8
9 1
import dataclasses
10 1
from datetime import datetime
11
12 1
from flask import abort, g, request
13 1
from flask_babel import format_datetime, gettext, to_utc
14
15 1
from byceps.services.brand import brand_service
16 1
from byceps.services.image import image_service
17 1
from byceps.services.news import (
18
    news_channel_service,
19
    news_image_service,
20
    news_item_service,
21
)
22 1
from byceps.services.news.models import BodyFormat, NewsChannel
23 1
from byceps.services.site import site_service
24 1
from byceps.services.text_diff import text_diff_service
25 1
from byceps.services.user import user_service
26 1
from byceps.signals import news as news_signals
27 1
from byceps.util.framework.blueprint import create_blueprint
28 1
from byceps.util.framework.flash import flash_error, flash_success
29 1
from byceps.util.framework.templating import templated
30 1
from byceps.util.iterables import pairwise
31 1
from byceps.util.views import (
32
    permission_required,
33
    redirect_to,
34
    respond_no_content,
35
)
36
37 1
from .forms import (
38
    ChannelCreateForm,
39
    ChannelUpdateForm,
40
    ImageCreateForm,
41
    ImageUpdateForm,
42
    ItemCreateForm,
43
    ItemPublishLaterForm,
44
    ItemUpdateForm,
45
)
46
47
48 1
blueprint = create_blueprint('news_admin', __name__)
49
50
51
# -------------------------------------------------------------------- #
52
# channels
53
54
55 1
@blueprint.get('/brands/<brand_id>')
56 1
@permission_required('news_item.view')
57 1
@templated
58 1
def channel_index_for_brand(brand_id):
59
    """List channels for that brand."""
60 1
    brand = _get_brand_or_404(brand_id)
61
62 1
    channels = news_channel_service.get_channels_for_brand(brand.id)
63
64 1
    announcement_site_ids = {
65
        channel.announcement_site_id for channel in channels
66
    }
67 1
    announcement_sites_by_channel_id = {
68
        site.id: site for site in site_service.get_sites(announcement_site_ids)
69
    }
70
71 1
    item_count_by_channel_id = news_item_service.get_item_count_by_channel_id()
72
73 1
    return {
74
        'brand': brand,
75
        'channels': channels,
76
        'announcement_sites_by_channel_id': announcement_sites_by_channel_id,
77
        'item_count_by_channel_id': item_count_by_channel_id,
78
    }
79
80
81 1
@blueprint.get('/channels/<channel_id>', defaults={'page': 1})
82 1
@blueprint.get('/channels/<channel_id>/pages/<int:page>')
83 1
@permission_required('news_item.view')
84 1
@templated
85 1
def channel_view(channel_id, page):
86
    """View that channel and list its news items."""
87 1
    channel = _get_channel_or_404(channel_id)
88
89 1
    brand = brand_service.get_brand(channel.brand_id)
90 1
    if channel.announcement_site_id is not None:
91
        announcement_site = site_service.get_site(channel.announcement_site_id)
92
    else:
93 1
        announcement_site = None
94
95 1
    channel_ids = {channel.id}
96 1
    per_page = request.args.get('per_page', type=int, default=15)
97
98 1
    items = news_item_service.get_admin_list_items_paginated(
99
        channel_ids, page, per_page
100
    )
101
102 1
    return {
103
        'channel': channel,
104
        'brand': brand,
105
        'announcement_site': announcement_site,
106
        'items': items,
107
        'per_page': per_page,
108
    }
109
110
111 1
@blueprint.get('/for_brand/<brand_id>/channels/create')
112 1
@permission_required('news_channel.administrate')
113 1
@templated
114 1
def channel_create_form(brand_id, erroneous_form=None):
115
    """Show form to create a channel."""
116 1
    brand = _get_brand_or_404(brand_id)
117
118 1
    form = erroneous_form if erroneous_form else ChannelCreateForm()
119 1
    form.set_announcement_site_id_choices(brand.id)
120
121 1
    return {
122
        'brand': brand,
123
        'form': form,
124
    }
125
126
127 1
@blueprint.post('/for_brand/<brand_id>/channels')
128 1
@permission_required('news_channel.administrate')
129 1
def channel_create(brand_id):
130
    """Create a channel."""
131 1
    brand = _get_brand_or_404(brand_id)
132
133 1
    form = ChannelCreateForm(request.form)
134 1
    form.set_announcement_site_id_choices(brand.id)
135
136 1
    if not form.validate():
137
        return channel_create_form(brand.id, form)
138
139 1
    channel_id = form.channel_id.data.strip().lower()
140 1
    announcement_site_id = form.announcement_site_id.data or None
141
142 1
    channel = news_channel_service.create_channel(
143
        brand, channel_id, announcement_site_id=announcement_site_id
144
    )
145
146 1
    flash_success(
147
        gettext(
148
            'News channel "%(channel_id)s" has been created.',
149
            channel_id=channel.id,
150
        )
151
    )
152
153 1
    return redirect_to('.channel_view', channel_id=channel.id)
154
155
156 1
@blueprint.get('/channels/<channel_id>/update')
157 1
@permission_required('news_channel.administrate')
158 1
@templated
159 1
def channel_update_form(channel_id, erroneous_form=None):
160
    """Show form to update a channel."""
161
    channel = _get_channel_or_404(channel_id)
162
163
    brand = brand_service.get_brand(channel.brand_id)
164
165
    form = erroneous_form if erroneous_form else ChannelUpdateForm(obj=channel)
166
    form.set_announcement_site_id_choices(brand.id)
167
168
    return {
169
        'brand': brand,
170
        'channel': channel,
171
        'form': form,
172
    }
173
174
175 1
@blueprint.post('/channels/<channel_id>')
176 1
@permission_required('news_channel.administrate')
177 1
def channel_update(channel_id):
178
    """Update a channel."""
179
    channel = _get_channel_or_404(channel_id)
180
181
    brand = brand_service.get_brand(channel.brand_id)
182
183
    form = ChannelUpdateForm(request.form)
184
    form.set_announcement_site_id_choices(brand.id)
185
186
    if not form.validate():
187
        return channel_update_form(channel.id, form)
188
189
    announcement_site_id = form.announcement_site_id.data or None
190
    archived = form.archived.data
191
192
    channel = news_channel_service.update_channel(
193
        channel.id, announcement_site_id, archived
194
    )
195
196
    flash_success(gettext('Changes have been saved.'))
197
198
    return redirect_to('.channel_view', channel_id=channel.id)
199
200
201 1
@blueprint.delete('/channels/<channel_id>')
202 1
@permission_required('news_channel.administrate')
203 1
@respond_no_content
204 1
def channel_delete(channel_id):
205
    """Delete the channel."""
206
    channel = _get_channel_or_404(channel_id)
207
208
    if news_item_service.has_channel_items(channel.id):
209
        flash_error(
210
            gettext(
211
                'News channel "%(channel_id)s" cannot be deleted because it contains news items.',
212
                channel_id=channel.id,
213
            )
214
        )
215
        return
216
217
    sites_for_brand = site_service.get_sites_for_brand(channel.brand_id)
218
    linked_sites = {
219
        site for site in sites_for_brand if channel.id in site.news_channel_ids
220
    }
221
    if linked_sites:
222
        flash_error(
223
            gettext(
224
                'News channel "%(channel_id)s" cannot be deleted because it is referenced by %(site_count)s site(s).',
225
                channel_id=channel.id,
226
                site_count=len(linked_sites),
227
            )
228
        )
229
        return
230
231
    news_channel_service.delete_channel(channel.id)
232
233
    flash_success(
234
        gettext(
235
            'News channel "%(channel_id)s" has been deleted.',
236
            channel_id=channel_id,
237
        )
238
    )
239
240
241
# -------------------------------------------------------------------- #
242
# images
243
244
245 1
@blueprint.get('/for_item/<item_id>/create')
246 1
@permission_required('news_item.update')
247 1
@templated
248 1
def image_create_form(item_id, erroneous_form=None):
249
    """Show form to create a news image."""
250 1
    item = _get_item_or_404(item_id)
251
252 1
    form = erroneous_form if erroneous_form else ImageCreateForm()
253
254 1
    image_type_names = image_service.get_image_type_names(
255
        news_image_service.ALLOWED_IMAGE_TYPES
256
    )
257
258 1
    return {
259
        'item': item,
260
        'form': form,
261
        'allowed_types': image_type_names,
262
        'maximum_dimensions': news_image_service.MAXIMUM_DIMENSIONS,
263
    }
264
265
266 1
@blueprint.post('/for_item/<item_id>')
267 1
@permission_required('news_item.update')
268 1
def image_create(item_id):
269
    """Create a news image."""
270
    item = _get_item_or_404(item_id)
271
272
    # Make `InputRequired` work on `FileField`.
273
    form_fields = request.form.copy()
274
    if request.files:
275
        form_fields.update(request.files)
276
277
    form = ImageCreateForm(form_fields)
278
    if not form.validate():
279
        return image_create_form(item.id, form)
280
281
    creator = g.user
282
    image = request.files.get('image')
283
    alt_text = form.alt_text.data.strip()
284
    caption = form.caption.data.strip()
285
    attribution = form.attribution.data.strip()
286
287
    if not image or not image.filename:
288
        abort(400, 'No file to upload has been specified.')
289
290
    try:
291
        creation_result = news_image_service.create_image(
292
            creator,
293
            item,
294
            image.stream,
295
            alt_text=alt_text,
296
            caption=caption,
297
            attribution=attribution,
298
        )
299
        if creation_result.is_err():
300
            abort(400, creation_result.unwrap_err())
301
302
        image = creation_result.unwrap()
303
    except FileExistsError:
304
        abort(409, 'File already exists, not overwriting.')
305
306
    flash_success(
307
        gettext(
308
            'News image #%(image_number)s has been created.',
309
            image_number=image.number,
310
        )
311
    )
312
313
    return redirect_to('.item_view', item_id=image.item_id)
314
315
316 1
@blueprint.get('/images/<uuid:image_id>/update')
317 1
@permission_required('news_item.update')
318 1
@templated
319 1
def image_update_form(image_id, erroneous_form=None):
320
    """Show form to update a news image."""
321
    image = _get_image_or_404(image_id)
322
    item = news_item_service.find_item(image.item_id)
323
324
    form = erroneous_form if erroneous_form else ImageUpdateForm(obj=image)
325
326
    return {
327
        'image': image,
328
        'item': item,
329
        'form': form,
330
    }
331
332
333 1
@blueprint.post('/images/<uuid:image_id>')
334 1
@permission_required('news_item.update')
335 1
def image_update(image_id):
336
    """Update a news image."""
337
    image = _get_image_or_404(image_id)
338
339
    form = ImageUpdateForm(request.form)
340
    if not form.validate():
341
        return image_update_form(image.id, form)
342
343
    alt_text = form.alt_text.data.strip()
344
    caption = form.caption.data.strip()
345
    attribution = form.attribution.data.strip()
346
347
    image = news_image_service.update_image(
348
        image.id,
349
        alt_text=alt_text,
350
        caption=caption,
351
        attribution=attribution,
352
    )
353
354
    flash_success(
355
        gettext(
356
            'News image #%(image_number)s has been updated.',
357
            image_number=image.number,
358
        )
359
    )
360
361
    return redirect_to('.item_view', item_id=image.item_id)
362
363
364 1
@blueprint.post('/images/<uuid:image_id>/featured')
365 1
@permission_required('news_item.update')
366 1
@respond_no_content
367 1
def image_set_featured(image_id):
368
    """Set the image as featured image."""
369
    image = _get_image_or_404(image_id)
370
371
    news_item_service.set_featured_image(image.item_id, image.id)
372
373
    flash_success(gettext('Featured image has been set.'))
374
375
376 1
@blueprint.delete('/items/<uuid:item_id>/featured')
377 1
@permission_required('news_item.update')
378 1
@respond_no_content
379 1
def image_unset_featured(item_id):
380
    """Unset the item's featured image."""
381
    item = _get_item_or_404(item_id)
382
383
    news_item_service.unset_featured_image(item.id)
384
385
    flash_success(gettext('Featured image has been unset.'))
386
387
388
# -------------------------------------------------------------------- #
389
# items
390
391
392 1
@blueprint.get('/items/<uuid:item_id>')
393 1
@permission_required('news_item.view')
394 1
def item_view(item_id):
395
    """Show the current version of the news item."""
396 1
    item = _get_item_or_404(item_id)
397
398 1
    version = news_item_service.get_current_item_version(item.id)
399
400 1
    return item_view_version(version.id)
401
402
403 1
@blueprint.get('/versions/<uuid:version_id>')
404 1
@permission_required('news_item.view')
405 1
@templated
406 1
def item_view_version(version_id):
407
    """Show the news item with the given version."""
408 1
    version = _find_version(version_id)
409
410 1
    item = news_item_service.find_item(version.item_id)
411
412 1
    channel = item.channel
413 1
    brand = brand_service.find_brand(channel.brand_id)
414
415 1
    creator = user_service.get_user(version.creator_id, include_avatar=True)
416
417 1
    current_version = news_item_service.get_current_item_version(item.id)
418 1
    is_current_version = version.id == current_version.id
419
420 1
    return {
421
        'item': item,
422
        'version': version,
423
        'brand': brand,
424
        'creator': creator,
425
        'is_current_version': is_current_version,
426
    }
427
428
429 1
@blueprint.get('/versions/<uuid:version_id>/preview')
430 1
@permission_required('news_item.view')
431 1
@templated
432 1
def item_view_version_preview(version_id):
433
    """Show a preview of the news item with the given version."""
434
    version = _find_version(version_id)
435
436
    item = news_item_service.find_item(version.item_id)
437
438
    item = dataclasses.replace(
439
        item,
440
        title=version.title,
441
        body=version.body,
442
        body_format=version.body_format,
443
    )
444
445
    rendered_item = news_item_service.render_html(item)
446
447
    return {
448
        'item': rendered_item,
449
    }
450
451
452 1
@blueprint.get('/items/<uuid:item_id>/versions')
453 1
@permission_required('news_item.view')
454 1
@templated
455 1
def item_list_versions(item_id):
456
    """List news item's versions."""
457 1
    item = _get_item_or_404(item_id)
458
459 1
    channel = item.channel
460 1
    brand = brand_service.find_brand(channel.brand_id)
461
462 1
    versions = news_item_service.get_item_versions(item.id)
463 1
    versions_pairwise = list(pairwise(versions + [None]))
464
465 1
    user_ids = {version.creator_id for version in versions}
466 1
    users_by_id = user_service.get_users_indexed_by_id(
467
        user_ids, include_avatars=True
468
    )
469
470 1
    return {
471
        'item': item,
472
        'brand': brand,
473
        'versions_pairwise': versions_pairwise,
474
        'users_by_id': users_by_id,
475
    }
476
477
478 1
@blueprint.get('/items/<uuid:from_version_id>/compare_to/<uuid:to_version_id>')
479 1
@permission_required('news_item.view')
480 1
@templated
481 1
def item_compare_versions(from_version_id, to_version_id):
482
    """Show the difference between two news item versions."""
483 1
    from_version = _find_version(from_version_id)
484 1
    to_version = _find_version(to_version_id)
485
486 1
    if from_version.item_id != to_version.item_id:
487
        abort(400, 'The versions do not belong to the same item.')
488
489 1
    item = news_item_service.find_item(from_version.item_id)
490 1
    channel = item.channel
491 1
    brand = brand_service.find_brand(channel.brand_id)
492
493 1
    html_diff_title = _create_html_diff(
494
        from_version, to_version, lambda version: version.title
495
    )
496 1
    html_diff_body = _create_html_diff(
497
        from_version, to_version, lambda version: version.body
498
    )
499 1
    html_diff_body_format = _create_html_diff(
500
        from_version, to_version, lambda version: version.body_format.name
501
    )
502
503 1
    return {
504
        'brand': brand,
505
        'diff_title': html_diff_title,
506
        'diff_body': html_diff_body,
507
        'diff_body_format': html_diff_body_format,
508
    }
509
510
511 1
@blueprint.get('/for_channel/<channel_id>/create')
512 1
@permission_required('news_item.create')
513 1
@templated
514 1
def item_create_form(channel_id, erroneous_form=None):
515
    """Show form to create a news item."""
516 1
    channel = _get_channel_or_404(channel_id)
517
518 1
    if erroneous_form:
519
        form = erroneous_form
520
    else:
521 1
        form = ItemCreateForm(
522
            channel.brand_id, body_format=BodyFormat.markdown.name
523
        )
524
525 1
    return {
526
        'channel': channel,
527
        'form': form,
528
    }
529
530
531 1
@blueprint.post('/for_channel/<channel_id>')
532 1
@permission_required('news_item.create')
533 1
def item_create(channel_id):
534
    """Create a news item."""
535 1
    channel = _get_channel_or_404(channel_id)
536
537 1
    form = ItemCreateForm(channel.brand_id, request.form)
538 1
    if not form.validate():
539
        return item_create_form(channel.id, form)
540
541 1
    slug = form.slug.data.strip().lower()
542 1
    creator = g.user
543 1
    title = form.title.data.strip()
544 1
    body = form.body.data.strip()
545 1
    body_format = form.body_format.data
546
547 1
    item = news_item_service.create_item(
548
        channel,
549
        slug,
550
        creator,
551
        title,
552
        body,
553
        body_format,
554
    )
555
556 1
    flash_success(
557
        gettext('News item "%(title)s" has been created.', title=item.title)
558
    )
559
560 1
    return redirect_to('.item_view', item_id=item.id)
561
562
563 1
@blueprint.get('/items/<uuid:item_id>/update')
564 1
@permission_required('news_item.update')
565 1
@templated
566 1
def item_update_form(item_id, erroneous_form=None):
567
    """Show form to update a news item."""
568 1
    item = _get_item_or_404(item_id)
569
570 1
    current_version = news_item_service.get_current_item_version(item.id)
571
572 1
    data = {
573
        'slug': item.slug,
574
        'title': current_version.title,
575
        'body_format': current_version.body_format.name,
576
        'body': current_version.body,
577
    }
578 1
    form = (
579
        erroneous_form
580
        if erroneous_form
581
        else ItemUpdateForm(item.brand_id, item.slug, data=data)
582
    )
583
584 1
    return {
585
        'item': item,
586
        'form': form,
587
    }
588
589
590 1
@blueprint.post('/items/<uuid:item_id>')
591 1
@permission_required('news_item.update')
592 1
def item_update(item_id):
593
    """Update a news item."""
594
    item = _get_item_or_404(item_id)
595
596
    form = ItemUpdateForm(item.brand_id, item.slug, request.form)
597
    if not form.validate():
598
        return item_update_form(item.id, form)
599
600
    creator = g.user
601
    slug = form.slug.data.strip().lower()
602
    title = form.title.data.strip()
603
    body = form.body.data.strip()
604
    body_format = form.body_format.data
605
606
    item = news_item_service.update_item(
607
        item.id,
608
        slug,
609
        creator,
610
        title,
611
        body,
612
        body_format,
613
    )
614
615
    flash_success(
616
        gettext('News item "%(title)s" has been updated.', title=item.title)
617
    )
618
619
    return redirect_to('.item_view', item_id=item.id)
620
621
622 1
@blueprint.get('/items/<uuid:item_id>/publish_later')
623 1
@permission_required('news_item.publish')
624 1
@templated
625 1
def item_publish_later_form(item_id, erroneous_form=None):
626
    """Show form to publish a news item at a time in the future."""
627 1
    item = _get_item_or_404(item_id)
628
629 1
    form = erroneous_form if erroneous_form else ItemPublishLaterForm()
630
631 1
    return {
632
        'item': item,
633
        'form': form,
634
    }
635
636
637 1
@blueprint.post('/items/<uuid:item_id>/publish_later')
638 1
@permission_required('news_item.publish')
639 1
def item_publish_later(item_id):
640
    """Publish a news item at a time in the future."""
641 1
    item = _get_item_or_404(item_id)
642
643 1
    form = ItemPublishLaterForm(request.form)
644 1
    if not form.validate():
645
        return item_publish_later_form(item.id, form)
646
647 1
    publish_at = to_utc(
648
        datetime.combine(form.publish_on.data, form.publish_at.data)
649
    )
650
651 1
    result = news_item_service.publish_item(
652
        item.id, publish_at=publish_at, initiator=g.user
653
    )
654
655 1
    if result.is_err():
656
        flash_error(result.unwrap_err())
657
        return redirect_to('.item_view', item_id=item.id)
658
659 1
    event = result.unwrap()
660
661 1
    news_signals.item_published.send(None, event=event)
662
663 1
    flash_success(
664
        gettext(
665
            'News item "%(title)s" will be published later.', title=item.title
666
        )
667
    )
668
669 1
    return redirect_to('.item_view', item_id=item.id)
670
671
672 1
@blueprint.post('/items/<uuid:item_id>/publish_now')
673 1
@permission_required('news_item.publish')
674 1
@respond_no_content
675 1
def item_publish_now(item_id):
676
    """Publish a news item now."""
677 1
    item = _get_item_or_404(item_id)
678
679 1
    result = news_item_service.publish_item(item.id, initiator=g.user)
680
681 1
    if result.is_err():
682
        flash_error(result.unwrap_err())
683
        return
684
685 1
    event = result.unwrap()
686
687 1
    news_signals.item_published.send(None, event=event)
688
689 1
    flash_success(
690
        gettext('News item "%(title)s" has been published.', title=item.title)
691
    )
692
693
694 1
@blueprint.post('/items/<uuid:item_id>/unpublish')
695 1
@permission_required('news_item.publish')
696 1
@respond_no_content
697 1
def item_unpublish(item_id):
698
    """Unpublish a news item."""
699 1
    item = _get_item_or_404(item_id)
700
701 1
    result = news_item_service.unpublish_item(item.id)
702
703 1
    if result.is_err():
704
        flash_error(result.unwrap_err())
705
        return
706
707 1
    flash_success(
708
        gettext('News item "%(title)s" has been unpublished.', title=item.title)
709
    )
710
711
712
# -------------------------------------------------------------------- #
713
# helpers
714
715
716 1
def _get_brand_or_404(brand_id):
717 1
    brand = brand_service.find_brand(brand_id)
718
719 1
    if brand is None:
720
        abort(404)
721
722 1
    return brand
723
724
725 1
def _get_channel_or_404(channel_id) -> NewsChannel:
726 1
    channel = news_channel_service.find_channel(channel_id)
727
728 1
    if channel is None:
729
        abort(404)
730
731 1
    return channel
732
733
734 1
def _get_item_or_404(item_id):
735 1
    item = news_item_service.find_item(item_id)
736
737 1
    if item is None:
738
        abort(404)
739
740 1
    return item
741
742
743 1
def _get_image_or_404(image_id):
744
    image = news_image_service.find_image(image_id)
745
746
    if image is None:
747
        abort(404)
748
749
    return image
750
751
752 1
def _find_version(version_id):
753 1
    version = news_item_service.find_item_version(version_id)
754
755 1
    if version is None:
756
        abort(404)
757
758 1
    return version
759
760
761 1
def _create_html_diff(from_version, to_version, attribute_getter):
762
    """Create an HTML diff between the named attribute's value of each
763
    of the two versions.
764
    """
765 1
    from_description = format_datetime(from_version.created_at)
766 1
    to_description = format_datetime(to_version.created_at)
767
768 1
    from_text = attribute_getter(from_version)
769 1
    to_text = attribute_getter(to_version)
770
771 1
    return text_diff_service.create_html_diff(
772
        from_text, to_text, from_description, to_description
773
    )
774