Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Display locations on conference list #532

Merged
merged 11 commits into from
Jan 21, 2025
12 changes: 7 additions & 5 deletions app/CallingAllPapers/ConferenceImporter.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

use App\Exceptions\InvalidAddressGeocodingException;
use App\Models\Conference;
use App\Services\Geocoder;
use App\Services\Geocoder\Geocoder;
use Carbon\Carbon;
use Carbon\Exceptions\InvalidFormatException;
use DateTime;
Expand Down Expand Up @@ -74,7 +74,7 @@ public function import(Event $event)
$this->updateConferenceFromCallingAllPapersEvent($conference, $event);

if (! $conference->latitude && ! $conference->longitude && $conference->location) {
$this->geocodeLatLongFromLocation($conference);
$this->geocodeLocation($conference);
}

if ($validator->fails()) {
Expand Down Expand Up @@ -112,13 +112,15 @@ private function nullifyInvalidLatLong($primary, $secondary)
return (float) $primary && (float) $secondary ? $primary : null;
}

private function geocodeLatLongFromLocation(Conference $conference): Conference
private function geocodeLocation(Conference $conference): void
{
try {
$conference->coordinates = $this->geocoder->geocode($conference->location);
$result = $this->geocoder->geocode($conference->location);
} catch (InvalidAddressGeocodingException $e) {
return;
}

return $conference;
$conference->coordinates = $result->getCoordinates();
$conference->location_name = $result->getLocationName();
}
}
35 changes: 35 additions & 0 deletions app/Console/Commands/BackfillConferenceLocationNames.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php

namespace App\Console\Commands;

use App\Exceptions\InvalidAddressGeocodingException;
use App\Models\Conference;
use App\Services\Geocoder\Geocoder;
use Illuminate\Console\Command;

class BackfillConferenceLocationNames extends Command
{
protected $signature = 'app:backfill-conference-location-names';

protected $description = 'Backfill location names for future conferences';

public function handle()
{
$conferences = Conference::query()
->whereAfter(now())
->whereNotNull(['latitude', 'longitude'])
->whereNull('location_name')
->get();

$conferences->each(function ($conference) {
try {
$result = app(Geocoder::class)->geocode($conference->location);
} catch (InvalidAddressGeocodingException $e) {
return;
}

$conference->location_name = $result->getLocationName();
$conference->save();
});
}
}
1 change: 1 addition & 0 deletions app/Http/Requests/SaveConferenceRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ public function rules(): array
'before:starts_at',
],
'location' => ['nullable'],
'location_name' => ['nullable'],
'latitude' => ['nullable'],
'longitude' => ['nullable'],
'speaker_package' => ['nullable'],
Expand Down
1 change: 1 addition & 0 deletions app/Models/Conference.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ class Conference extends UuidBase
'author_id',
'title',
'location',
'location_name',
'latitude',
'longitude',
'description',
Expand Down
29 changes: 10 additions & 19 deletions app/Services/Geocoder.php → app/Services/Geocoder/Geocoder.php
Original file line number Diff line number Diff line change
@@ -1,35 +1,24 @@
<?php

namespace App\Services;
namespace App\Services\Geocoder;

use App\Casts\Coordinates;
use App\Exceptions\InvalidAddressGeocodingException;
use Illuminate\Support\Facades\Http;

class Geocoder
{
public function geocode(string $address): Coordinates
public function geocode(string $address): GeocoderResponse
{
if ($this->isInvalidAddress($address)) {
throw new InvalidAddressGeocodingException;
}

$response = $this->requestGeocoding($address);

if (! count($response['results'])) {
cache()->set('invalid-address::' . md5($address), true);
throw new InvalidAddressGeocodingException;
}

return new Coordinates(
$this->getCoordinate('lat', $response),
$this->getCoordinate('lng', $response),
);
return $this->requestGeocoding($address);
}

private function requestGeocoding($address)
{
return Http::acceptJson()
$response = Http::acceptJson()
->withHeaders([
'User-Agent' => 'Symposium CLI',
])
Expand All @@ -38,11 +27,13 @@ private function requestGeocoding($address)
'key' => config('services.google.maps.key'),
])
->json();
}

