From 07bb5e2c3a794e3b535432fb5ac26fc177e02b63 Mon Sep 17 00:00:00 2001 From: Peter Deltchev Date: Sat, 5 Dec 2015 18:41:12 -0800 Subject: [PATCH] #20: Implemented the genre merging tool. --- app/Commands/DeleteGenreCommand.php | 76 ++++++++++++++++++ app/Commands/RenameGenreCommand.php | 2 +- app/Genre.php | 4 +- .../Controllers/Api/Web/GenresController.php | 8 ++ app/Http/routes.php | 1 + app/Jobs/DeleteGenre.php | 78 +++++++++++++++++++ app/Library/SerializesModels.php | 48 ++++++++++++ app/Policies/GenrePolicy.php | 4 + app/Track.php | 1 - composer.json | 3 +- composer.lock | 54 ++++++++++++- ...1_24_182326_AddDeletedAtColumnToGenres.php | 49 ++++++++++++ ..._12_05_235108_create_failed_jobs_table.php | 33 ++++++++ public/templates/admin/genres.html | 8 +- .../app/controllers/admin-genres.coffee | 26 ++++++- .../scripts/app/services/admin-genres.coffee | 12 +++ resources/assets/styles/admin.less | 4 + vagrant/install.sh | 8 +- 18 files changed, 404 insertions(+), 15 deletions(-) create mode 100644 app/Commands/DeleteGenreCommand.php create mode 100644 app/Jobs/DeleteGenre.php create mode 100644 app/Library/SerializesModels.php create mode 100644 database/migrations/2015_11_24_182326_AddDeletedAtColumnToGenres.php create mode 100644 database/migrations/2015_12_05_235108_create_failed_jobs_table.php diff --git a/app/Commands/DeleteGenreCommand.php b/app/Commands/DeleteGenreCommand.php new file mode 100644 index 00000000..ae9cee0e --- /dev/null +++ b/app/Commands/DeleteGenreCommand.php @@ -0,0 +1,76 @@ +. + */ + +namespace Poniverse\Ponyfm\Commands; + +use Gate; +use Illuminate\Foundation\Bus\DispatchesJobs; +use Poniverse\Ponyfm\Genre; +use Poniverse\Ponyfm\Jobs\DeleteGenre; +use Validator; + +class DeleteGenreCommand extends CommandBase +{ + use DispatchesJobs; + + + /** @var Genre */ + private $_genreToDelete; + private $_destinationGenre; + + public function __construct($genreId, $destinationGenreId) { + $this->_genreToDelete = Genre::find($genreId); + $this->_destinationGenre = Genre::find($destinationGenreId); + } + + /** + * @return bool + */ + public function authorize() { + return Gate::allows('delete', $this->_genreToDelete); + } + + /** + * @throws \Exception + * @return CommandResponse + */ + public function execute() { + $rules = [ + 'genre_to_delete' => 'required', + 'destination_genre' => 'required', + ]; + + // The validation will fail if the genres don't exist + // because they'll be null. + $validator = Validator::make([ + 'genre_to_delete' => $this->_genreToDelete, + 'destination_genre' => $this->_destinationGenre, + ], $rules); + + + if ($validator->fails()) { + return CommandResponse::fail($validator); + } + + $this->dispatch(new DeleteGenre($this->_genreToDelete, $this->_destinationGenre)); + + return CommandResponse::succeed(['message' => 'Genre deleted!']); + } +} diff --git a/app/Commands/RenameGenreCommand.php b/app/Commands/RenameGenreCommand.php index f4e99005..6882c646 100644 --- a/app/Commands/RenameGenreCommand.php +++ b/app/Commands/RenameGenreCommand.php @@ -33,7 +33,7 @@ class RenameGenreCommand extends CommandBase public function __construct($genreId, $newName) { - $this->_genre = Genre::find($genreId);; + $this->_genre = Genre::find($genreId); $this->_newName = $newName; } diff --git a/app/Genre.php b/app/Genre.php index 3539f6db..f44d5c71 100644 --- a/app/Genre.php +++ b/app/Genre.php @@ -22,6 +22,7 @@ namespace Poniverse\Ponyfm; use DB; use Illuminate\Database\Eloquent\Relations\Relation; +use Illuminate\Database\Eloquent\SoftDeletes; use Poniverse\Ponyfm\Traits\SlugTrait; use Illuminate\Database\Eloquent\Model; use URL; @@ -30,13 +31,14 @@ use Venturecraft\Revisionable\RevisionableTrait; class Genre extends Model { protected $table = 'genres'; + protected $fillable = ['name', 'slug']; protected $appends = ['track_count', 'url']; protected $hidden = ['trackCountRelation']; public $timestamps = false; - use SlugTrait, RevisionableTrait; + use SlugTrait, SoftDeletes, RevisionableTrait; public function tracks(){ return $this->hasMany(Track::class, 'genre_id'); diff --git a/app/Http/Controllers/Api/Web/GenresController.php b/app/Http/Controllers/Api/Web/GenresController.php index 6e09d41c..78710baa 100644 --- a/app/Http/Controllers/Api/Web/GenresController.php +++ b/app/Http/Controllers/Api/Web/GenresController.php @@ -21,6 +21,7 @@ namespace Poniverse\Ponyfm\Http\Controllers\Api\Web; use Input; +use Poniverse\Ponyfm\Commands\DeleteGenreCommand; use Poniverse\Ponyfm\Commands\RenameGenreCommand; use Poniverse\Ponyfm\Genre; use Poniverse\Ponyfm\Http\Controllers\ApiControllerBase; @@ -50,4 +51,11 @@ class GenresController extends ApiControllerBase $command = new RenameGenreCommand($genreId, Input::get('name')); return $this->execute($command); } + + + public function deleteGenre($genreId) + { + $command = new DeleteGenreCommand($genreId, Input::get('destination_genre_id')); + return $this->execute($command); + } } diff --git a/app/Http/routes.php b/app/Http/routes.php index 6b03f878..8186f2b8 100644 --- a/app/Http/routes.php +++ b/app/Http/routes.php @@ -145,6 +145,7 @@ Route::group(['prefix' => 'api/web'], function() { Route::group(['prefix' => 'admin', 'middleware' => ['auth', 'can:access-admin-area']], function() { Route::get('/genres', 'Api\Web\GenresController@getIndex'); Route::put('/genres/{id}', 'Api\Web\GenresController@putRename')->where('id', '\d+'); + Route::delete('/genres/{id}', 'Api\Web\GenresController@deleteGenre')->where('id', '\d+'); }); Route::post('/auth/logout', 'Api\Web\AuthController@postLogout'); diff --git a/app/Jobs/DeleteGenre.php b/app/Jobs/DeleteGenre.php new file mode 100644 index 00000000..5a518f1b --- /dev/null +++ b/app/Jobs/DeleteGenre.php @@ -0,0 +1,78 @@ +. + */ + +namespace Poniverse\Ponyfm\Jobs; + +use Auth; +use Poniverse\Ponyfm\Genre; +use Illuminate\Queue\InteractsWithQueue; +use Illuminate\Contracts\Bus\SelfHandling; +use Illuminate\Contracts\Queue\ShouldQueue; +use Poniverse\Ponyfm\Track; +use SerializesModels; + +class DeleteGenre extends Job implements SelfHandling, ShouldQueue +{ + use InteractsWithQueue, SerializesModels; + + protected $executingUser; + protected $genreToDelete; + protected $destinationGenre; + + /** + * Create a new job instance. + * + * @param Genre $genreToDelete + * @param Genre $destinationGenre + */ + public function __construct(Genre $genreToDelete, Genre $destinationGenre) + { + $this->executingUser = Auth::user(); + $this->genreToDelete = $genreToDelete; + $this->destinationGenre = $destinationGenre; + + // The genre is deleted synchronously before the job is executed in + // order to prevent race conditions. + $this->genreToDelete->delete(); + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + // The user who kicked off this job is used when generating revision log entries. + Auth::login($this->executingUser); + + // This is done instead of a single UPDATE query in order to + // generate revision logs for the change. + $this->genreToDelete->tracks()->chunk(200, function ($tracks) { + foreach ($tracks as $track) { + /** @var Track $track */ + + $track->genre_id = $this->destinationGenre->id; + $track->save(); + $track->updateTags(); + } + }); + } +} diff --git a/app/Library/SerializesModels.php b/app/Library/SerializesModels.php new file mode 100644 index 00000000..4de788c3 --- /dev/null +++ b/app/Library/SerializesModels.php @@ -0,0 +1,48 @@ +. + */ + +use Illuminate\Contracts\Database\ModelIdentifier; + +/** + * Class SerializesModels + * This version of the SerializesModel trait overrides a method to make it work + * with soft-deletable models. + * + * @link https://github.com/laravel/framework/issues/9347#issuecomment-120803564 + */ +trait SerializesModels { + use \Illuminate\Queue\SerializesModels; + + /** + * Get the restored property value after deserialization. + * + * @param mixed $value + * @return mixed + */ + protected function getRestoredPropertyValue($value) { + if ($value instanceof ModelIdentifier) { + return method_exists($value->class, 'withTrashed') + ? (new $value->class)->withTrashed()->findOrFail($value->id) + : (new $value->class)->findOrFail($value->id); + } else { + return $value; + } + } +} diff --git a/app/Policies/GenrePolicy.php b/app/Policies/GenrePolicy.php index 55fdabe9..54e07780 100644 --- a/app/Policies/GenrePolicy.php +++ b/app/Policies/GenrePolicy.php @@ -28,4 +28,8 @@ class GenrePolicy public function rename(User $user, Genre $genre) { return $user->hasRole('admin'); } + + public function delete(User $user, Genre $genre) { + return $user->hasRole('admin'); + } } diff --git a/app/Track.php b/app/Track.php index 34727b8b..078d968a 100644 --- a/app/Track.php +++ b/app/Track.php @@ -24,7 +24,6 @@ use Auth; use Cache; use Config; use DB; -use Illuminate\Database\Eloquent\ModelNotFoundException; use Poniverse\Ponyfm\Traits\SlugTrait; use Exception; use External; diff --git a/composer.json b/composer.json index 69b2eb09..6b7875a2 100644 --- a/composer.json +++ b/composer.json @@ -13,7 +13,8 @@ "barryvdh/laravel-ide-helper": "^2.1", "guzzlehttp/guzzle": "~6.0", "doctrine/dbal": "^2.5", - "venturecraft/revisionable": "^1.23" + "venturecraft/revisionable": "^1.23", + "pda/pheanstalk": "~3.0" }, "require-dev": { "fzaninotto/faker": "~1.4", diff --git a/composer.lock b/composer.lock index a45118e3..d271ed7c 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,8 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file", "This file is @generated automatically" ], - "hash": "5f66a059010df46b5b6e50c3e4056e17", - "content-hash": "07e7a5fff5a8914a7ced3d14959d194f", + "hash": "edca1732ab37f49b64614d5729d652d3", + "content-hash": "b476009ee841e5b048e73b4fab8372ee", "packages": [ { "name": "barryvdh/laravel-ide-helper", @@ -1622,6 +1622,56 @@ ], "time": "2015-07-14 17:31:05" }, + { + "name": "pda/pheanstalk", + "version": "v3.1.0", + "source": { + "type": "git", + "url": "https://github.com/pda/pheanstalk.git", + "reference": "430e77c551479aad0c6ada0450ee844cf656a18b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/pda/pheanstalk/zipball/430e77c551479aad0c6ada0450ee844cf656a18b", + "reference": "430e77c551479aad0c6ada0450ee844cf656a18b", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "~4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "psr-4": { + "Pheanstalk\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paul Annesley", + "email": "paul@annesley.cc", + "homepage": "http://paul.annesley.cc/", + "role": "Developer" + } + ], + "description": "PHP client for beanstalkd queue", + "homepage": "https://github.com/pda/pheanstalk", + "keywords": [ + "beanstalkd" + ], + "time": "2015-08-07 21:42:41" + }, { "name": "phpdocumentor/reflection-docblock", "version": "2.0.4", diff --git a/database/migrations/2015_11_24_182326_AddDeletedAtColumnToGenres.php b/database/migrations/2015_11_24_182326_AddDeletedAtColumnToGenres.php new file mode 100644 index 00000000..63c8bbdd --- /dev/null +++ b/database/migrations/2015_11_24_182326_AddDeletedAtColumnToGenres.php @@ -0,0 +1,49 @@ +. + */ + +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Database\Migrations\Migration; + +class AddDeletedAtColumnToGenres extends Migration +{ + /** + * Run the migrations. + * + * @return void + */ + public function up() + { + Schema::table('genres', function(Blueprint $table) { + $table->softDeletes()->index(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('genres', function(Blueprint $table) { + $table->dropSoftDeletes(); + }); + } +} diff --git a/database/migrations/2015_12_05_235108_create_failed_jobs_table.php b/database/migrations/2015_12_05_235108_create_failed_jobs_table.php new file mode 100644 index 00000000..c1ba41b4 --- /dev/null +++ b/database/migrations/2015_12_05_235108_create_failed_jobs_table.php @@ -0,0 +1,33 @@ +increments('id'); + $table->text('connection'); + $table->text('queue'); + $table->longText('payload'); + $table->timestamp('failed_at'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::drop('failed_jobs'); + } +} diff --git a/public/templates/admin/genres.html b/public/templates/admin/genres.html index d274e613..1751f942 100644 --- a/public/templates/admin/genres.html +++ b/public/templates/admin/genres.html @@ -6,7 +6,7 @@ Genre # of tracks (including deleted) - Actions + Actions @@ -23,8 +23,10 @@ {{ genre.track_count }} - - + + + + diff --git a/resources/assets/scripts/app/controllers/admin-genres.coffee b/resources/assets/scripts/app/controllers/admin-genres.coffee index fcc766ce..7242b5e1 100644 --- a/resources/assets/scripts/app/controllers/admin-genres.coffee +++ b/resources/assets/scripts/app/controllers/admin-genres.coffee @@ -20,13 +20,21 @@ angular.module('ponyfm').controller 'admin-genres', [ $scope.genres = [] + # Used for merging/deleting genres + $scope.mergeInProgress = false + $scope.genreToDelete = null + setGenres = (genres) -> + $scope.genres = [] for genre in genres genre.isSaving = false genre.isError = false $scope.genres.push(genre) - genres.fetch().done setGenres + loadGenres = () -> + genres.fetch().done setGenres + + loadGenres() # Renames the given genre @@ -42,7 +50,17 @@ angular.module('ponyfm').controller 'admin-genres', [ genre.isSaving = false - # Merges genre1 into genre2 - mergeGenre = (genre1, genre2) -> - # stub method + $scope.startMerge = (genreToDelete) -> + $scope.genreToDelete = genreToDelete + $scope.mergeInProgress = true + + $scope.cancelMerge = () -> + $scope.genreToDelete = null + $scope.mergeInProgress = false + + $scope.finishMerge = (destinationGenre) -> + $scope.mergeInProgress = false + genres.merge($scope.genreToDelete.id, destinationGenre.id) + .done (response) -> + loadGenres() ] diff --git a/resources/assets/scripts/app/services/admin-genres.coffee b/resources/assets/scripts/app/services/admin-genres.coffee index b36e22c1..4aa28c89 100644 --- a/resources/assets/scripts/app/services/admin-genres.coffee +++ b/resources/assets/scripts/app/services/admin-genres.coffee @@ -41,5 +41,17 @@ angular.module('ponyfm').factory('admin-genres', [ def.promise() + merge: (genre_id_to_delete, destination_genre_id) -> + url = "/api/web/admin/genres/#{genre_id_to_delete}" + def = new $.Deferred() + + $http.delete(url, {params: {destination_genre_id: destination_genre_id}}) + .success (response)-> + def.resolve(response) + + .error (response)-> + def.reject(response) + + def.promise() self ]) diff --git a/resources/assets/styles/admin.less b/resources/assets/styles/admin.less index 3a28666d..eaae7f6a 100644 --- a/resources/assets/styles/admin.less +++ b/resources/assets/styles/admin.less @@ -24,4 +24,8 @@ .-status { width: 30px; } + + .-actions { + width: 300px; + } } diff --git a/vagrant/install.sh b/vagrant/install.sh index 973b6a7e..f6d82960 100755 --- a/vagrant/install.sh +++ b/vagrant/install.sh @@ -36,5 +36,9 @@ cp -n "/vagrant/resources/environments/.env.local" "/vagrant/.env" php artisan migrate php artisan db:seed -echo "Now - if you haven't already, SSH into the VM and run \`php artisan poni:setup\`!" -echo "See the README for more details." +echo "" +echo "+-----------------------------------------------+" +echo "| Now - if you haven't already, SSH into the VM |" +echo "| and run \`php artisan poni:setup\`! |" +echo "| See the README for more details. |" +echo "+-----------------------------------------------+"