Add: Store Course

This commit is contained in:
philipp lang 2021-11-19 22:58:27 +01:00
parent fbd4640396
commit fb95c9a135
21 changed files with 379 additions and 40 deletions

View File

@ -0,0 +1,19 @@
<?php
namespace App\Course\Controllers;
use App\Course\Requests\StoreRequest;
use App\Http\Controllers\Controller;
use App\Member\Member;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class CourseController extends Controller
{
public function store(Member $member, StoreRequest $request): RedirectResponse
{
$request->persist($member);
return redirect()->route('member.index');
}
}

View File

@ -1,6 +1,6 @@
<?php
namespace App;
namespace App\Course\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@ -10,5 +10,5 @@ class Course extends Model
use HasFactory;
public $timestamps = false;
public $guarded = [];
public $fillable = ['name', 'nami_id'];
}

View File

@ -0,0 +1,51 @@
<?php
namespace App\Course\Requests;
use App\Course\Models\Course;
use App\Member\Member;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Arr;
class StoreRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*
* @return bool
*/
public function authorize()
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, string>
*/
public function rules()
{
return [
'organizer' => 'required|max:255',
'event_name' => 'required|max:255',
'completed_at' => 'required|date',
'course_id' => 'required|exists:courses,id',
];
}
public function persist(Member $member): void
{
$course = Course::findOrFail($this->input('course_id'));
$payload = array_merge(
$this->only(['event_name', 'completed_at', 'course_id', 'organizer']),
['course_id' => $course->nami_id],
);
$namiId = auth()->user()->api()->createCourse($member->nami_id, $payload);
$member->courses()->attach(
$course,
$this->safe()->collect()->put('nami_id', $namiId)->except(['course_id'])->toArray(),
);
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace App\Course\Resources;
use Carbon\Carbon;
use Illuminate\Http\Resources\Json\JsonResource;
class CourseResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @param \Illuminate\Http\Request $request
* @return array|\Illuminate\Contracts\Support\Arrayable|\JsonSerializable
*/
public function toArray($request)
{
return [
'id' => $this->id,
'organizer' => $this->pivot->organizer,
'event_name' => $this->pivot->event_name,
'completed_at_human' => Carbon::parse($this->pivot->completed_at)->format('d.m.Y'),
'completed_at' => $this->pivot->completed_at,
'course_name' => $this->name,
];
}
}

View File