private function getCoordinate($type, $response)
{
return data_get($response, "results.0.geometry.location.{$type}");
if (! count($response['results'])) {
cache()->set('invalid-address::' . md5($address), true);
throw new InvalidAddressGeocodingException;
}

return new GeocoderResponse($response);
}

private function isInvalidAddress($address)
Expand Down
56 changes: 56 additions & 0 deletions app/Services/Geocoder/GeocoderResponse.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php

namespace App\Services\Geocoder;

use App\Casts\Coordinates;

class GeocoderResponse
{
public function __construct(protected $response) {}

public function getCoordinates(): Coordinates
{
return new Coordinates(
$this->getCoordinate('lat'),
$this->getCoordinate('lng'),
);
}

public function getLocationName(): string
{
$country = $this->getCountry();
$city = $this->getCity();

$values = $country === 'United States'
? [$city, $this->getState(), $country]
: [$city, $country];

return collect($values)->filter()->implode(', ');
}

private function getCoordinate($type)
{
return data_get($this->response, "results.0.geometry.location.{$type}");
}

private function getCity()
{
return $this->getAddressComponent(['locality', 'postal_town'])['long_name'] ?? null;
}

private function getState()
{
return $this->getAddressComponent(['administrative_area_level_1'])['short_name'] ?? null;
}

private function getCountry()
{
return $this->getAddressComponent(['country'])['long_name'] ?? null;
}

private function getAddressComponent(array $types)
{
return collect(data_get($this->response, 'results.0.address_components', []))
->firstWhere(fn ($component) => collect($component['types'])->intersect($types)->isNotEmpty());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
public function up(): void
{
Schema::table('conferences', function (Blueprint $table) {
$table->string('location_name')->nullable()->after('location');
});
}

public function down(): void
{
Schema::table('conferences', function (Blueprint $table) {
$table->dropColumn('location_name');
});
}
};
24 changes: 24 additions & 0 deletions resources/js/components/LocationLookup.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
<slot :lookup="lookup" @keydown.enter.prevent></slot>
<input type="hidden" name="latitude" v-model="latitude">
<input type="hidden" name="longitude" v-model="longitude">
<input type="hidden" name="location_name" v-model="locationName">
</div>
</template>

Expand All @@ -13,6 +14,7 @@ export default {
return {
latitude: '',
longitude: '',
locationName: '',
};
},
methods: {
Expand All @@ -25,10 +27,32 @@ export default {

dropdown.addListener('place_changed', () => {
const place = dropdown.getPlace();

this.latitude = place.geometry.location.lat();
this.longitude = place.geometry.location.lng();
this.locationName = this.getLocationName(place.address_components);
});
},
getLocationName(components) {
const country = components.find(component => {
return component.types.includes('country');
})?.long_name;
const city = components.find(component => {
return component.types.includes('locality') ||
component.types.includes('postal_town');
})?.long_name;

const values = country === 'United States'
? [city, this.getState(components), country]
: [city, country];

return values.filter(v => v).join(', ');
},
getState(components) {
return components.find(component => {
return component.types.includes('administrative_area_level_1');
})?.short_name;
},
},
};
</script>
5 changes: 5 additions & 0 deletions resources/views/conferences/listing.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,11 @@ class="text-danger"
@endif
</div>
</div>
@if ($conference->location_name || $conference->location)
<div class="pl-8 text-gray-500">
{{ $conference->location_name ?? $conference->location }}
</div>
@endif
<div class="mt-4 pl-8 space-y-3">
<x-info icon="calendar" icon-color="text-gray-400">
<span class="text-gray-400">Dates:</span>
Expand Down
59 changes: 56 additions & 3 deletions tests/Feature/CallingAllPapersConferenceImporterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
use App\Casts\Coordinates;
use App\Exceptions\InvalidAddressGeocodingException;
use App\Models\Conference;
use App\Services\Geocoder;
use App\Services\Geocoder\Geocoder;
use App\Services\Geocoder\GeocoderResponse;
use PHPUnit\Framework\Attributes\Before;
use PHPUnit\Framework\Attributes\Test;
use Tests\MocksCallingAllPapers;
Expand Down Expand Up @@ -234,9 +235,14 @@ public function it_fills_latitude_and_longitude_from_location_if_lat_long_are_nu
$event->location = '10th St. & Constitution Ave. NW, Washington, DC';

