feat(episodes): replace soft delete with permanent delete

+ add constraint to prevent deleting an episode when published
This commit is contained in:
Ola Hneini 2022-05-05 15:48:16 +00:00 committed by Yassine Doghri
parent 0345728739
commit eb9ff522c2
20 changed files with 242 additions and 107 deletions

View File

@ -147,10 +147,6 @@ class AddEpisodes extends Migration
'updated_at' => [
'type' => 'DATETIME',
],
'deleted_at' => [
'type' => 'DATETIME',
'null' => true,
],
]);
$this->forge->addPrimaryKey('id');
$this->forge->addUniqueKey(['podcast_id', 'slug']);

View File

@ -207,12 +207,6 @@ class AuthSeeder extends Seeder
],
[
'name' => 'delete',
'description' =>
'Delete an episode of a podcast without removing it from the database',
'has_permission' => ['podcast_admin'],
],
[
'name' => 'delete_permanently',
'description' =>
'Delete all occurrences of an episode of a podcast from the database',
'has_permission' => ['podcast_admin'],

View File

@ -78,7 +78,6 @@ use RuntimeException;
* @property Time|null $published_at;
* @property Time $created_at;
* @property Time $updated_at;
* @property Time|null $deleted_at;
*
* @property Person[] $persons;
* @property Soundbite[] $soundbites;

View File

@ -10,6 +10,8 @@ declare(strict_types=1);
namespace App\Entities\Media;
use App\Models\MediaModel;
use CodeIgniter\Database\BaseResult;
use CodeIgniter\Entity\Entity;
use CodeIgniter\Files\File;
@ -101,9 +103,15 @@ class BaseMedia extends Entity
return $this;
}
public function deleteFile(): void
public function deleteFile(): bool
{
helper('media');
unlink(media_path($this->file_path));
return unlink(media_path($this->file_path));
}
public function delete(): bool|BaseResult
{
$mediaModel = new MediaModel();
return $mediaModel->delete($this->id, true);
}
}

View File

@ -69,11 +69,13 @@ class Image extends BaseMedia
return $this;
}
public function deleteFile(): void
public function deleteFile(): bool
{
parent::deleteFile();
if (parent::deleteFile()) {
return $this->deleteSizes();
}
$this->deleteSizes();
return false;
}
public function saveSizes(): void
@ -89,12 +91,16 @@ class Image extends BaseMedia
}
}
private function deleteSizes(): void
private function deleteSizes(): bool
{
// delete all derived sizes
foreach (array_keys($this->sizes) as $name) {
$pathProperty = $name . '_path';
unlink(media_path($this->{$pathProperty}));
if (! unlink(media_path($this->{$pathProperty}))) {
return false;
}
}
return true;
}
}

View File

@ -63,12 +63,16 @@ class Transcript extends BaseMedia
return $this;
}
public function deleteFile(): void
public function deleteFile(): bool
{
parent::deleteFile();
if (! parent::deleteFile()) {
return false;
}
if ($this->json_path) {
unlink($this->json_path);
return unlink($this->json_path);
}
return true;
}
}

View File