@ -3,6 +3,7 @@
namespace App\Http\Views;
use App\Activity;
use App\Course\Models\Course;
use App\Member\Member;
use App\Member\MemberResource;
use App\Payment\ActionFactory;
@ -19,18 +20,19 @@ class MemberView {
return [
'data' => MemberResource::collection(Member::select('*')
->filter($filter)->search($request->query('search', null))
->with('billKind')->with('payments')->with('memberships')
->with('billKind')->with('payments')->with('memberships')->with('courses')
->withSubscriptionName()->withIsConfirmed()->withPendingPayment()->withAgeGroup()
->orderByRaw('lastname, firstname')
->paginate(15)
),
'filterActivities' => Activity::where('is_filterable', true)->get()->pluck('name', 'id'),
'filterSubactivities' => Subactivity::where('is_filterable', true)->get()->pluck('name', 'id'),
'filterActivities' => Activity::where('is_filterable', true)->pluck('name', 'id'),
'filterSubactivities' => Subactivity::where('is_filterable', true)->pluck('name', 'id'),
'toolbar' => [ ['href' => route('member.index'), 'label' => 'Zurück', 'color' => 'primary', 'icon' => 'plus'] ],
'paymentDefaults' => ['nr' => date('Y')],
'subscriptions' => Subscription::get()->pluck('name', 'id'),
'statuses' => Status::get()->pluck('name', 'id'),
'subscriptions' => Subscription::pluck('name', 'id'),
'statuses' => Status::pluck('name', 'id'),
'activities' => $activities->pluck('name', 'id'),
'courses' => Course::pluck('name', 'id'),
'subactivities' => $activities->map(function(Activity $activity) {
return ['subactivities' => $activity->subactivities->pluck('name', 'id'), 'id' => $activity->id];
})->pluck('subactivities', 'id'),

View File

@ -2,7 +2,7 @@
namespace App\Initialize;
use App\Course;
use App\Course\Models\Course;
use Aweos\Agnoster\Progress\Progress;
use Zoomyboy\LaravelNami\Api;
use Zoomyboy\LaravelNami\NamiUser;

View File

@ -5,7 +5,7 @@ namespace App\Initialize;
use App\Activity;
use App\Confession;
use App\Country;
use App\Course;
use App\Course\Models\Course;
use App\Fee;
use App\Gender;
use App\Group;

View File

@ -6,7 +6,7 @@ use App\Activity;
use App\Bill\BillKind;
use App\Confession;
use App\Country;
use App\Course;
use App\Course\Models\Course;
use App\Group;
use App\Nationality;
use App\Payment\Payment;

View File

@ -2,6 +2,7 @@
namespace App\Member;
use App\Course\Resources\CourseResource;
use App\Membership\MembershipResource;
use App\Payment\PaymentResource;
use Illuminate\Http\Resources\Json\JsonResource;
@ -55,6 +56,7 @@ class MemberResource extends JsonResource
'first_activity_id' => $this->first_activity_id,
'first_subactivity_id' => $this->first_subactivity_id,
'age_group_icon' => $this->age_group_icon,
'courses' => CourseResource::collection($this->whenLoaded('courses')),
];
}
}

View File

@ -0,0 +1,37 @@
<?php
namespace Database\Factories\Course\Models;
use App\Course\Models\Course;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Course>
*/
class CourseFactory extends Factory
{
/**
* The name of the factory's corresponding model.
*
* @var string
*/
public $model = Course::class;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition()
{
return [
'name' => $this->faker->words(5, true),
];
}
public function inNami(int $namiId): self
{
return $this->state(['nami_id' => $namiId]);
}
}

View File

@ -1,20 +0,0 @@
<?php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
class CourseFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array
*/
public function definition()
{
return [
//
];
}
}

View File

@ -17,12 +17,12 @@ class GroupFactory extends Factory
/**
* Define the model's default state.
*
* @return array
* @return array<string, mixed>
*/
public function definition()
{
return [
'name' => $this->faker->randomElement(['Normaler Beitrag', 'Familienermäßigt']),
'name' => $this->faker->words(5, true),
'nami_id' => $this->faker->randomNumber(),
];
}

View File