$this->mockClient($event);
$this->mock(Geocoder::class, function ($mock) {
$response = $this->mock(GeocoderResponse::class, function ($mock) {
$mock->shouldReceive('getCoordinates')
->andReturn(new Coordinates('38.8921062', '-77.0259036'))
->shouldReceive('getLocationName');
});
$this->mock(Geocoder::class, function ($mock) use ($response) {
$mock->shouldReceive('geocode')
->andReturn(new Coordinates('38.8921062', '-77.0259036'));
->andReturn($response);
});

$importer = new ConferenceImporter(1);
Expand All @@ -248,6 +254,35 @@ public function it_fills_latitude_and_longitude_from_location_if_lat_long_are_nu
$this->assertEquals('-77.0259036', $conference->longitude);
}

#[Test]
public function it_fills_location_name(): void
{
$event = $this->eventStub;

$event->latitude = '0';
$event->longitude = '-82.682221';
$event->location = '10th St. & Constitution Ave. NW, Washington, DC';

$this->mockClient($event);
$response = $this->mock(GeocoderResponse::class, function ($mock) {
$mock->shouldReceive('getCoordinates')
->andReturn(new Coordinates('38.8921062', '-77.0259036'))
->shouldReceive('getLocationName')
->andReturn('Göteborg, Sweden');
});
$this->mock(Geocoder::class, function ($mock) use ($response) {
$mock->shouldReceive('geocode')
->andReturn($response);
});

$importer = new ConferenceImporter(1);
$importer->import($event);

$conference = Conference::first();

$this->assertEquals('Göteborg, Sweden', $conference->location_name);
}

#[Test]
public function it_keeps_lat_long_values_null_if_no_results(): void
{
Expand Down Expand Up @@ -438,4 +473,22 @@ public function conferences_with_null_start_are_valid_with_end_less_than_2_years

$this->assertEquals(1, Conference::count());
}

#[Test]
public function the_geocoder_is_not_called_for_conferences_already_having_coordinates(): void
{
$this->mockClient();
$spy = $this->spy(Geocoder::class);

$importer = new ConferenceImporter(1);
$event = $this->eventStub;
$event->location = 'Somewhere';
$event->latitude = 123;
$event->longitude = 321;

$importer->import($event);

$spy->shouldNotHaveReceived('geocode');
$this->assertEquals(1, Conference::count());
}
}
4 changes: 3 additions & 1 deletion tests/Feature/ConferenceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ public function user_can_create_conference(): void
}

#[Test]
public function a_conference_can_include_location_coordinates(): void
public function a_conference_can_include_location_coordinates_and_name(): void
{
$user = User::factory()->create();

Expand All @@ -43,6 +43,7 @@ public function a_conference_can_include_location_coordinates(): void
'url' => 'https://jedicon.com',
'latitude' => '37.7991531',
'longitude' => '-122.45050129999998',
'location_name' => 'San Francisco, CA, United States',
]);

$this->assertDatabaseHas(Conference::class, [
Expand All @@ -51,6 +52,7 @@ public function a_conference_can_include_location_coordinates(): void
'url' => 'https://jedicon.com',
'latitude' => '37.7991531',
'longitude' => '-122.45050129999998',
'location_name' => 'San Francisco, CA, United States',
]);
}

Expand Down
Loading
Loading