Passed
Push — main ( b25e28...d471db )
by Jochen
04:48
created

item_unpublish()   A

Complexity

Conditions 1

Size

Total Lines 11
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 6
CRAP Score 1

Importance

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