Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,17 @@ _Avoid_: ParkingSpotConfirmation
**ParkingRule**: A reference to parking rules for a municipality or an entire country.

**Favorite**: A user's saved reference to a parking option from any of the three sources.

**DatasetSource**: A dataset selected for connection to NIPKaart, with an identified publisher, scope and provenance. It is not a research lead or a contact-management record.

**SourceRecord**: What one dataset says about a parking place or facility, identified within that dataset. Multiple source records can describe the same physical place without becoming the same source.

**ParkingObservation**: A dated statement about particular properties of a parking place, with its method and supporting provenance. Its registration date does not establish when the place was observed.

**CorrectionProposal**: A proposed change to information about an existing community or imported parking record, with a reason and supporting observation.

**LocalCorrection**: An accepted correction whose value and justification remain distinct from the source's current statement.

**PublicationDecision**: An attributable decision about which information NIPKaart presents, including the reason and evidence on which it is based.

**ParkingRecordLink**: An assessed relationship between parking records, distinguishing records that describe the same place from a space located within a facility.
37 changes: 37 additions & 0 deletions app/Console/Commands/RegisterAmsterdamDataset.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

namespace App\Console\Commands;

use App\Models\DatasetSource;
use App\Models\Municipality;
use Illuminate\Console\Command;

class RegisterAmsterdamDataset extends Command
{
protected $signature = 'nipkaart:register-amsterdam {municipality : Existing Amsterdam municipality ID}';

protected $description = 'Register the Amsterdam E6a pilot for local review, with publication disabled';

public function handle(): int
{
$municipality = Municipality::with(['country', 'province'])->find($this->argument('municipality'));
if (! $municipality || $municipality->name !== 'Amsterdam' || $municipality->country->code !== 'NL' || $municipality->province->geocode !== 'NL-NH') {
$this->error('Select Amsterdam in Noord-Holland, Netherlands.');

return self::FAILURE;
}
DatasetSource::firstOrCreate(['code' => 'nl-amsterdam-parkeervakken-e6a'], [
'name' => 'Amsterdam — algemene gehandicaptenparkeerplaatsen',
'selection' => 'e6a-all', 'target_type' => 'municipal',
'source_url' => 'https://api.data.amsterdam.nl/v1/parkeervakken/parkeervakken/',
'attribution' => 'Gemeente Amsterdam; parkeervakken E6a; capaciteit is een schatting.',
'terms_url' => 'https://data.overheid.nl/dataset/318a98b8-ef87-4335-9674-f5405f2bc4be',
'municipality_id' => $municipality->id,
'bounds' => [4.65, 52.2, 5.15, 52.5],
'publication_enabled' => false,
]);
$this->info('Dataset registered. Confirm the source terms in the import screen before publication.');

return self::SUCCESS;
}
}
72 changes: 72 additions & 0 deletions app/Http/Controllers/Admin/MunicipalImportController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<?php

namespace App\Http\Controllers\Admin;

use App\Http\Controllers\Controller;
use App\Http\Requests\App\StoreMunicipalImportRequest;
use App\Models\DatasetSource;
use App\Models\MunicipalImport;
use App\Services\MunicipalImportService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Inertia\Inertia;
use Inertia\Response;

class MunicipalImportController extends Controller
{
public function index(): Response
{
Gate::authorize('viewAny', MunicipalImport::class);

return Inertia::render('backend/municipal-imports/index', [
'datasets' => DatasetSource::orderBy('name')->get(),
'imports' => MunicipalImport::with('datasetSource:id,name')->latest('id')->paginate(20),
]);
}

public function store(StoreMunicipalImportRequest $request, MunicipalImportService $service): RedirectResponse
{
$import = $service->intake($request->file('file')->getContent(), $request->user());

return to_route('app.municipal-imports.show', $import);
}

public function show(Request $request, MunicipalImport $municipalImport, MunicipalImportService $service): Response
{
Gate::authorize('view', $municipalImport);
$review = $service->review($municipalImport);
$page = max(1, min((int) $request->query('page', 1), max(1, (int) ceil(count($review['rows']) / 50))));
$total = count($review['rows']);
$review['rows'] = array_slice($review['rows'], ($page - 1) * 50, 50);

return Inertia::render('backend/municipal-imports/show', [
'import' => $municipalImport, 'dataset' => $municipalImport->datasetSource,
'review' => $review, 'page' => $page, 'pages' => max(1, (int) ceil($total / 50)),
]);
}

public function update(Request $request, MunicipalImport $municipalImport, MunicipalImportService $service): RedirectResponse
{
Gate::authorize('update', $municipalImport);
$data = $request->validate([
'decision' => ['required', 'in:publish,reject'], 'reason' => ['required', 'string', 'max:2000'],
'review_token' => ['required', 'string', 'size:64'],
'geometry_reviewed' => ['sometimes', 'boolean'],
]);
$service->decide($municipalImport, $request->user(), $data['decision'], $data['reason'], $data['review_token'], (bool) ($data['geometry_reviewed'] ?? false));

return to_route('app.municipal-imports.show', $municipalImport);
}

public function enable(Request $request, DatasetSource $datasetSource): RedirectResponse
{
Gate::authorize('create', MunicipalImport::class);
$request->validate(['terms_confirmed' => ['accepted'], 'reason' => ['required', 'string', 'max:2000']]);
$datasetSource->publication_enabled = true;
$datasetSource->terms_review = ['user_id' => $request->user()->id, 'at' => now()->toIso8601String(), 'reason' => $request->string('reason')->toString()];
$datasetSource->save();

return to_route('app.municipal-imports.index');
}
}
20 changes: 20 additions & 0 deletions app/Http/Requests/App/StoreMunicipalImportRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

