Passed
Push — main ( 058650...b4a17c )
by Jochen
04:36
created

channel_update()   A

Complexity

Conditions 2

Size

Total Lines 23
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 4.3145

Importance

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