@ -327,6 +327,18 @@ class Podcast extends Entity
return $this->episodes;
}
/**
* Returns the podcast's episodes count
*/
public function getEpisodesCount(): int|string
{
if ($this->id === null) {
throw new RuntimeException('Podcast must be created before getting number of episodes.');
}
return (new EpisodeModel())->getPodcastEpisodesCount($this->id);
}
/**
* Returns the podcast's persons
*

View File

@ -94,11 +94,6 @@ class EpisodeModel extends Model
*/
protected $returnType = Episode::class;
/**
* @var bool
*/
protected $useSoftDeletes = true;
/**
* @var bool
*/
@ -249,6 +244,18 @@ class EpisodeModel extends Model
return $found;
}
/**
* Returns number of episodes of a podcast
*/
public function getPodcastEpisodesCount(int $podcastId): int|string
{
return $this
->where([
'podcast_id' => $podcastId,
])
->countAllResults();
}
/**
* Returns the timestamp difference in seconds between the next episode to publish and the current timestamp Returns
* false if there's no episode to publish

View File

@ -38,6 +38,7 @@ class Button extends Component
'danger' => 'text-white bg-red-600 hover:bg-red-700',
'warning' => 'text-black bg-yellow-500 hover:bg-yellow-600',
'info' => 'text-white bg-blue-500 hover:bg-blue-600',
'disabled' => 'text-black bg-gray-300 cursor-not-allowed',
];
$sizeClass = [

View File

@ -131,6 +131,18 @@ class EpisodeController extends BaseController
->with('errors', $this->validator->getErrors());
}
if ((new EpisodeModel())
->where([
'slug' => $this->request->getPost('slug'),
'podcast_id' => $this->podcast->id,
])
->first()) {
return redirect()
->back()
->withInput()
->with('error', lang('Episode.messages.sameSlugError'));
}
$db = db_connect();
$db->transStart();
@ -681,6 +693,11 @@ class EpisodeController extends BaseController
->with('errors', $episodeModel->errors());
}
// set podcast is_published_on_hubs to false to trigger websub push
(new PodcastModel())->update($this->episode->podcast->id, [
'is_published_on_hubs' => false,
]);
$db->transComplete();
return redirect()->route('episode-view', [$this->podcast->id, $this->episode->id]);
@ -715,43 +732,74 @@ class EpisodeController extends BaseController
->with('errors', $this->validator->getErrors());
}
if ($this->episode->published_at !== null) {
return redirect()
->back()
->withInput()
->with('error', lang('Episode.messages.deletePublishedEpisodeError'));
}
$audio = $this->episode->audio;
$db = db_connect();
$db->transStart();
$allPostsLinkedToEpisode = (new PostModel())
->where([
'episode_id' => $this->episode->id,
])
->findAll();
foreach ($allPostsLinkedToEpisode as $post) {
(new PostModel())->removePost($post);
$episodeModel = new EpisodeModel();
if (! $episodeModel->delete($this->episode->id)) {
$db->transRollback();
return redirect()
->back()
->withInput()
->with('errors', $episodeModel->errors());
}
// set podcast is_published_on_hubs to false to trigger websub push
(new PodcastModel())->update($this->episode->podcast->id, [
'is_published_on_hubs' => false,
]);
$episodeMediaList = [$this->episode->transcript, $this->episode->chapters, $audio];
$episodeModel = new EpisodeModel();
if ($this->episode->published_at !== null) {
// if episode is published, set episode published_at to null to unpublish before deletion
$this->episode->published_at = null;
//only delete episode cover if different from podcast's
if ($this->episode->cover_id !== null) {
$episodeMediaList[] = $this->episode->cover;
}
if (! $episodeModel->update($this->episode->id, $this->episode)) {
//delete episode media records from database
foreach ($episodeMediaList as $episodeMedia) {
if ($episodeMedia !== null && ! $episodeMedia->delete()) {
$db->transRollback();
return redirect()
->back()
->withInput()
->with('errors', $episodeModel->errors());
->with('error', lang('Episode.messages.deleteError', [
'type' => $episodeMedia->type,
]));
}
}
$episodeModel->delete($this->episode->id);
$warnings = [];
//remove episode media files from disk
foreach ($episodeMediaList as $episodeMedia) {
if ($episodeMedia !== null && ! $episodeMedia->deleteFile()) {
$warnings[] = lang('Episode.messages.deleteFileError', [
'type' => $episodeMedia->type,
'file_path' => $episodeMedia->file_path,
]);
}
}
$db->transComplete();
return redirect()->route('episode-list', [$this->podcast->id]);
if ($warnings !== []) {
return redirect()
->route('episode-list', [$this->podcast->id])
->with('message', lang('Episode.messages.deleteSuccess'))
->with('warnings', $warnings);
}
return redirect()->route('episode-list', [$this->podcast->id])->with(
'message',
lang('Episode.messages.deleteSuccess')
);
}
public function embed(): string

View File

@ -47,6 +47,24 @@ return [
'createSuccess' => 'Episode has been successfully created!',
'editSuccess' => 'Episode has been successfully updated!',
'publishCancelSuccess' => 'Episode publication successfully cancelled!',
'unpublishBeforeDeleteTip' => 'You must unpublish the episode before deleting it.',
'deletePublishedEpisodeError' => 'Please unpublish the episode before deleting it.',
'deleteSuccess' => 'Episode successfully deleted!',
'deleteError' => 'Failed to delete episode {type, select,
transcript {transcript}
chapters {chapters}
image {cover}
audio {audio}
other {media}
}.',
'deleteFileError' => 'Failed to delete {type, select,
transcript {transcript}
chapters {chapters}
image {cover}
audio {audio}
other {media}
} file {file_path}. You must manually remove it from your disk.',
'sameSlugError' => 'An episode with the chosen slug already exists.',
],
'form' => [
'file_size_error' =>
@ -147,7 +165,7 @@ return [
],
'delete_form' => [
'disclaimer' =>
"Deleting the episode will delete all the posts associated with it and remove it from the podcast's RSS feed.",
"Deleting the episode will delete all media files, comments, video clips and soundbites associated with it.",
'understand' => 'I understand, I want to delete the episode',
'submit' => 'Delete',
],

View File

@ -147,7 +147,7 @@ return [
],
'delete_form' => [
'disclaimer' =>
"Supprimer lépisode supprimera toutes les publications qui lui sont associées et le retirera du flux RSS du podcast.",
"Supprimer lépisode supprimera tous les fichiers multimédia, commentaires, extraits vidéos et extraits sonores qui lui sont associés.",
'understand' => 'Je comprends, je veux supprimer lépisode',
'submit' => 'Supprimer',
],

View File

@ -195,12 +195,6 @@ class AuthSeeder extends Seeder
],
[
'name' => 'delete',
'description' =>
'Delete an episode of a podcast without removing it from the database',
'has_permission' => ['podcast_admin'],
],
[
'name' => 'delete_permanently',
'description' =>
'Delete all occurrences of an episode of a podcast from the database',
'has_permission' => ['podcast_admin'],

View File

@ -28,8 +28,6 @@ class AddIsPublishedOnHubsToPodcasts extends Migration
public function down(): void
{
$prefix = $this->db->getPrefix();
$this->forge->dropColumn($prefix . 'podcasts', 'is_published_on_hubs');
$this->forge->dropColumn('podcasts', 'is_published_on_hubs');
}
}

View File

@ -28,8 +28,6 @@ class AddIsPublishedOnHubsToEpisodes extends Migration
public function down(): void
{
$prefix = $this->db->getPrefix();
$this->forge->dropColumn($prefix . 'episodes', 'is_published_on_hubs');
$this->forge->dropColumn('episodes', 'is_published_on_hubs');
}
}

View File

@ -17,3 +17,13 @@ if (session()->has('message')): ?>
</ul>
</Alert>
<?php endif; ?>
<?php if (session()->has('warnings')): ?>
<Alert variant="warning" class="mb-4">
<ul>
<?php foreach (session('warnings') as $warning): ?>
<li><?= esc($warning) ?></li>
<?php endforeach; ?>
</ul>
</Alert>
<?php endif; ?>

View File

@ -11,7 +11,7 @@
</div>
</a>
<button class="absolute top-0 right-0 z-10 p-2 mt-2 mr-2 text-white transition -translate-y-12 rounded-full opacity-0 focus:ring-accent focus:opacity-100 focus:-translate-y-0 group-hover:translate-y-0 bg-black/50 group-hover:opacity-100" id="more-dropdown-<?= $episode->id ?>" data-dropdown="button" data-dropdown-target="more-dropdown-<?= $episode->id ?>-menu" aria-haspopup="true" aria-expanded="false" title="<?= lang('Common.more') ?>"><?= icon('more') ?></button>
<DropdownMenu id="more-dropdown-<?= $episode->id ?>-menu" labelledby="more-dropdown-<?= $episode->id ?>" offsetY="-32" items="<?= esc(json_encode([
<?php $items = [
[
'type' => 'link',
'title' => lang('Episode.go_to_page'),
@ -45,11 +45,24 @@
[
'type' => 'separator',
],
[
];
if ($episode->published_at === null) {
$items[] = [
'type' => 'link',
'title' => lang('Episode.delete'),
'uri' => route_to('episode-delete', $episode->podcast->id, $episode->id),
'class' => 'font-semibold text-red-600',
],
])) ?>" />
];
} else {
$label = lang('Episode.delete');
$icon = icon('forbid', 'mr-2');
$title = lang('Episode.messages.unpublishBeforeDeleteTip');
$items[] = [
'type' => 'html',
'content' => esc(<<<CODE_SAMPLE
<span class="inline-flex items-center px-4 py-1 font-semibold text-gray-400 cursor-not-allowed" data-tooltip="bottom" title="{$title}">{$icon}{$label}</span>
CODE_SAMPLE),
];
} ?>
<DropdownMenu id="more-dropdown-<?= $episode->id ?>-menu" labelledby="more-dropdown-<?= $episode->id ?>" offsetY="-32" items="<?= esc(json_encode($items)) ?>" />
</article>

View File

@ -278,7 +278,11 @@
</form>
<Button class="mt-8" variant="danger" uri="<?= route_to('episode-delete', $podcast->id, $episode->id) ?>" iconLeft="delete-bin"><?= lang('Episode.delete') ?></Button>
<?php if ($episode->published_at === null): ?>
<Button class="mt-8" variant="danger" uri="<?= route_to('episode-delete', $podcast->id, $episode->id) ?>" iconLeft="delete-bin"><?= lang('Episode.delete') ?></Button>
<?php else: ?>
<Button class="mt-8" variant="disabled" iconLeft="forbid" data-tooltip="right" title="<?= lang('Episode.messages.unpublishBeforeDeleteTip') ?>"><?= lang('Episode.delete') ?></Button>
<?php endif ?>
<?= $this->endSection() ?>

View File

@ -73,50 +73,63 @@
[
'header' => lang('Episode.list.actions'),
'cell' => function ($episode, $podcast) {
$items = [
[
'type' => 'link',
'title' => lang('Episode.go_to_page'),
'uri' => route_to('episode', esc($podcast->handle), esc($episode->slug)),
],
[
'type' => 'link',
'title' => lang('Episode.edit'),
'uri' => route_to('episode-edit', $podcast->id, $episode->id),
],
[
'type' => 'link',
'title' => lang('Episode.embed.title'),
'uri' => route_to('embed-add', $podcast->id, $episode->id),
],
[
'type' => 'link',
'title' => lang('Person.persons'),
'uri' => route_to('episode-persons-manage', $podcast->id, $episode->id),
],
[
'type' => 'link',
'title' => lang('VideoClip.list.title'),
'uri' => route_to('video-clips-list', $episode->podcast->id, $episode->id),
],
[
'type' => 'link',
'title' => lang('Soundbite.list.title'),
'uri' => route_to('soundbites-list', $podcast->id, $episode->id),
],
[
'type' => 'separator',
],
];
if ($episode->published_at === null) {
$items[] = [
'type' => 'link',
'title' => lang('Episode.delete'),
'uri' => route_to('episode-delete', $podcast->id, $episode->id),
'class' => 'font-semibold text-red-600',
];
} else {
$label = lang('Episode.delete');
$icon = icon('forbid');
$title = lang('Episode.messages.unpublishBeforeDeleteTip');
$items[] = [
'type' => 'html',
'content' => esc(<<<CODE_SAMPLE
<span class="inline-flex items-center px-4 py-1 font-semibold text-gray-400 cursor-not-allowed" data-tooltip="bottom" title="{$title}">{$icon}<span class="ml-2">{$label}</span></span>
CODE_SAMPLE),
];
}
return '<button id="more-dropdown-' . $episode->id . '" type="button" class="inline-flex items-center p-1 rounded-full focus:ring-accent" data-dropdown="button" data-dropdown-target="more-dropdown-' . $episode->id . '-menu" aria-haspopup="true" aria-expanded="false">' .
icon('more') .
'</button>' .
'<DropdownMenu id="more-dropdown-' . $episode->id . '-menu" labelledby="more-dropdown-' . $episode->id . '" offsetY="-24" items="' . esc(json_encode([
[
'type' => 'link',
'title' => lang('Episode.go_to_page'),
'uri' => route_to('episode', esc($podcast->handle), esc($episode->slug)),
],
[
'type' => 'link',
'title' => lang('Episode.edit'),
'uri' => route_to('episode-edit', $podcast->id, $episode->id),
],
[
'type' => 'link',
'title' => lang('Episode.embed.title'),
'uri' => route_to('embed-add', $podcast->id, $episode->id),
],
[
'type' => 'link',
'title' => lang('Person.persons'),
'uri' => route_to('episode-persons-manage', $podcast->id, $episode->id),
],
[
'type' => 'link',
'title' => lang('VideoClip.list.title'),
'uri' => route_to('video-clips-list', $episode->podcast->id, $episode->id),
],
[
'type' => 'link',
'title' => lang('Soundbite.list.title'),
'uri' => route_to('soundbites-list', $podcast->id, $episode->id),
],
[
'type' => 'separator',
],
[
'type' => 'link',
'title' => lang('Episode.delete'),
'uri' => route_to('episode-delete', $podcast->id, $episode->id),
'class' => 'font-semibold text-red-600',
],
])) . '" />';
'<DropdownMenu id="more-dropdown-' . $episode->id . '-menu" labelledby="more-dropdown-' . $episode->id . '" offsetY="-24" items="' . esc(json_encode($items)) . '" />';
},
],
],

View File

@ -33,7 +33,13 @@ $podcastNavigation = [
'platforms-funding',
],
],
]; ?>
];
$counts = [
'episode-list' => $podcast->getEpisodesCount(),
];
?>
<div class="flex items-center px-4 py-2 border-b border-navigation">
<img
@ -65,13 +71,19 @@ $podcastNavigation = [
<ul class="flex flex-col">
<?php foreach ($data['items'] as $item): ?>
<?php $isActive = url_is(route_to($item, $podcast->id)); ?>
<?php
$itemLabel = lang('PodcastNavigation.' . $item);
if (array_key_exists($item, $counts)) {
$itemLabel .= ' (' . $counts[$item] . ')';
}
?>
<li class="inline-flex">
<a class="w-full py-1 pl-14 pr-2 text-sm hover:opacity-100 focus:ring-inset focus:ring-accent <?= $isActive
? 'font-semibold opacity-100 inline-flex items-center'
: 'opacity-75' ?>" href="<?= route_to(
$item,
$podcast->id,
) ?>"><?= ($isActive ? icon('chevron-right', 'mr-2') : '') . lang('PodcastNavigation.' . $item) ?></a>
) ?>"><?= ($isActive ? icon('chevron-right', 'mr-2') : '') . $itemLabel ?></a>
</li>
<?php endforeach; ?>
</ul>