Compare commits

...

12 Commits

Author SHA1 Message Date
philipp lang 1d575a8476 Update CHANGELOG
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/tag Build is failing Details
2025-12-02 01:20:48 +01:00
philipp lang 5117360c03 Add checkbox for destroying participant
continuous-integration/drone/push Build is failing Details
2025-12-02 01:15:24 +01:00
philipp lang 502ba20b6d Add cancelled member data
continuous-integration/drone/push Build is passing Details
2025-12-02 00:28:58 +01:00
philipp lang 56af6f4cbf Fix tests
continuous-integration/drone/push Build is passing Details
2025-12-01 23:53:48 +01:00
philipp lang ac88df2cad Lint CreateExcelDocumentAction 2025-12-01 23:53:48 +01:00
philipp lang adc78a65e7 Lint 2025-12-01 23:53:48 +01:00
philipp lang c7618b0545 Fix stubs 2025-12-01 23:53:48 +01:00
philipp lang e5c2599846 Fix tests 2025-12-01 23:53:48 +01:00
philipp lang 3a80e3bcee Fix participant scout index 2025-12-01 23:53:48 +01:00
philipp lang 4abdac75f6 Add ID to participant excel document 2025-12-01 23:53:48 +01:00
philipp lang 1e4361c709 Add cancelled participants to excel sheet 2025-12-01 23:53:48 +01:00
philipp lang 8924774ed0 Add Soft deletes to participants
Fix tests
2025-12-01 23:53:46 +01:00
18 changed files with 183 additions and 41 deletions

View File

@ -1,5 +1,10 @@
# Letzte Änderungen
### 1.12.23
- Veranstaltungs-Teilnehmer*innen können nun abgemeldet statt vollständig gelöscht werden.
- Beim Excel-Export wird eine Spalte "ID" angezeigt mit der ID des TNs
### 1.12.22
- Bei Mitgliedern wird nun auch die geschäftliche tel-nr aktualisiert

View File