namespace App\Http\Requests\App;

use App\Models\MunicipalImport;
use Illuminate\Foundation\Http\FormRequest;

class StoreMunicipalImportRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('create', MunicipalImport::class);
}

/** @return array<string, list<string>> */
public function rules(): array
{
return ['file' => ['required', 'file', 'max:32768']];
}
}
31 changes: 31 additions & 0 deletions app/Models/DatasetSource.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

namespace App\Models;

use Database\Factories\DatasetSourceFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class DatasetSource extends Model
{
/** @use HasFactory<DatasetSourceFactory> */
use HasFactory;

protected $dateFormat = 'Y-m-d H:i:s.u';

protected $fillable = ['code', 'name', 'selection', 'target_type', 'source_url', 'attribution', 'terms_url', 'municipality_id', 'bounds', 'publication_enabled'];

protected $casts = ['terms_review' => 'array', 'bounds' => 'array', 'publication_enabled' => 'boolean', 'last_published_retrieved_at' => 'immutable_datetime'];

public function municipality(): BelongsTo
{
return $this->belongsTo(Municipality::class);
}

/** @return array<string, mixed> */
public function configuration(): array
{
return [...$this->only($this->fillable), 'country_id' => $this->municipality->country_id, 'province_id' => $this->municipality->province_id];
}
}
27 changes: 27 additions & 0 deletions app/Models/MunicipalImport.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

namespace App\Models;

use Database\Factories\MunicipalImportFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class MunicipalImport extends Model
{
/** @use HasFactory<MunicipalImportFactory> */
use HasFactory;

protected $dateFormat = 'Y-m-d H:i:s.u';

protected $fillable = ['dataset_source_id', 'delivery_id', 'fingerprint', 'retrieved_at', 'dataset_config', 'records', 'submitted_by'];

protected $casts = ['dataset_config' => 'array', 'records' => 'array', 'before_values' => 'array', 'retrieved_at' => 'immutable_datetime', 'reviewed_at' => 'immutable_datetime'];

protected $hidden = ['records', 'before_values', 'dataset_config'];

public function datasetSource(): BelongsTo
{
return $this->belongsTo(DatasetSource::class);
}
}
6 changes: 6 additions & 0 deletions app/Models/ParkingMunicipal.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ class ParkingMunicipal extends Model

protected $table = 'parking_municipal_spaces';

protected $hidden = ['source_record', 'geometry_derivation', 'last_imported_values'];

protected $primaryKey = 'id';

protected $keyType = 'string';
Expand All @@ -42,6 +44,10 @@ class ParkingMunicipal extends Model
* @var array<string, string>
*/
protected $casts = [
'source_record' => 'array',
'geometry_derivation' => 'array',
'last_imported_values' => 'array',
'last_checked_at' => 'immutable_datetime',
'orientation' => ParkingOrientation::class,
'updated_at' => 'datetime',
'created_at' => 'datetime',
Expand Down
30 changes: 30 additions & 0 deletions app/Policies/MunicipalImportPolicy.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

namespace App\Policies;

use App\Enums\UserRole;
use App\Models\MunicipalImport;
use App\Models\User;

class MunicipalImportPolicy
{
public function viewAny(User $user): bool
{
return $user->hasRole(UserRole::ADMIN);
}

public function view(User $user, MunicipalImport $municipalImport): bool
{
return $this->viewAny($user);
}

public function create(User $user): bool
{
return $this->viewAny($user);
}

public function update(User $user, MunicipalImport $municipalImport): bool
{
return $this->viewAny($user);
}
}
Loading