2 |
3 |
7 |
8 |
12 |
13 |
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/src/ApprovableObserver.php:
--------------------------------------------------------------------------------
1 | initializeApprovalStatus($model);
12 | }
13 |
14 | public function updating(Model $model)
15 | {
16 | $this->resetApprovalStatus($model);
17 | }
18 |
19 | protected function initializeApprovalStatus(Model $model)
20 | {
21 | if ($model->isDirty($model->getApprovalStatusColumn())) {
22 | return;
23 | }
24 |
25 | $this->suspend($model);
26 | }
27 |
28 | protected function resetApprovalStatus(Model $model)
29 | {
30 | $modifiedAttributes = array_keys(
31 | $model->getDirty()
32 | );
33 |
34 | foreach ($modifiedAttributes as $name) {
35 | if ($model->isApprovalRequired($name)) {
36 | $this->suspend($model);
37 |
38 | return;
39 | }
40 | }
41 | }
42 |
43 | protected function suspend(Model $model)
44 | {
45 | $model->setAttribute(
46 | $model->getApprovalStatusColumn(),
47 | ApprovalStatuses::PENDING
48 | );
49 |
50 | $model->setAttribute(
51 | $model->getApprovalAtColumn(),
52 | null
53 | );
54 | }
55 | }
--------------------------------------------------------------------------------
/tests/HandlesApprovalTest.php:
--------------------------------------------------------------------------------
1 | create();
25 |
26 | $key = $entity->id;
27 |
28 | foreach($this->approvalStatuses as $approval_status) {
29 | $request = Request::create("/admin/enitiy/$key/approval", 'POST', [
30 | 'approval_status' => $approval_status
31 | ]);
32 |
33 | $response = $this->handleRequestUsing($request, function ($request) use($key) {
34 | return $this->performApproval($key, $request);
35 | });
36 |
37 | $this->assertDatabaseHas('entities', [
38 | 'id' => $key,
39 | 'approval_status' => $approval_status,
40 | 'approval_at' => now()
41 | ]);
42 |
43 | $response->assertStatus(200);
44 | }
45 | }
46 |
47 | protected function handleRequestUsing(Request $request, callable $callback)
48 | {
49 | try {
50 | $response = response(
51 | (new Pipeline($this->app))
52 | ->send($request)
53 | ->through([])
54 | ->then($callback)
55 | );
56 | } catch (\Throwable $e) {
57 | $this->app[ExceptionHandler::class]
58 | ->report($e);
59 |
60 | $response = $this->app[ExceptionHandler::class]
61 | ->render($request, $e);
62 | }
63 |
64 | return new TestResponse($response);
65 | }
66 | }
--------------------------------------------------------------------------------
/src/ApprovalEvents.php:
--------------------------------------------------------------------------------
1 | approvalScopeDisabled) {
32 | return;
33 | }
34 |
35 | $builder->where(
36 | $model->getQualifiedApprovalStatusColumn(),
37 | ApprovalStatuses::APPROVED
38 | );
39 | }
40 |
41 | public function extend(Builder $builder)
42 | {
43 | foreach ($this->extensions as $extension) {
44 | $this->{'add'.$extension}($builder);
45 | }
46 | }
47 |
48 | protected function addAnyApprovalStatus(Builder $builder)
49 | {
50 | $builder->macro('anyApprovalStatus', function (Builder $builder) {
51 | return $builder->withoutGlobalScope($this);
52 | });
53 | }
54 |
55 | protected function addOnlyPending(Builder $builder)
56 | {
57 | $builder->macro('onlyPending', function (Builder $builder) {
58 | return $this->onlyWithStatus($builder, ApprovalStatuses::PENDING);
59 | });
60 | }
61 |
62 | protected function addOnlyRejected(Builder $builder)
63 | {
64 | $builder->macro('onlyRejected', function (Builder $builder) {
65 | return $this->onlyWithStatus($builder, ApprovalStatuses::REJECTED);
66 | });
67 | }
68 |
69 | protected function addOnlyApproved(Builder $builder)
70 | {
71 | $builder->macro('onlyApproved', function (Builder $builder) {
72 | return $this->onlyWithStatus($builder, ApprovalStatuses::APPROVED);
73 | });
74 | }
75 |
76 | protected function onlyWithStatus(Builder $builder, $status)
77 | {
78 | $model = $builder->getModel();
79 |
80 | $builder->anyApprovalStatus()->where(
81 | $model->getQualifiedApprovalStatusColumn(),
82 | $status
83 | );
84 |
85 | return $builder;
86 | }
87 |
88 | protected function addApprove(Builder $builder)
89 | {
90 | $builder->macro('approve', function (Builder $builder) {
91 | return $this->updateStatus($builder, ApprovalStatuses::APPROVED);
92 | });
93 | }
94 |
95 | protected function addReject(Builder $builder)
96 | {
97 | $builder->macro('reject', function (Builder $builder) {
98 | return $this->updateStatus($builder, ApprovalStatuses::REJECTED);
99 | });
100 | }
101 |
102 | protected function addSuspend(Builder $builder)
103 | {
104 | $builder->macro('suspend', function (Builder $builder) {
105 | return $this->updateStatus($builder, ApprovalStatuses::PENDING);
106 | });
107 | }
108 |
109 | protected function updateStatus(Builder $builder, $status)
110 | {
111 | $model = $builder->getModel();
112 |
113 | $builder->anyApprovalStatus();
114 |
115 | $model->timestamps = false;
116 |
117 | return $builder->update([
118 | $model->getApprovalStatusColumn() => $status,
119 | $model->getApprovalAtColumn() => $model->freshTimestampString()
120 | ]);
121 | }
122 | }
--------------------------------------------------------------------------------
/src/Approvable.php:
--------------------------------------------------------------------------------
1 | getTable().'.'.$this->getApprovalStatusColumn();
27 | }
28 |
29 | public function getApprovalAtColumn()
30 | {
31 | return defined('static::APPROVAL_AT') ? static::APPROVAL_AT : 'approval_at';
32 | }
33 |
34 | /**
35 | * @return bool|void
36 | */
37 | public function approve()
38 | {
39 | return $this->updateApproval(
40 | ApprovalStatuses::APPROVED,
41 | 'approving',
42 | 'approved');
43 | }
44 |
45 | /**
46 | * @return bool|void
47 | */
48 | public function reject()
49 | {
50 | return $this->updateApproval(
51 | ApprovalStatuses::REJECTED,
52 | 'rejecting',
53 | 'rejected');
54 | }
55 |
56 | /**
57 | * @return bool|void
58 | */
59 | public function suspend()
60 | {
61 | return $this->updateApproval(
62 | ApprovalStatuses::PENDING,
63 | 'suspending',
64 | 'suspended');
65 | }
66 |
67 | /**
68 | * @param $status
69 | * @param $beforeEvent
70 | * @param $afterEvent
71 | * @return bool|void
72 | * @throws Exception
73 | */
74 | protected function updateApproval($status, $beforeEvent, $afterEvent)
75 | {
76 | if (is_null($this->getKeyName())) {
77 | throw new Exception('No primary key defined on model.');
78 | }
79 |
80 | if (! $this->exists) {
81 | return;
82 | }
83 |
84 | if ($this->{$this->getApprovalStatusColumn()} == $status)
85 | {
86 | return false;
87 | }
88 |
89 | if ($this->fireModelEvent($beforeEvent) === false) {
90 | return false;
91 | }
92 |
93 | $this->{$this->getApprovalStatusColumn()} = $status;
94 |
95 | $time = $this->freshTimestamp();
96 |
97 | $this->{$this->getApprovalAtColumn()} = $time;
98 |
99 | $columns = [
100 | $this->getApprovalStatusColumn() => $status,
101 |
102 | $this->getApprovalAtColumn() => $this->fromDateTime($time)
103 | ];
104 |
105 | $this->getConnection()
106 | ->table($this->getTable())
107 | ->where($this->getKeyName(), $this->getKey())
108 | ->update($columns);
109 |
110 | $this->fireModelEvent($afterEvent, false);
111 | $this->fireModelEvent('approvalChanged', false);
112 |
113 | return true;
114 | }
115 |
116 | /**
117 | * @return bool|void
118 | */
119 | public function isPending()
120 | {
121 | return $this->hasApprovalStatus(ApprovalStatuses::PENDING);
122 | }
123 |
124 | /**
125 | * @return bool|void
126 | */
127 | public function isApproved()
128 | {
129 | return $this->hasApprovalStatus(ApprovalStatuses::APPROVED);
130 | }
131 |
132 | /**
133 | * @return bool|void
134 | */
135 | public function isRejected()
136 | {
137 | return $this->hasApprovalStatus(ApprovalStatuses::REJECTED);
138 | }
139 |
140 | /**
141 | * @param $status
142 | * @return bool|void
143 | */
144 | protected function hasApprovalStatus($status)
145 | {
146 | if (! $this->exists) {
147 | return;
148 | }
149 |
150 | return $this->{$this->getApprovalStatusColumn()} == $status;
151 | }
152 | }
--------------------------------------------------------------------------------
/tests/ApprovalRequiredtTest.php:
--------------------------------------------------------------------------------
1 | assertEquals(['*'], $entity->approvalRequired());
21 | $this->assertEquals([], $entity->approvalNotRequired());
22 | }
23 |
24 | /**
25 | * @test
26 | */
27 | public function it_works_when_all_are_required()
28 | {
29 | $entity = new Class extends Model {
30 | use Approvable;
31 | };
32 |
33 | $this->assertTrue($entity->isApprovalRequired('attr_1'));
34 | $this->assertTrue($entity->isApprovalRequired('attr_2'));
35 | $this->assertTrue($entity->isApprovalRequired('attr_3'));
36 | }
37 |
38 | /**
39 | * @test
40 | */
41 | public function it_works_when_all_are_required_except_some_not_required()
42 | {
43 | $entity = new Class extends Model {
44 | use Approvable;
45 |
46 | public function approvalNotRequired()
47 | {
48 | return ['attr_3'];
49 | }
50 | };
51 |
52 | $this->assertTrue($entity->isApprovalRequired('attr_1'));
53 | $this->assertTrue($entity->isApprovalRequired('attr_2'));
54 | $this->assertFalse($entity->isApprovalRequired('attr_3'));
55 | }
56 |
57 | /**
58 | * @test
59 | */
60 | public function it_works_when_some_are_required_and_the_rest_not_required()
61 | {
62 | $entity = new Class extends Model {
63 | use Approvable;
64 |
65 | public function approvalRequired()
66 | {
67 | return ['attr_1'];
68 | }
69 | };
70 |
71 | $this->assertTrue($entity->isApprovalRequired('attr_1'));
72 | $this->assertFalse($entity->isApprovalRequired('attr_2'));
73 | $this->assertFalse($entity->isApprovalRequired('attr_3'));
74 | }
75 |
76 | /**
77 | * @test
78 | */
79 | public function it_works_when_non_is_required()
80 | {
81 | $entity = new Class extends Model
82 | {
83 | use Approvable;
84 |
85 | public function approvalRequired()
86 | {
87 | return [];
88 | }
89 | };
90 |
91 | $this->assertFalse($entity->isApprovalRequired('attr_1'));
92 | $this->assertFalse($entity->isApprovalRequired('attr_2'));
93 | $this->assertFalse($entity->isApprovalRequired('attr_3'));
94 | }
95 |
96 | /**
97 | * @test
98 | */
99 | public function it_works_when_some_are_required_and_some_not_required()
100 | {
101 | $entity = new Class extends Model
102 | {
103 | use Approvable;
104 |
105 | public function approvalRequired()
106 | {
107 | return ['attr_1'];
108 | }
109 |
110 | public function approvalNotRequired()
111 | {
112 | return ['attr_2'];
113 | }
114 | };
115 |
116 | $this->assertTrue($entity->isApprovalRequired('attr_1'));
117 | $this->assertFalse($entity->isApprovalRequired('attr_2'));
118 | $this->assertTrue($entity->isApprovalRequired('attr_3'));
119 | }
120 |
121 | /**
122 | * @test
123 | */
124 | public function it_works_when_some_are_not_required_and_the_rest_are_required()
125 | {
126 | $entity = new Class extends Model
127 | {
128 | use Approvable;
129 |
130 | public function approvalRequired()
131 | {
132 | return [];
133 | }
134 |
135 | public function approvalNotRequired()
136 | {
137 | return ['attr_1'];
138 | }
139 | };
140 |
141 | $this->assertFalse($entity->isApprovalRequired('attr_1'));
142 | $this->assertTrue($entity->isApprovalRequired('attr_2'));
143 | $this->assertTrue($entity->isApprovalRequired('attr_3'));
144 | }
145 | }
--------------------------------------------------------------------------------
/tests/SuspensionOnUpdateTest.php:
--------------------------------------------------------------------------------
1 | approved()->raw();
24 |
25 | with($entity = new class ($attributes) extends Entity {
26 | protected $table = 'entities';
27 |
28 | public function approvalRequired()
29 | {
30 | return ['*'];
31 | }
32 |
33 | public function approvalNotRequired()
34 | {
35 | return [];
36 | }
37 | })->save();
38 |
39 | $entity->update([
40 | 'attr_1' => $this->faker->word
41 | ]);
42 |
43 | $this->assertEquals(ApprovalStatuses::PENDING, $entity->approval_status);
44 | $this->assertNull($entity->approval_at);
45 |
46 | $this->assertDatabaseHas('entities', [
47 | 'id' => $entity->id,
48 | 'approval_status' => ApprovalStatuses::PENDING,
49 | 'approval_at' => null,
50 | ]);
51 | }
52 |
53 | /**
54 | * @test
55 | */
56 | public function it_works_when_some_attributes_do_not_require_approval_on_update()
57 | {
58 | $attributes = Entity::factory()->approved()->raw();
59 |
60 | // it isn't suspended on update of the attributes that don't require approval
61 | with($entity = new class ($attributes) extends Entity {
62 | protected $table = 'entities';
63 |
64 | public function approvalRequired()
65 | {
66 | return ['*'];
67 | }
68 |
69 | public function approvalNotRequired()
70 | {
71 | return ['attr_1',];
72 | }
73 | })->save();
74 |
75 | $entity->update([
76 | 'attr_1' => $this->faker->word,
77 | ]);
78 |
79 | $this->assertEquals(ApprovalStatuses::APPROVED, $entity->approval_status);
80 | $this->assertNotNull($entity->approval_at);
81 |
82 | $this->assertDatabaseHas('entities', [
83 | 'id' => $entity->id,
84 | 'approval_status' => ApprovalStatuses::APPROVED,
85 | 'approval_at' => $attributes['approval_at']
86 | ]);
87 |
88 | // it is suspended on update of the attributes that require approval
89 | with($entity = new class ($attributes) extends Entity {
90 | protected $table = 'entities';
91 |
92 | public function approvalRequired()
93 | {
94 | return ['*'];
95 | }
96 |
97 | public function approvalNotRequired()
98 | {
99 | return ['attr_1',];
100 | }
101 | })->save();
102 |
103 | $entity->update([
104 | 'attr_2' => $this->faker->word,
105 | ]);
106 |
107 | $this->assertEquals(ApprovalStatuses::PENDING, $entity->approval_status);
108 | $this->assertNull($entity->approval_at);
109 |
110 | $this->assertDatabaseHas('entities', [
111 | 'id' => $entity->id,
112 | 'approval_status' => ApprovalStatuses::PENDING,
113 | 'approval_at' => null,
114 | ]);
115 | }
116 |
117 | /**
118 | * @test
119 | */
120 | public function it_works_when_some_attributes_require_approval_on_update()
121 | {
122 | $attributes = Entity::factory()->approved()->raw();
123 |
124 | // it isn't suspended on update of the attributes that don't require approval
125 | with($entity = new class ($attributes) extends Entity {
126 | protected $table = 'entities';
127 |
128 | public function approvalRequired()
129 | {
130 | return ['attr_1',];
131 | }
132 |
133 | public function approvalNotRequired()
134 | {
135 | return [];
136 | }
137 | })->save();
138 |
139 | $entity->update([
140 | 'attr_2' => $this->faker->word,
141 | 'attr_3' => $this->faker->word,
142 | ]);
143 |
144 | $this->assertEquals(ApprovalStatuses::APPROVED, $entity->approval_status);
145 | $this->assertNotNull($entity->approval_at);
146 |
147 | $this->assertDatabaseHas('entities', [
148 | 'id' => $entity->id,
149 | 'approval_status' => ApprovalStatuses::APPROVED,
150 | 'approval_at' => $attributes['approval_at']
151 | ]);
152 |
153 | // it is suspended on update of the attributes that require approval
154 | with($entity = new class ($attributes) extends Entity {
155 | protected $table = 'entities';
156 |
157 | public function approvalRequired()
158 | {
159 | return ['attr_1',];
160 | }
161 |
162 | public function approvalNotRequired()
163 | {
164 | return [];
165 | }
166 | })->save();
167 |
168 | $entity->update([
169 | 'attr_1' => $this->faker->word,
170 | ]);
171 |
172 | $this->assertEquals(ApprovalStatuses::PENDING, $entity->approval_status);
173 | $this->assertNull($entity->approval_at);
174 |
175 | $this->assertDatabaseHas('entities', [
176 | 'id' => $entity->id,
177 | 'approval_status' => ApprovalStatuses::PENDING,
178 | 'approval_at' => null,
179 | ]);
180 | }
181 |
182 | /**
183 | * @test
184 | */
185 | public function it_works_when_no_attribute_requires_approval_on_update()
186 | {
187 | $attributes = Entity::factory()->approved()->raw();
188 |
189 | with($entity = new class ($attributes) extends Entity {
190 | protected $table = 'entities';
191 |
192 | public function approvalRequired()
193 | {
194 | return [];
195 | }
196 |
197 | public function approvalNotRequired()
198 | {
199 | return [];
200 | }
201 | })->save();
202 |
203 | $entity->update([
204 | 'attr_1' => $this->faker->word,
205 | 'attr_2' => $this->faker->word,
206 | 'attr_3' => $this->faker->word,
207 | ]);
208 |
209 | $this->assertEquals(ApprovalStatuses::APPROVED, $entity->approval_status);
210 | $this->assertNotNull($entity->approval_status);
211 |
212 | $this->assertDatabaseHas('entities', [
213 | 'id' => $entity->id,
214 | 'approval_status' => ApprovalStatuses::APPROVED,
215 | 'approval_at' => $attributes['approval_at']
216 | ]);
217 | }
218 | }
--------------------------------------------------------------------------------
/tests/ApprovalEventsTest.php:
--------------------------------------------------------------------------------
1 | create();
47 |
48 | for ($i = 0; $i < count($this->actions); $i++) {
49 | $action = $this->actions[$i];
50 | $event = $this->beforeEvents[$i];
51 | $listener = $event.'Listener';
52 | $mock = $this->getMockBuilder('stdClass')
53 | ->addMethods([$listener])
54 | ->getMock();
55 | $mock->expects($this->once())->method($listener);
56 |
57 | Entity::$event([$mock, $listener]);
58 |
59 | $entity->$action();
60 | }
61 | }
62 |
63 | /**
64 | * @test
65 | */
66 | public function it_allows_listeners_of_before_action_events_halt_the_action_execution()
67 | {
68 | for ($i = 0; $i < count($this->actions); $i++) {
69 | $action = $this->actions[$i];
70 | $beforeEvent = $this->beforeEvents[$i];
71 | $beforeListener = $beforeEvent.'Listener';
72 | $afterEvent = $this->afterEvents[$i];
73 | $afterEventListener = $afterEvent.'Listener';
74 | $mock = $this->getMockBuilder('stdClass')
75 | ->addMethods([$beforeListener, $afterEventListener])
76 | ->getMock();
77 | $mock->method($beforeListener)->will($this->returnValue(false));
78 | $mock->expects($this->never())->method($afterEventListener);
79 | Entity::$beforeEvent([$mock, $beforeListener]);
80 | Entity::$afterEvent([$mock, $afterEventListener]);
81 |
82 | $entity = Entity::factory()->create([
83 | 'approval_status' => Arr::random(Arr::except($this->statuses, [$i]))
84 | ]);
85 |
86 | $this->assertFalse($entity->$action());
87 |
88 | $this->assertFalse($entity->{$this->checks[$i]}());
89 |
90 | $this->assertDatabaseMissing('entities', [
91 | 'id' => $entity->id,
92 | 'approval_status' => $this->statuses[$i]
93 | ]);
94 | }
95 | }
96 |
97 | /**
98 | * @test
99 | */
100 | public function it_dispatches_events_after_approval_actions()
101 | {
102 | $entity = Entity::factory()->create();
103 |
104 | for ($i = 0; $i < count($this->actions); $i++) {
105 | $action = $this->actions[$i];
106 | $event = $this->afterEvents[$i];
107 | $listener = $event.'Listener';
108 | $mock = $this->getMockBuilder('stdClass')
109 | ->addMethods([$listener, 'approvalChangedListener'])
110 | ->getMock();
111 | $mock->expects($this->once())->method($listener);
112 | $mock->expects($this->once())->method('approvalChangedListener');
113 |
114 | Entity::$event([$mock, $listener]);
115 | Entity::getEventDispatcher()->forget("eloquent.approvalChanged: ".Entity::class);
116 | Entity::approvalChanged([$mock, 'approvalChangedListener']);
117 |
118 | $entity->$action();
119 | }
120 | }
121 |
122 | /**
123 | * @test
124 | */
125 | public function it_will_not_dispatch_the_events_on_the_duplicate_approvals()
126 | {
127 | for($i = 0; $i < count($this->statuses); $i++)
128 | {
129 | $entity = Entity::factory()->create([
130 | 'approval_status' => $this->statuses[$i],
131 | 'approval_at' => (new Entity())->freshTimestamp()
132 | ]);
133 |
134 | $beforeEvent = $this->beforeEvents[$i];
135 | $afterEvent = $this->afterEvents[$i];
136 |
137 | $mock = $this->getMockBuilder('stdClass')
138 | ->addMethods([
139 | 'beforeListener',
140 | 'afterListener',
141 | 'approvalChangedListener'
142 | ])->getMock();
143 |
144 | $mock->expects($this->never())->method('beforeListener');
145 | $mock->expects($this->never())->method('afterListener');
146 | $mock->expects($this->never())->method('approvalChangedListener');
147 |
148 | Entity::$beforeEvent([$mock, 'beforeListener']);
149 | Entity::$afterEvent([$mock, 'afterListener']);
150 | Entity::approvalChanged([$mock, 'approvalChangedListener']);
151 |
152 | $entity->{$this->actions[$i]}();
153 | }
154 | }
155 |
156 | /**
157 | * @test
158 | */
159 | public function it_supports_observers()
160 | {
161 | $observerMock = $this->getMockBuilder('stdClass')
162 | ->addMethods($events = array_merge($this->beforeEvents, $this->afterEvents))
163 | ->getMock();
164 |
165 | foreach ($events as $event) {
166 | $observerMock->expects($this->once())->method($event);
167 | }
168 |
169 | app()->singleton(get_class($observerMock), function () use ($observerMock) {
170 | return $observerMock;
171 | });
172 |
173 | Entity::observe($observerMock);
174 |
175 | $entity = Entity::factory()->create();
176 |
177 | foreach ($this->actions as $action) {
178 | $entity->$action();
179 | }
180 | }
181 | }
--------------------------------------------------------------------------------
/tests/ApprovalScopeTest.php:
--------------------------------------------------------------------------------
1 | createOneEntityFromEachStatus();
18 |
19 | $entities = Entity::all();
20 |
21 | $this->assertNotEmpty($entities);
22 |
23 | foreach ($entities as $entity) {
24 | $this->assertEquals(
25 | ApprovalStatuses::APPROVED,
26 | $entity->approval_status
27 | );
28 | }
29 | }
30 |
31 | /**
32 | * @test
33 | */
34 | public function it_can_retrieve_all()
35 | {
36 | $this->createOneEntityFromEachStatus();
37 |
38 | $entities = Entity::anyApprovalStatus()->get();
39 |
40 | $totalCount = Entity::withoutGlobalScope(new ApprovalScope())->count();
41 |
42 | $this->assertCount($totalCount, $entities);
43 | }
44 |
45 | /** @test */
46 | public function it_can_be_disabled_on_the_model()
47 | {
48 | $this->createOneEntityFromEachStatus();
49 |
50 | $totalCount = Entity::withoutGlobalScope(new ApprovalScope())->count();
51 |
52 | $entityWithApprovalScopeDisabled = new class extends Entity {
53 | protected $table = 'entities';
54 |
55 | public $approvalScopeDisabled = true;
56 | };
57 |
58 | $entities = $entityWithApprovalScopeDisabled->newQuery()->get();
59 |
60 | $this->assertEquals($totalCount, count($entities));
61 | }
62 |
63 | /**
64 | * @test
65 | */
66 | public function it_can_retrieve_only_pending()
67 | {
68 | $this->createOneEntityFromEachStatus();
69 |
70 | $entities = Entity::onlyPending()->get();
71 |
72 | $this->assertNotEmpty($entities);
73 |
74 | foreach ($entities as $entity) {
75 | $this->assertEquals(
76 | ApprovalStatuses::PENDING,
77 | $entity->approval_status
78 | );
79 | }
80 | }
81 |
82 | /**
83 | * @test
84 | */
85 | public function it_can_retrieve_only_rejected()
86 | {
87 | $this->createOneEntityFromEachStatus();
88 |
89 | $entities = Entity::onlyRejected()->get();
90 |
91 | $this->assertNotEmpty($entities);
92 |
93 | foreach ($entities as $entity) {
94 | $this->assertEquals(
95 | ApprovalStatuses::REJECTED,
96 | $entity->approval_status
97 | );
98 | }
99 | }
100 |
101 | /**
102 | * @test
103 | */
104 | public function it_can_retrieve_only_approved()
105 | {
106 | $this->createOneEntityFromEachStatus();
107 |
108 | $entities = Entity::onlyApproved()->get();
109 |
110 | $this->assertNotEmpty($entities);
111 |
112 | foreach ($entities as $entity) {
113 | $this->assertEquals(
114 | ApprovalStatuses::APPROVED,
115 | $entity->approval_status
116 | );
117 | }
118 | }
119 |
120 | /**
121 | * @test
122 | */
123 | public function it_can_approve_entities()
124 | {
125 | $this->createOneEntityFromEachStatus();
126 |
127 | Entity::query()->approve();
128 |
129 | $entities = Entity::withoutGlobalScope(new ApprovalScope())->get();
130 |
131 | foreach ($entities as $entity) {
132 | $this->assertEquals(ApprovalStatuses::APPROVED, $entity->approval_status);
133 | }
134 | }
135 |
136 | /**
137 | * @test
138 | */
139 | public function it_can_reject_entities()
140 | {
141 | $this->createOneEntityFromEachStatus();
142 |
143 | Entity::query()->reject();
144 |
145 | $entities = Entity::withoutGlobalScope(new ApprovalScope())->get();
146 |
147 | foreach ($entities as $entity) {
148 | $this->assertEquals(ApprovalStatuses::REJECTED, $entity->approval_status);
149 | }
150 | }
151 |
152 | /**
153 | * @test
154 | */
155 | public function it_can_suspend_entities()
156 | {
157 | $this->createOneEntityFromEachStatus();
158 |
159 | Entity::query()->suspend();
160 |
161 | $entities = Entity::withoutGlobalScope(new ApprovalScope())->get();
162 |
163 | foreach ($entities as $entity) {
164 | $this->assertEquals(ApprovalStatuses::PENDING, $entity->approval_status);
165 | }
166 | }
167 |
168 | /**
169 | * @test
170 | */
171 | public function it_refreshes_approval_at_on_status_update()
172 | {
173 | foreach ($this->approvalActions as $action) {
174 | $entityId = Entity::factory()->create()->id;
175 |
176 | $timestampString = (new Entity())->freshTimestampString();
177 |
178 | Entity::whereId($entityId)->{$action}();
179 |
180 | $this->assertDatabaseHas('entities', [
181 | 'id' => $entityId,
182 | 'approval_at' => $timestampString
183 | ]);
184 | }
185 | }
186 |
187 | /**
188 | * @test
189 | */
190 | public function it_does_not_refresh_updated_at_on_status_update()
191 | {
192 |
193 | foreach ($this->approvalActions as $action) {
194 | $timestampString =
195 | (New Entity())->fromDateTime(Carbon::now()->subHour());
196 |
197 | $entityId = Entity::factory()->create([
198 | 'updated_at' => $timestampString
199 | ])->id;
200 |
201 | Entity::whereId($entityId)->{$action}();
202 |
203 | $this->assertDatabaseHas('entities', [
204 | 'id' => $entityId,
205 | 'updated_at' => $timestampString
206 | ]);
207 | }
208 | }
209 |
210 | /**
211 | * @test
212 | */
213 | public function it_returns_number_of_updated_entities_on_status_update()
214 | {
215 | Entity::factory(3)->create();
216 |
217 | foreach ($this->approvalActions as $action) {
218 | $this->assertEquals(1, Entity::whereId(1)->{$action}());
219 | $this->assertEquals(3, Entity::query()->{$action}());
220 | $this->assertEquals(0, Entity::whereId(0)->{$action}());
221 | }
222 | }
223 |
224 | protected function createOneEntityFromEachStatus()
225 | {
226 | Entity::factory()->suspended()->create();
227 |
228 | Entity::factory()->approved()->create();
229 |
230 | Entity::factory()->rejected()->create();
231 | }
232 | }
233 |
--------------------------------------------------------------------------------
/tests/ApprovableTest.php:
--------------------------------------------------------------------------------
1 | create();
18 |
19 | $this->assertArrayHasKey('approval_status', $entity->getAttributes());
20 |
21 | $this->assertEquals(ApprovalStatuses::PENDING, $entity->approval_status);
22 |
23 | $this->assertDatabaseHas('entities', [
24 | 'id' => $entity->id,
25 | 'approval_status' => ApprovalStatuses::PENDING
26 | ]);
27 | }
28 |
29 | /**
30 | * @test
31 | */
32 | public function its_approval_status_default_can_be_overridden()
33 | {
34 | $entity = Entity::factory()->create([
35 | 'approval_status' => ApprovalStatuses::APPROVED
36 | ]);
37 |
38 | $this->assertEquals(ApprovalStatuses::APPROVED, $entity->approval_status);
39 |
40 | $this->assertDatabaseHas('entities', [
41 | 'id' => $entity->id,
42 | 'approval_status' => ApprovalStatuses::APPROVED
43 | ]);
44 | }
45 |
46 | /**
47 | * @test
48 | */
49 | public function it_has_default_for_approval_status_column()
50 | {
51 | $entity = new Entity();
52 |
53 | $this->assertEquals('approval_status', $entity->getApprovalStatusColumn());
54 | }
55 |
56 | /**
57 | * @test
58 | */
59 | public function it_can_detect_custom_approval_status_column()
60 | {
61 | $entity = new EntityWithCustomColumns();
62 |
63 | $this->assertEquals(
64 | EntityWithCustomColumns::APPROVAL_STATUS,
65 | $entity->getApprovalStatusColumn()
66 | );
67 | }
68 |
69 | /**
70 | * @test
71 | */
72 | public function it_has_default_for_approval_at_column()
73 | {
74 | $entity = new Entity();
75 |
76 | $this->assertEquals('approval_at', $entity->getApprovalAtColumn());
77 | }
78 |
79 | /**
80 | * @test
81 | */
82 | public function it_can_detect_custom_approval_at_column()
83 | {
84 | $entity = new EntityWithCustomColumns();
85 |
86 | $this->assertEquals(
87 | EntityWithCustomColumns::APPROVAL_AT,
88 | $entity->getApprovalAtColumn()
89 | );
90 | }
91 |
92 | /**
93 | * @test
94 | */
95 | public function it_can_approve_the_entity()
96 | {
97 | $entity = Entity::factory()->create();
98 |
99 | $entity->approve();
100 |
101 | $this->assertEquals(ApprovalStatuses::APPROVED, $entity->approval_status);
102 |
103 | $this->assertDatabaseHas('entities', [
104 | 'id' => $entity->id,
105 | 'approval_status' => ApprovalStatuses::APPROVED
106 | ]);
107 | }
108 |
109 | /**
110 | * @test
111 | */
112 | public function it_can_reject_the_entity()
113 | {
114 | $entity = Entity::factory()->create();
115 |
116 | $entity->reject();
117 |
118 | $this->assertEquals(ApprovalStatuses::REJECTED, $entity->approval_status);
119 |
120 | $this->assertDatabaseHas('entities', [
121 | 'id' => $entity->id,
122 | 'approval_status' => ApprovalStatuses::REJECTED
123 | ]);
124 | }
125 |
126 | /**
127 | * @test
128 | */
129 | public function it_can_suspend_the_entity()
130 | {
131 | $entity = Entity::factory()->create([
132 | 'approval_status' => ApprovalStatuses::APPROVED
133 | ]);
134 |
135 | $entity->suspend();
136 |
137 | $this->assertEquals(ApprovalStatuses::PENDING, $entity->approval_status);
138 |
139 | $this->assertDatabaseHas('entities', [
140 | 'id' => $entity->id,
141 | 'approval_status' => ApprovalStatuses::PENDING
142 | ]);
143 | }
144 |
145 | /**
146 | * @test
147 | */
148 | public function it_refreshes_the_entity_approval_at_on_status_update()
149 | {
150 | $entity = Entity::factory()->create();
151 |
152 | foreach ($this->approvalActions as $action) {
153 | $time = (new Entity())->freshTimestamp();
154 |
155 | $entity->{$action}();
156 |
157 | $this->assertEquals($time->timestamp, $entity->approval_at->timestamp);
158 |
159 | $this->assertDatabaseHas('entities', [
160 | 'id' => $entity->id,
161 | 'approval_at' => $entity->fromDateTime($time)
162 | ]);
163 |
164 | $entity->newQuery()->where('id', $entity->id)->update([
165 | 'approval_at' => $time->subHour()
166 | ]);
167 | }
168 | }
169 |
170 | /** @test */
171 | public function it_does_not_refresh_the_entity_updated_at()
172 | {
173 | $entity = Entity::factory()->create([
174 | 'updated_at' => $time = (new Entity())->freshTimestamp()->subHour(1)
175 | ]);
176 |
177 | foreach ($this->approvalActions as $action) {
178 | $entity->$action();
179 |
180 | $this->assertEquals($time->timestamp, $entity->updated_at->timestamp);
181 |
182 | $this->assertDatabaseHas('entities', [
183 | 'id' => $entity->id,
184 | 'updated_at' => $entity->fromDateTime($time)
185 | ]);
186 | }
187 | }
188 |
189 |
190 | /**
191 | * @test
192 | */
193 | public function it_returns_true_when_updates_status()
194 | {
195 | $entity = Entity::factory()->create();
196 |
197 | foreach ($this->approvalActions as $action) {
198 | $this->assertTrue($entity->{$action}());
199 | }
200 | }
201 |
202 | /**
203 | * @test
204 | */
205 | public function it_refuses_to_update_status_when_not_exists()
206 | {
207 | $entity = Entity::factory()->make();
208 |
209 | foreach ($this->approvalActions as $action) {
210 | $this->assertNull($entity->{$action}());
211 |
212 | $this->assertNull($entity->approval_at);
213 | }
214 | }
215 |
216 | /**
217 | * @test
218 | **/
219 | public function it_rejects_the_duplicate_approvals()
220 | {
221 | $statuses = [
222 | ApprovalStatuses::APPROVED,
223 | ApprovalStatuses::PENDING,
224 | ApprovalStatuses::REJECTED
225 | ];
226 |
227 | $actions = [
228 | 'approve',
229 | 'suspend',
230 | 'reject'
231 | ];
232 |
233 | foreach(range(0, 2) as $i)
234 | {
235 | $entity = Entity::factory()->create([
236 | 'approval_status' => $statuses[$i],
237 | 'approval_at' => now()->subHour(1),
238 | ]);
239 |
240 | $return = $entity->{$actions[$i]}();
241 |
242 | $this->assertNotEquals(now()->timestamp, $entity->approval_at->timestamp);
243 | }
244 | $this->assertFalse($return);
245 |
246 | }
247 |
248 | /**
249 | * @test
250 | */
251 | public function it_can_check_if_it_is_pending()
252 | {
253 | $pendingEntity = Entity::factory()->create();
254 | $approvedEntity = Entity::factory()->create([
255 | 'approval_status' => ApprovalStatuses::APPROVED
256 | ]);
257 | $rejectedEntity = Entity::factory()->create([
258 | 'approval_status' => ApprovalStatuses::REJECTED
259 | ]);
260 |
261 | $this->assertTrue($pendingEntity->isPending());
262 | $this->assertFalse($approvedEntity->isPending());
263 | $this->assertFalse($rejectedEntity->isPending());
264 | }
265 |
266 | /**
267 | * @test
268 | */
269 | public function it_can_check_if_it_is_approved()
270 | {
271 | $pendingEntity = Entity::factory()->create();
272 | $approvedEntity = Entity::factory()->create([
273 | 'approval_status' => ApprovalStatuses::APPROVED
274 | ]);
275 | $rejectedEntity = Entity::factory()->create([
276 | 'approval_status' => ApprovalStatuses::REJECTED
277 | ]);
278 |
279 | $this->assertFalse($pendingEntity->isApproved());
280 | $this->assertTrue($approvedEntity->isApproved());
281 | $this->assertFalse($rejectedEntity->isApproved());
282 | }
283 |
284 | /**
285 | * @test
286 | */
287 | public function it_can_check_if_it_is_rejected()
288 | {
289 | $pendingEntity = Entity::factory()->create();
290 | $approvedEntity = Entity::factory()->create([
291 | 'approval_status' => ApprovalStatuses::APPROVED
292 | ]);
293 | $rejectedEntity = Entity::factory()->create([
294 | 'approval_status' => ApprovalStatuses::REJECTED
295 | ]);
296 |
297 | $this->assertFalse($pendingEntity->isRejected());
298 | $this->assertFalse($approvedEntity->isRejected());
299 | $this->assertTrue($rejectedEntity->isRejected());
300 | }
301 |
302 | /**
303 | * @test
304 | */
305 | public function it_refuses_to_check_status_when_not_exists()
306 | {
307 | $entity = Entity::factory()->make();
308 |
309 | foreach ($this->approvalChecks as $check) {
310 | $this->assertNull($entity->{$check}());
311 | }
312 | }
313 | }
314 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 
2 |
3 | # Eloquent Approval
4 |
5 | Approval process for Laravel's Eloquent models.
6 |
7 | 
8 |
9 | ## Why we need content approval in our apps
10 |
11 | Unless you're comfortable with unacceptable content, spam and any other
12 | violations that may appear in what the users post, you need to include some
13 | sort of content approval in your app.
14 |
15 | ## Why approval process with three states
16 |
17 | Although it's possible to approve a model by using a boolean field but a field
18 | that has three possible values: pending, approved and rejected gives us more
19 | power. It differentiates between the models waiting for the decision and the
20 | rejected ones and also makes it clear for the user if their content gets rejected.
21 |
22 | ## How it works
23 |
24 | After the setup, when new entities are being created, they are marked as
25 | _pending_. Then their status can be changed to _approved_ or _rejected_.
26 |
27 | Also, when an update occurs that modifies attributes that require approval the
28 | entity becomes _suspended_ again.
29 |
30 | By default the approval scope is applied on every query and filters out the
31 | _pending_ and _rejected_ entities, so only _approved_ entities are included.
32 | You can include the entities that aren't _approved_ by explicitly specifying it.
33 |
34 | ## Install
35 |
36 | ```sh
37 | $ composer require mtvs/eloquent-approval
38 | ```
39 |
40 | ## Setup
41 |
42 | ### Registering the service provider
43 |
44 | By default the service provider is registered automatically by Laravel package
45 | discovery otherwise you need to register it in your `config\app.php`
46 |
47 | ```php
48 | Mtvs\EloquentApproval\ApprovalServiceProvider::class
49 | ```
50 | ### Database
51 |
52 | The following method adds two columns to the schema, one to store
53 | the _approval status_ named `approval_status` and another to store the _timestamp_ at which the
54 | last status update is occurred named `approval_at`.
55 |
56 | ```php
57 | $table->approvals()
58 | ```
59 |
60 | You can change the default column names but then you need to specify them on the model too.
61 |
62 | ### Model
63 |
64 | Add `Approvable` trait to the model
65 |
66 | ```php
67 | use Illuminate\Database\Eloquent\Model;
68 | use Mtvs\EloquentApproval\Approvable;
69 |
70 | class Entity extends Model
71 | {
72 | use Approvable;
73 | }
74 | ```
75 |
76 | If you want to change the default column names you need to specify them
77 | by adding class constants to your model
78 |
79 | ```php
80 | use Illuminate\Database\Eloquent\Model;
81 | use Mtvs\EloquentApproval\Approvable;
82 |
83 | class Entity extends Model
84 | {
85 | use Approvable;
86 |
87 | const APPROVAL_STATUS = 'custom_approval_status';
88 | const APPROVAL_AT = 'custom_approval_at';
89 | }
90 | ```
91 |
92 | > Add `approval_at` to the model `$dates` list to get `Carbon` instances when accessing it.
93 |
94 | #### Approval Required Attributes
95 |
96 | When an update occurs that modifies attributes that require
97 | approval, the entity becomes _suspended_ again.
98 |
99 | ```php
100 | $entity->update($attributes); // an update with approval required modification
101 |
102 | $entity->isPending(); // true
103 | ```
104 |
105 | > Note that this happens only when you perform the _update_ on `Model` object
106 | itself not by using a query `Builder` instance.
107 |
108 | By default all attributes require approval.
109 |
110 | ```php
111 | /**
112 | * @return array
113 | */
114 | public function approvalRequired()
115 | {
116 | return ['*'];
117 | }
118 |
119 | /**
120 | * @return array
121 | */
122 | public function approvalNotRequired()
123 | {
124 | return [];
125 | }
126 | ```
127 |
128 | You can override them to have a custom set of approval required attributes.
129 |
130 | They work like `$fillable` and `$guarded` in the Eloquent. `approvalRequired()` returns
131 | the _black list_ while `approvalNotRequired()` returns the _white list_.
132 |
133 | ## Usage
134 |
135 | Newly created entities are marked as _pending_ and by default excluded from
136 | queries on the model.
137 |
138 | ```php
139 | Entity::create(); // #1 pending
140 |
141 | Entity::all(); // []
142 |
143 | Entity::find(1); // null
144 | ```
145 |
146 | ### Including all the entities
147 |
148 | ```php
149 | Entity::anyApprovalStatus()->get(); // retrieving all
150 |
151 | Entity::anyApprovalStatus()->find(1); // retrieving one
152 |
153 | Entity::anyApprovalStatus()->delete(); // deleting all
154 | ```
155 |
156 | If you want to disable the approval scope totally on every query, you can set
157 | the `approvalScopeDisabled` on the model.
158 |
159 | ```php
160 | use Illuminate\Database\Eloquent\Model;
161 | use Mtvs\EloquentApproval\Approvable;
162 |
163 | class Entity extends Model
164 | {
165 | use Approvable;
166 |
167 | public $approvalScopeDisabled = true;
168 | }
169 | ```
170 |
171 | ### Limiting to only a specific status
172 |
173 | ```php
174 | Entity::onlyPending()->get(); // retrieving only pending entities
175 | Entity::onlyRejected()->get(); // retrieving only rejected entities
176 | Entity::onlyApproved()->get(); // retrieving only approved entities
177 | ```
178 |
179 | ### Updating the status
180 |
181 | #### On model objects
182 |
183 | You can update the status of an entity by using provided methods on the `Model`
184 | object.
185 |
186 | ```php
187 | $entity->approve(); // returns bool if the entity exists otherwise null
188 | $entity->reject(); // returns bool if the entity exists otherwise null
189 | $entity->suspend(); // returns bool if the entity exists otherwise null
190 | ```
191 |
192 | #### On `Builder` objects
193 |
194 | You can update the status of more than one entity by using provided methods on `Builder`
195 | objects.
196 |
197 | ```php
198 | Entity::whereIn('id', $updateIds)->approve(); // returns number of updated
199 | Entity::whereIn('id', $updateIds)->reject(); // returns number of updated
200 | Entity::whereIn('id', $updateIds)->suspend(); // returns number of updated
201 | ```
202 |
203 | #### Approval Timestamp
204 |
205 | When you change the approval status of an entity its `approval_at` column updates.
206 | Before the first approval action on an entity its`approval_at` is `null`.
207 |
208 | ### Check the status of an entity
209 |
210 | You can check the status of an entity using provided methods on `Model` objects.
211 |
212 | ```php
213 | $entity->isApproved(); // returns bool if entity exists otherwise null
214 | $entity->isRejected(); // returns bool if entity exists otherwise null
215 | $entity->isPending(); // returns bool if entity exists otherwise null
216 | ```
217 |
218 | ### Approval Events
219 |
220 | There are some model events that are dispatched before and after each approval action.
221 |
222 | | Action | Before | After |
223 | |---------|------------|-----------|
224 | | approve | approving | approved |
225 | | suspend | suspending | suspended |
226 | | reject | rejecting | rejected |
227 |
228 | Also, there is a general event named `approvalChanged` that is dispatched whenever
229 | the approval status is changed regardless of the actual status.
230 |
231 | You can hook to them by calling the provided `static` methods, which are named
232 | after them, and passing your callbacks. Or by registring observers with methods
233 | with the same names.
234 |
235 | ```php
236 | use Illuminate\Database\Eloquent\Model;
237 | use Mtvs\EloquentApproval\Approvable;
238 |
239 | class Entity extends Model
240 | {
241 | use Approvable;
242 |
243 | protected static function boot()
244 | {
245 | parent::boot();
246 |
247 | static::approving(function ($entity) {
248 | // You can halt the process by returning false
249 | });
250 |
251 | static::approved(function ($entity) {
252 | // $entity has been approved
253 | });
254 |
255 | // or:
256 |
257 | static::observe(ApprovalObserver::class);
258 | }
259 | }
260 |
261 | class ApprovalObserver
262 | {
263 | public function approving($entity)
264 | {
265 | // You can halt the process by returning false
266 | }
267 |
268 | public function approved($entity)
269 | {
270 | // $entity has been approved
271 | }
272 | }
273 | ```
274 |
275 | [Eloquent model events](https://laravel.com/docs/eloquent#events) can also be mapped to your application event classes.
276 |
277 | ## Duplicate Approvals
278 |
279 | Trying to set the approval status to the current value is ignored, i.e.:
280 | no event will be dispatched and the approval timestamp won't be updated.
281 | In this case the approval method returns `false`.
282 |
283 | ## The Model Factory
284 |
285 | Import the `ApprovalFactoryStates` to be able to use the approval states
286 | when using the model factory.
287 |
288 | ```php
289 | namespace Database\Factories;
290 |
291 | use Illuminate\Database\Eloquent\Factories\Factory;
292 | use Mtvs\EloquentApproval\ApprovalFactoryStates;
293 |
294 | class EntityFactory extends Factory
295 | {
296 | use ApprovalFactoryStates;
297 |
298 | public function definition()
299 | {
300 | //
301 | }
302 | }
303 | ```
304 | ```php
305 | Entity::factory()->approved()->create();
306 | Entity::factory()->rejected()->create();
307 | Entity::factory()->suspended()->create();
308 | ```
309 | ## Handling Approval HTTP Requests
310 |
311 | You can import the `HandlesApproval` in a controller to perform the approval
312 | operations on a model. It contains an abstract method which has to be implemented
313 | to return the model's class name.
314 |
315 | ```php
316 | namespace App\Http\Controllers\Admin;
317 |
318 | use App\Http\Controllers\Controller;
319 | use App\Models\Entity;
320 | use Mtvs\EloquentApproval\HandlesApproval;
321 |
322 | class EntitiesController extends Controller
323 | {
324 | use HandlesApproval;
325 |
326 | protected function model()
327 | {
328 | return Entity::class;
329 | }
330 | }
331 |
332 | ```
333 |
334 | The trait's `performApproval()` does the approval and the request should be
335 | routed to this method. It has the `key` and `request` parameters which are
336 | passed to it by the router.
337 |
338 | When do the routing, don't forget to apply the `auth` and `can` middlewares for
339 | authentication and authourization.
340 |
341 | ```php
342 | Route::post(
343 | 'admin/enitiy/{key}/approval',
344 | 'Admin\EntitiesController@performApproval'
345 | )->middleware(['auth', 'can:perform-approval'])
346 | ```
347 |
348 | The request must have a `approval_status` key with
349 | one of the possible values: `approved`, `pending`, `rejected`.
350 |
351 | ## Frontend Components
352 |
353 | There are also some UI components here written for Vue.js and Bootstrap that
354 | you can use. First install them using the `approval:ui` artisan command and
355 | then register them in your app.js file.
356 |
357 | ### Approval Buttons Component
358 |
359 | Call `