@ -31,25 +31,27 @@ class CreateExcelDocumentAction
private function allSheet(Collection $participants): TableDocumentData
{
$document = TableDocumentData::from(['title' => 'Anmeldungen für ' . $this->form->name, 'sheets' => []]);
$headers = $this->form->getFields()->map(fn ($field) => $field->name)->toArray();
$headers = $this->form->getFields()->names()->push('Abgemeldet am')->prepend('ID')->toArray();
[$activeParticipants, $cancelledParticipants] = $participants->partition(fn ($participant) => $participant->cancelled_at === null);
$document->addSheet(SheetData::from([
'header' => $headers,
'data' => $participants
->map(fn ($participant) => $this->form->getFields()->map(fn ($field) => $participant->getFields()->find($field)->presentRaw())->toArray())
->toArray(),
'data' => $this->rowsFor($activeParticipants),
'name' => 'Alle',
]));
$document->addSheet(SheetData::from([
'header' => $headers,
'data' => $this->rowsFor($cancelledParticipants),
'name' => 'Abgemeldet',
]));
if ($this->form->export->groupBy) {
$groups = $participants->groupBy(fn ($participant) => $participant->getFields()->findByKey($this->form->export->groupBy)->presentRaw());
$groups = $activeParticipants->groupBy(fn ($participant) => $participant->getFields()->findByKey($this->form->export->groupBy)->presentRaw());
foreach ($groups as $name => $participants) {
foreach ($groups as $name => $groupedParticipants) {
$document->addSheet(SheetData::from([
'header' => $headers,
'data' => $participants
->map(fn ($participant) => $this->form->getFields()->map(fn ($field) => $participant->getFields()->find($field)->presentRaw())->toArray())
->toArray(),
'data' => $this->rowsFor($groupedParticipants),
'name' => $name,
]));
}
@ -64,6 +66,17 @@ class CreateExcelDocumentAction
return $document;
}
/**
* @param Collection<int, Participant> $participants
* @return array<int, array<string, mixed>>
*/
public function rowsFor(Collection $participants): array {
return $participants->map(fn ($participant) => $participant->getFields()->presentValues()
->put('Abgemeldet am', $participant->cancelled_at?->format('d.m.Y H:i:s') ?: '')
->prepend((string) $participant->id, 'ID')
)->toArray();
}
private function tempPath(): string
{
return sys_get_temp_dir() . '/' . str()->uuid()->toString();

View File

@ -20,7 +20,7 @@ class FormIndexAction
*/
public function handle(string $filter): LengthAwarePaginator
{
return FormFilterScope::fromRequest($filter)->getQuery()->query(fn ($query) => $query->withCount('participants'))->paginate(15);
return FormFilterScope::fromRequest($filter)->getQuery()->query(fn ($query) => $query->withCount(['participants' => fn ($q) => $q->whereNull('cancelled_at')]))->paginate(15);
}
public function asController(ActionRequest $request): Response

View File

@ -6,6 +6,7 @@ use App\Form\Models\Participant;
use App\Lib\JobMiddleware\JobChannels;
use App\Lib\JobMiddleware\WithJobState;
use App\Lib\Queue\TracksJob;
use Lorisleiva\Actions\ActionRequest;
use Lorisleiva\Actions\Concerns\AsAction;
class ParticipantDestroyAction
@ -13,14 +14,20 @@ class ParticipantDestroyAction
use AsAction;
use TracksJob;
public function handle(int $participantId): void
public function handle(int $participantId, bool $force): void
{
Participant::findOrFail($participantId)->delete();
$participant = Participant::findOrFail($participantId);
if ($force) {
$participant->delete();
} else {
$participant->update(['cancelled_at' => now()]);
}
}
public function asController(Participant $participant): void
public function asController(ActionRequest $request, Participant $participant): void
{
$this->startJob($participant->id);
$this->startJob($participant->id, $request->header('X-Force') === '1');
}
/**

View File

@ -18,10 +18,10 @@ class UpdateParticipantSearchIndexAction
$form->searchableUsing()->updateIndexSettings(
$form->participantsSearchableAs(),
[
'filterableAttributes' => [...$form->getFields()->filterables()->getKeys(), 'parent-id'],
'filterableAttributes' => [...$form->getFields()->filterables()->getKeys(), 'parent-id', 'cancelled_at'],
'searchableAttributes' => $form->getFields()->searchables()->getKeys(),
'sortableAttributes' => [...$form->getFields()->sortables()->getKeys(), 'id', 'created_at'],
'displayedAttributes' => [...$form->getFields()->filterables()->getKeys(), ...$form->getFields()->searchables()->getKeys(), 'id'],
'displayedAttributes' => [...$form->getFields()->filterables()->getKeys(), ...$form->getFields()->searchables()->getKeys(), 'id', 'cancelled_at'],
'pagination' => [
'maxTotalHits' => 1000000,
]

View File

@ -99,19 +99,19 @@ class FieldCollection extends Collection
}
/**
* @return array<int, string>
* @return Collection<int, string>
*/
public function names(): array
public function names(): Collection
{
return $this->map(fn ($field) => $field->name)->toArray();
return $this->map(fn ($field) => $field->name);
}
/**
* @return array<int, string>
* @return Collection<string, string>
*/
public function presentValues(): array
public function presentValues(): Collection
{
return $this->map(fn ($field) => $field->presentRaw())->toArray();
return $this->mapWithKeys(fn ($field) => [$field->name => $field->presentRaw()]);
}
public function hasSpecialType(SpecialType $specialType): bool

View File

@ -31,6 +31,7 @@ class Participant extends Model implements Preventable
public $casts = [
'data' => 'json',
'last_remembered_at' => 'datetime',
'cancelled_at' => 'datetime',
];
/**
@ -108,7 +109,12 @@ class Participant extends Model implements Preventable
/** @return array<string, mixed> */
public function toSearchableArray(): array
{
return [...$this->data, 'parent-id' => $this->parent_id, 'created_at' => $this->created_at->timestamp];
return [
...$this->data,
'parent-id' => $this->parent_id,
'created_at' => $this->created_at->timestamp,
'cancelled_at' => $this->cancelled_at
];
}
public function matchesCondition(Condition $condition): bool {

View File

@ -32,7 +32,8 @@ class ParticipantFilterScope extends ScoutFilter
public string $search = '',
public array $options = [],
public ?int $parent = null,
public ?Sorting $sort = null
public ?Sorting $sort = null,
public bool $showCancelled = false,
) {
}
@ -54,6 +55,12 @@ class ParticipantFilterScope extends ScoutFilter
$filter->push('parent-id IS NULL');
}
if ($this->showCancelled) {
$filter->push('cancelled_at IS NOT NULL');
} else {
$filter->push('cancelled_at IS NULL');
}
if ($this->parent !== null && $this->parent !== -1) {
$filter->push('parent-id = ' . $this->parent);
}

View File

@ -39,6 +39,10 @@ class ParticipantFactory extends Factory
return $this->state(['data' => $data]);
}
public function cancelled(): self {
return $this->state(['cancelled_at' => now()->subWeek()]);
}
public function nr(int $number): self
{
return $this->state(['member_id' => $number]);

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('participants', function (Blueprint $table) {
$table->datetime('cancelled_at')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('participants', function (Blueprint $table) {
$table->dropColumn('cancelled_at');
});
}
};

@ -1 +1 @@
Subproject commit 7304963370ff64fb5accf08da4864981cc424301
Subproject commit f7b04591830ebdeaddf76236e4cbc87a8b3eec8f

View File

@ -47,8 +47,10 @@ export function useApiIndex(firstUrl, siteName = null) {
single.value = null;
}
async function remove(model) {
await axios.delete(model.links.destroy);
async function remove(model, force = true) {
await axios.delete(model.links.destroy, {
headers: { 'X-Force': force ? '1' : '0' }
});
await reload();
}

View File

@ -11,9 +11,10 @@
<ui-popup v-if="assigning !== null" heading="Mitglied zuweisen" closeable @close="assigning = null">
<member-assign @assign="assign" />
</ui-popup>
<ui-popup v-if="deleting !== null" heading="Teilnehmer*in löschen?" @close="deleting = null">
<ui-popup v-if="deleting !== null" heading="Teilnehmer*in abmelden?" @close="deleting = null">
<div>
<p class="mt-4">Den*Die Teilnehmer*in löschen?</p>
<p class="mt-4">Den*Die Teilnehmer*in abmelden?</p>
<f-switch class="mt-2" v-model="deleting.force" name="force_delete" id="force_delete" label="löschen statt abmelden (permanent)" size="sm" />
<div class="grid grid-cols-2 gap-3 mt-6">
<a href="#" class="text-center btn btn-danger" @click.prevent="handleDelete">Mitglied loschen</a>
<a href="#" class="text-center btn btn-primary" @click.prevent="deleting = null">Abbrechen</a>
@ -29,6 +30,7 @@
</template>
<template #fields>
<f-switch id="show_cancelled" v-model="innerFilter.show_cancelled" label="Abgemeldete zeigen" size="sm" name="show_cancelled" />
<template v-for="(filter, index) in meta.filters">
<f-select v-if="filter.base_type === 'CheckboxField'"
:id="`filter-field-${index}`"
@ -95,7 +97,7 @@
</td>
<td>
<a v-tooltip="`Bearbeiten`" href="#" class="ml-2 inline-flex btn btn-warning btn-sm" @click.prevent="editReal(participant)"><ui-sprite src="pencil" /></a>
<a v-tooltip="`Löschen`" href="#" class="ml-2 inline-flex btn btn-danger btn-sm" @click.prevent="deleting = participant"><ui-sprite src="trash" /></a>
<a v-tooltip="`Abmelden`" href="#" class="ml-2 inline-flex btn btn-danger btn-sm" @click.prevent="deleting = {model: participant, force: false}"><ui-sprite src="trash" /></a>
</td>
</tr>
<template v-for="child in childrenOf(participant.id)" :key="child.id">
@ -114,7 +116,7 @@
</td>
<td>
<a v-tooltip="`Bearbeiten`" href="#" class="ml-2 inline-flex btn btn-warning btn-sm" @click.prevent="editReal(child)"><ui-sprite src="pencil" /></a>
<a v-tooltip="`Löschen`" href="#" class="ml-2 inline-flex btn btn-danger btn-sm" @click.prevent="deleting = child"><ui-sprite src="trash" /></a>
<a v-tooltip="`Abmelden`" href="#" class="ml-2 inline-flex btn btn-danger btn-sm" @click.prevent="deleting = {model: child, force: false}"><ui-sprite src="trash" /></a>
</td>
</tr>
</template>
@ -209,7 +211,7 @@ const sortingConfig = computed({
});
async function handleDelete() {
await remove(deleting.value);
await remove(deleting.value.model, deleting.value.force);
deleting.value = null;
}

View File

@ -98,6 +98,18 @@ it('testItDisplaysForms', function () {
->assertInertiaPath('data.meta.default.location', '');
});
it('displays participants count', function () {
$this->login()->loginNami()->withoutExceptionHandling();
Form::factory()
->has(Participant::factory()->count(2))
->has(Participant::factory()->cancelled()->count(3))
->create();
sleep(1);
$this->get(route('form.index'))
->assertInertiaPath('data.data.0.participants_count', 2);
});
it('testFormtemplatesHaveData', function () {
$this->login()->loginNami()->withoutExceptionHandling();
Formtemplate::factory()->name('tname')->sections([FormtemplateSectionRequest::new()->name('sname')->fields([

View File

@ -78,6 +78,9 @@ it('testItShowsEmptyFilters', function () {
$this->callFilter('form.participant.index', ['data' => ['check' => null]], ['form' => $form])->assertHasJsonPath('meta.filter.data.check')->assertJsonPath('meta.filter.data.check', null);
$this->callFilter('form.participant.index', ['data' => ['check' => 'A']], ['form' => $form])->assertJsonPath('meta.filter.data.check', 'A');
$this->callFilter('form.participant.index', ['data' => []], ['form' => $form])->assertJsonPath('meta.filter.data.check', ParticipantFilterScope::$nan);
$this->callFilter('form.participant.index', ['data' => []], ['form' => $form])->assertJsonPath('meta.filter.show_cancelled', false);
$this->callFilter('form.participant.index', ['show_cancelled' => true], ['form' => $form])->assertJsonPath('meta.filter.show_cancelled', true);
$this->callFilter('form.participant.index', ['show_cancelled' => false], ['form' => $form])->assertJsonPath('meta.filter.show_cancelled', false);
});
it('sorts by active colums sorting by default', function (array $sorting, string $by, bool $direction) {
@ -186,6 +189,22 @@ it('testItFiltersParticipantsByRadioValue', function () {
->assertJsonCount(4, 'data');
});
it('filters participants by cancelled at', function () {
$this->login()->loginNami()->withoutExceptionHandling();
$form = Form::factory()
->has(Participant::factory()->count(1))
->has(Participant::factory()->cancelled()->count(2))
->create();
sleep(2);
$this->callFilter('form.participant.index', [], ['form' => $form])
->assertJsonCount(1, 'data');
$this->callFilter('form.participant.index', ['show_cancelled' => false], ['form' => $form])
->assertJsonCount(1, 'data');
$this->callFilter('form.participant.index', ['show_cancelled' => true], ['form' => $form])
->assertJsonCount(2, 'data');
});
it('testItPresentsNamiField', function () {
$this->login()->loginNami()->withoutExceptionHandling();
$form = Form::factory()
@ -269,6 +288,15 @@ it('testItShowsPreventionState', function () {
->assertJsonPath('data.0.prevention_items.0.tooltip', 'erweitertes Führungszeugnis nicht vorhanden');
});
it('doesnt show cancelled participants', function () {
$this->login()->loginNami()->withoutExceptionHandling();
$participant = Participant::factory()->for(Form::factory())->create(['cancelled_at' => now()]);
sleep(2);
$this->callFilter('form.participant.index', [], ['form' => $participant->form])
->assertJsonCount(0, 'data');
});
it('test it orders participants by value', function (array $values, array $sorting, array $expected) {
list($key, $direction) = $sorting;
$this->login()->loginNami()->withoutExceptionHandling();

View File

@ -14,7 +14,7 @@ beforeEach(function () {
test()->setUpForm();
});
it('testItCanDestroyAParticipant', function () {
it('cancels a participant', function () {
$this->login()->loginNami()->withoutExceptionHandling();
$form = Form::factory()
->has(Participant::factory())
@ -24,5 +24,22 @@ it('testItCanDestroyAParticipant', function () {
$this->deleteJson(route('participant.destroy', ['participant' => $form->participants->first()]))
->assertOk();
$this->assertDatabaseCount('participants', 1);
$this->assertDatabaseHas('participants', [
'cancelled_at' => now(),
'id' => $form->participants->first()->id,
]);
});
it('testItCanDestroyAParticipant', function () {
$this->login()->loginNami()->withoutExceptionHandling();
$form = Form::factory()
->has(Participant::factory())
->sections([])
->create();
$this->deleteJson(route('participant.destroy', ['participant' => $form->participants->first()]), [], ['X-Force' => '1'])
->assertOk();
$this->assertDatabaseCount('participants', 0);
});

View File

@ -2,6 +2,7 @@
namespace Tests\Feature\Form;
use DB;
use App\Form\Models\Form;
use App\Form\Models\Participant;
use Illuminate\Foundation\Testing\DatabaseTransactions;
@ -20,15 +21,14 @@ it('testItShowsParticipantsAndColumns', function () {
$this->login()->loginNami()->withoutExceptionHandling();
$form = Form::factory()
->has(Participant::factory()->data(['stufe' => 'Pfadfinder', 'vorname' => 'Max', 'select' => ['A', 'B']]))
->sections([
FormtemplateSectionRequest::new()->fields([
$this->textField('vorname')->name('Vorname'),
$this->checkboxesField('select')->name('Abcselect')->options(['A', 'B', 'C']),
$this->dropdownField('stufe')->name('Stufe')->options(['Wölfling', 'Jungpfadfinder', 'Pfadfinder']),
]),
->fields([
$this->textField('vorname')->name('Vorname'),
$this->checkboxesField('select')->name('Abcselect')->options(['A', 'B', 'C']),
$this->dropdownField('stufe')->name('Stufe')->options(['Wölfling', 'Jungpfadfinder', 'Pfadfinder']),
])
->name('ZEM 2024')
->create();
DB::table('participants')->where('id', $form->participants->first()->id)->update(['id' => 9909]);
$this->get(route('form.export', ['form' => $form]))->assertDownload('tn-zem-2024.xlsx');
$contents = Storage::disk('temp')->get('tn-zem-2024.xlsx');
@ -37,4 +37,17 @@ it('testItShowsParticipantsAndColumns', function () {
$this->assertExcelContent('Pfadfinder', $contents);
$this->assertExcelContent('Stufe', $contents);
$this->assertExcelContent('Abcselect', $contents);
$this->assertExcelContent('9909', $contents);
});
it('shows cancelled at', function () {
Storage::fake('temp');
$this->login()->loginNami()->withoutExceptionHandling();
$form = Form::factory()->name('ZEM 2024')
->has(Participant::factory()->state(['cancelled_at' => now()->subWeek()]))
->create();
$this->get(route('form.export', ['form' => $form]))->assertDownload('tn-zem-2024.xlsx');
$contents = Storage::disk('temp')->get('tn-zem-2024.xlsx');
$this->assertExcelContent(now()->subWeek()->format('d.m.Y'), $contents);
});

View File

@ -1,7 +1,5 @@
<?php
namespace Illuminate\Testing;
namespace Spatie\LaravelSettings;
/**