@ -11,6 +11,9 @@ use App\Payment\Payment;
use App\Payment\Subscription;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Member>
*/
class MemberFactory extends Factory
{
/**
@ -23,7 +26,7 @@ class MemberFactory extends Factory
/**
* Define the model's default state.
*
* @return array
* @return array<string, mixed>
*/
public function definition()
{
@ -34,7 +37,7 @@ class MemberFactory extends Factory
'joined_at' => $this->faker->dateTimeBetween('-30 years'),
'send_newspaper' => $this->faker->boolean,
'address' => $this->faker->streetAddress,
'zip' => $this->faker->postCode,
'zip' => $this->faker->postcode,
'location' => $this->faker->city,
];
}
@ -61,6 +64,14 @@ class MemberFactory extends Factory
->for($subscription);
}
public function inNami(int $namiId): self
{
return $this->state(['nami_id' => $namiId]);
}
/**
* @param array<int, callable> $payments
*/
public function withPayments(array $payments): self
{
return $this->afterCreating(function (Member $model) use ($payments): void {

View File

@ -13,11 +13,11 @@ class CreateGeneralSettings extends SettingsMigration
{
$defaults = [
'diözese' => [
'modules' => [],
'modules' => ['courses'],
'single_view' => false,
],
'stamm' => [
'modules' => ['bill'],
'modules' => ['bill', 'courses'],
'single_view' => true,
]
];

View File

@ -0,0 +1 @@
<svg height="512" width="512" xmlns="http://www.w3.org/2000/svg"><path d="m501.991 128.354-241-85.031a14.992 14.992 0 0 0-9.981 0l-241 85.031a15.001 15.001 0 0 0-.034 28.279l241 85.677a14.991 14.991 0 0 0 10.048 0l241-85.677a15 15 0 0 0-.033-28.279zM475.973 328.574v-130.84l-30 10.665v120.175c-9.036 5.201-15.125 14.946-15.125 26.121 0 11.174 6.089 20.92 15.125 26.121v73.716c0 8.284 6.716 15 15 15s15-6.716 15-15v-73.715c9.036-5.2 15.125-14.947 15.125-26.121 0-11.175-6.088-20.921-15.125-26.122z"/><path d="M256 273.177c-5.149 0-10.22-.875-15.073-2.6l-135.483-48.165v66.008c0 16.149 16.847 29.806 50.073 40.59 28.961 9.4 64.647 14.577 100.483 14.577s71.521-5.177 100.483-14.577c33.226-10.784 50.073-24.441 50.073-40.59v-66.008l-135.482 48.165a44.896 44.896 0 0 1-15.074 2.6z"/></svg>

After

Width:  |  Height:  |  Size: 785 B

View File

@ -0,0 +1,95 @@
<template>
<div class="sidebar flex flex-col">
<sidebar-header :links="indexLinks" @close="$emit('close')" @create="mode = 'create'; single = {}" title="Ausbildungen"></sidebar-header>
<form v-if="single" class="p-6 grid gap-4 justify-start" @submit.prevent="submit">
<f-text id="nr" v-model="single.nr" label="Jahr" required></f-text>
<f-select id="subscription_id" :options="subscriptions" v-model="single.subscription_id" label="Beitrag" required></f-select>
<f-select id="status_id" :options="statuses" v-model="single.status_id" label="Status" required></f-select>
<button type="submit" class="btn btn-primary">Absenden</button>
</form>
<table v-else class="custom-table custom-table-light custom-table-sm text-sm flex-grow">
<thead>
<th>Baustein</th>
<th>Veranstaltung</th>
<th>Veranstalter</th>
<th>Datum</th>
<th></th>
</thead>
<tr v-for="course, index in value.courses">
<td v-text="course.course_name"></td>
<td v-text="course.event_name"></td>
<td v-text="course.organizer"></td>
<td v-text="course.completed_at_human"></td>
<td class="flex">
<a href="#" @click.prevent="single = course; mode = 'edit'" class="inline-flex btn btn-warning btn-sm"><sprite src="pencil"></sprite></a>
<inertia-link href="#" @click.prevent="remove(course)" class="inline-flex btn btn-danger btn-sm"><sprite src="trash"></sprite></inertia-link>
</td>
</tr>
</table>
</div>
</template>
<script>
import SidebarHeader from '../../components/SidebarHeader.vue';
export default {
data: function() {
return {
mode: null,
single: null,
indexLinks: [
{event: 'create', label: 'Neuer Kurs'}
]
};
},
props: {
value: {}
},
components: { SidebarHeader },
methods: {
remove(payment) {
this.$inertia.delete(`/member/${this.value.id}/payment/${payment.id}`);
},
accept(payment) {
this.$inertia.patch(`/member/${this.value.id}/payment/${payment.id}`, { ...payment, status_id: 3 });
},
openLink(link) {
if (link.disabled) {
return;
}
window.open(link.href);
},
submit() {
var _self = this;
this.mode === 'create'
? this.$inertia.post(`/member/${this.value.id}/payment`, this.single, {
onFinish() {
_self.single = null;
}
})
: this.$inertia.patch(`/member/${this.value.id}/payment/${this.single.id}`, this.single, {
onFinish() {
_self.single = null;
}
});
}
},
props: {
value: {},
subscriptions: {},
statuses: {},
}
};
</script>

View File

@ -53,6 +53,7 @@
<td class="flex">
<inertia-link :href="`/member/${member.id}/edit`" class="inline-flex btn btn-warning btn-sm"><sprite src="pencil"></sprite></inertia-link>
<a href="#" v-show="hasModule('bill')" @click.prevent="openSidebar(index, 'payment.index')" class="inline-flex btn btn-info btn-sm"><sprite src="money"></sprite></a>
<a href="#" v-show="hasModule('courses')" @click.prevent="openSidebar(index, 'courses.index')" class="inline-flex btn btn-info btn-sm"><sprite src="course"></sprite></a>
<a href="#" @click.prevent="openSidebar(index, 'membership.index')" class="inline-flex btn btn-info btn-sm"><sprite src="user"></sprite></a>
<inertia-link href="#" @click.prevent="remove(member)" class="inline-flex btn btn-danger btn-sm"><sprite src="trash"></sprite></inertia-link>
</td>
@ -67,6 +68,7 @@
<transition name="sidebar">
<payments v-if="single !== null && sidebar === 'payment.index'" @close="closeSidebar" :subscriptions="subscriptions" :statuses="statuses" v-model="data.data[single]"></payments>
<memberships v-if="single !== null && sidebar === 'membership.index'" @close="closeSidebar" :activities="activities" :subactivities="subactivities" v-model="data.data[single]"></memberships>
<courses v-if="single !== null && sidebar === 'courses.index'" @close="closeSidebar" :courses="courses" v-model="data.data[single]"></courses>
</transition>
</div>
</template>
@ -75,6 +77,7 @@
import App from '../../layouts/App';
import Payments from './Payments.vue';
import Memberships from './Memberships.vue';
import Courses from './Courses.vue';
import Filt from './Filt.vue';
import mergesQueryString from '../../mixins/mergesQueryString.js';
@ -91,7 +94,7 @@ export default {
mixins: [mergesQueryString],
components: { Memberships, Payments, Filt },
components: { Memberships, Payments, Filt, Courses },
methods: {
remove(member) {
@ -120,6 +123,7 @@ export default {
subactivities: {},
filterActivities: {},
filterSubactivities: {},
courses: {},
}
}
</script>

View File

@ -40,7 +40,7 @@ return [
'distinct' => ':attribute beinhaltet einen bereits vorhandenen Wert.',
'email' => ':attribute muss eine gültige E-Mail-Adresse sein.',
'ends_with' => ':attribute muss eine der folgenden Endungen aufweisen: :values',
'exists' => 'Der gewählte Wert für :attribute ist ungültig.',
'exists' => ':attribute ist nicht vorhanden.',
'file' => ':attribute muss eine Datei sein.',
'filled' => ':attribute muss ausgefüllt sein.',
'gt' => [
@ -95,7 +95,7 @@ return [
'password' => 'Das Passwort ist falsch.',
'present' => ':attribute muss vorhanden sein.',
'regex' => ':attribute Format ist ungültig.',
'required' => ':attribute muss ausgefüllt werden.',
'required' => ':attribute ist erforderlich.',
'required_if' => ':attribute muss ausgefüllt werden, wenn :other den Wert :value hat.',
'required_unless' => ':attribute muss ausgefüllt werden, wenn :other nicht den Wert :values hat.',
'required_with' => ':attribute muss ausgefüllt werden, wenn :values ausgefüllt wurde.',
@ -188,5 +188,9 @@ return [
'first_group_id' => 'Erste Untertätigkeit',
'first_activity_id' => 'Erste Tätigkeit',
'fee_id' => 'Beitragsart',
'course_id' => 'Baustein',
'completed_at' => 'Datum',
'event_name' => 'Veranstaltung',
'organizer' => 'Veranstalter',
],
];

View File

@ -1,5 +1,6 @@
<?php
use App\Course\Controllers\CourseController;
use App\Http\Controllers\HomeController;
use App\Initialize\InitializeController;
use App\Member\MemberConfirmController;
@ -30,4 +31,5 @@ Route::group(['middleware' => 'auth:web'], function (): void {
Route::get('/sendpayment/pdf', [SendpaymentController::class, 'send'])->name('sendpayment.pdf');
Route::apiResource('member.membership', MembershipController::class);
Route::resource('setting', SettingController::class);
Route::resource('member.course', CourseController::class);
});

View File

@ -0,0 +1,104 @@
<?php
namespace Tests\Feature\Course;
use App\Course\Models\Course;
use App\Member\Member;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use Tests\TestCase;
use Zoomyboy\LaravelNami\Backend\FakeBackend;
use Zoomyboy\LaravelNami\Fakes\CourseFake;
class StoreTest extends TestCase
{
use RefreshDatabase;
/**
* @return array<string, array{payload: array<string, mixed>, errors: array<string, mixed>}>
*/
public function validationDataProvider(): array
{
return [
'course_id_missing' => [
'payload' => ['course_id' => null],
'errors' => ['course_id' => 'Baustein ist erforderlich.'],
],
'course_id_invalid' => [
'payload' => ['course_id' => 999],
'errors' => ['course_id' => 'Baustein ist nicht vorhanden.'],
],
'completed_at_required' => [
'payload' => ['completed_at' => ''],
'errors' => ['completed_at' => 'Datum ist erforderlich.'],
],
'completed_at_not_date' => [
'payload' => ['completed_at' => '123'],
'errors' => ['completed_at' => 'Datum muss ein gültiges Datum sein.'],
],
'event_name_required' => [
'payload' => ['event_name' => ''],
'errors' => ['event_name' => 'Veranstaltung ist erforderlich.'],
],
'organizer' => [
'payload' => ['organizer' => ''],
'errors' => ['organizer' => 'Veranstalter ist erforderlich.'],
],
];
}
/**
* @param array<string, string> $payload
* @param array<string, string> $errors
* @dataProvider validationDataProvider
*/
public function testItValidatesInput(array $payload, array $errors): void
{
$this->login()->init();
$member = Member::factory()->defaults()->inNami(123)->createOne();
$course = Course::factory()->inNami(456)->createOne();
$response = $this->post("/member/{$member->id}/course", array_merge([
'course_id' => $course->id,
'completed_at' => '2021-01-02',
'event_name' => '::event::',
'organizer' => '::org::',
], $payload));
$response->assertSessionHasErrors($errors);
}
public function testItCreatesACourse(): void
{
$this->withoutExceptionHandling();
$this->login()->init();
$member = Member::factory()->defaults()->inNami(123)->createOne();
$course = Course::factory()->inNami(456)->createOne();
app(CourseFake::class)->createsSuccessful(123, 999);
$response = $this->post("/member/{$member->id}/course", [
'course_id' => $course->id,
'completed_at' => '2021-01-02',
'event_name' => '::event::',
'organizer' => '::org::',
]);
$response->assertRedirect("/member");
$this->assertDatabaseHas('course_member', [
'member_id' => $member->id,
'course_id' => $course->id,
'completed_at' => '2021-01-02',
'event_name' => '::event::',
'organizer' => '::org::',
'nami_id' => 999,
]);
app(CourseFake::class)->assertCreated(123, [
'bausteinId' => 456,
'veranstalter' => '::org::',
'vstgName' => '::event',
'vstgTag' => '2021-01-02T00:00:00',
]);
}
}

View File

@ -4,7 +4,7 @@ namespace Tests\Feature\Initialize;
use App\Activity;
use App\Country;
use App\Course;
use App\Course\Models\Course;
use App\Gender;
use App\Member\Member;
use App\Nationality;