Open nickopris opened 4 years ago
I'm wondering if it would be possible to model all this within existing OpenActive/Schema.org properties, like so:
{
"name": "Training with Jessica",
"identifier":78854,
"@type": "Event",
"@context":[
"https://schema.org",
"https://openactive.io/",
"https://openactive.io/ns-beta"
],
"leader":{
"@type":"Person",
"name":"Jessica",
"url":"https://ourparks.org.uk/users/jessicaholland94",
"email":"getfitnow@ourparks.org.uk",
"gender":"https://schema.org/Female",
"knowsLanguage":{
"@type":"schema:Language",
"name":"Spanish",
"alternateName":"es"
},
"schema:qualifications": [
{
"@type": "schema:EducationalOccupationalCredential",
"name": "Athletfit",
"url": "https://example.com/qualifications/athletfit"
},
{
"@type": "schema:EducationalOccupationalCredential",
"name": "Basketball Fit",
"url": "https://example.com/qualifications/basketball-fit",
}
]
},
"activity":[
{
"@type": "Concept",
"id":"https://openactive.io/activity-list#e09776e6-f1b4-421b-b667-5c5913cf97aa",
"prefLabel":"Basketball",
},
{
"@type": "Concept",
"id":"https://openactive.io/activity-list#4d280e1b-a370-4582-ab4d-442b2eeaf5d4",
"prefLabel":"Athletics",
}
],
"location":{
"@type":"Location",
"identifier":77330,
"name":"London Borough of Lewisham",
"description":"Intro for borough",
"url":"https://ourparks.local/borough/london-borough-lewisham",
"logo":{
"@type":"ImageObject",
"url":"https://ourparks.org.uk/sites/default/files/styles/large/public/boroughs/normal2.png"
}
},
"genderRestriction": "https://openactive.io/NoRestriction",
"ageRange":{
"@type":"QuantitativeValue",
"minValue":10
},
"deliveryMode":[
"https://schema.org/OnlineEventAttendanceMode",
"https://schema.org/OfflineEventAttendanceMode"
],
"subEvent": [{
"@type": "Event",
"startDate": "2020-09-14T09:00:00Z",
"endDate": "2020-09-14T09:30:00Z",
"duration": "PT30M",
"maximumAttendeeCapacity": 1,
"remainingAttendeeCapacity": 1
},
{
"@type": "Event",
"startDate": "2020-09-14T09:30:00Z",
"endDate": "2020-09-14T10:00:00Z",
"duration": "PT30M",
"maximumAttendeeCapacity": 1,
"remainingAttendeeCapacity": 1
},
{ "..." : "..."}
]
}
It's less compact, because AFAICT there's no way to specify recurrence rules finer than weekly within schema.org. But this allows a full representation of available slots, which would be vital if others were to harvest this data.
Note also that I've split out activities from qualifications - with activities being part of the Event
, but qualifications being attached to the Person
. This seems to make sense to me both functionally and intuitively.
Main question for me on the use case is this:
In terms of modelling:
Person
", or an "Event
" (ala SessionSeries
) or a type of "Product
" (ala FacilityUse
) or actually a type of "Service
" ?An initial look at this makes me wonder if we're booking a Service
, and that the Service
could be branded as whatever, and provided by one or many Person
s.
For the actual slots, I'd suggest we use Slot
here rather than just Event
for consistency, as Slots are generally displayed in a timetable view, rather than expected to be listed as in the case of a ScheduledSession
, and we've tried to move away from overuse of Event
in 2.0 (in 1.0 everything was an Event
, parent and child, which caused many issues with conformance rules and made the spec too loose).
Whatever type we decide is the base type for the thing being booked, suggest we subclass it as "PrivateTrainer" or the most generic version of this for the usecase to make it clearly distinct from the other types already specified.
I may be looking at this the wrong way but my initial idea was to make available a list of personal trainers through this feed rather than to provide a direct link to booking individual slots.
I believe that having Event
or Slot
with that level of granularity will make it difficult to process large amounts of data and I would make that available as a separate feed if necessary.
So to get this started I suggest a feed of trainers and their meta, having a link to the platform where users can end up booking them (which will also deal with slots availability).
For consumers looking to provide the functionality of direct booking we can make available another endpoint e.g. /api/trainers/[identifier]
which will provide upcoming availability for the given trainer.
Any thoughts?
Yes that’s right, there would likely need to be two feeds, one for Metadata as you say and one for Slots
For facilities there are always two feeds: FacilityUse and Slots (see examples: https://data.everyoneactive.com/OpenActive/, https://gll-openactive.legendonlineservices.co.uk/openactive). The same pattern should work here too.
To make this work with the Open Booking API we’d need both of those feeds, so worth doing these together. Although there are a large volume of Slots, when the feed has 500 items in each page, it is amazing how few pages are required to cover a dataset. The above examples cover all facilities across over 500 leisure centres between them.
The Open Booking API needs an @id of a Slot in the feed, so that would be the thing here.
Do you know how many instructors there are, and how many slots/week there are for each, and how far in advance they can be booked? That would help give us an idea of data volume.
Even if the feed will only have a few pages it's more efficient for the consumer to fetch the Metadata once a few hours and the Slots more frequently.
I would not know how may instructors are going to be in the feed - we have thousands registered by I assume we'll need to give them the option of being booked - at the moment they are only internal to Our Parks.
When I started putting together the feed example I came up with some fields that I think are necessary but our platform does not have them or are not required for instructors to fill in. We'll need to make sure these are available and made a requirement.
Reviewing the above, I'm a little unclear on the proposal. Is it:
Subclass Slot
with a new class, PrivateTrainerSlot
or similar. This would be essentially identical to Slot
, except e.g. there would be a new REQUIRED field called something like trainer
or leader
; and FacilityUse
would be OPTIONAL.
Have a separate feed of Persons
? If so, I'm not sure what would have to be added, if anything, to schema:Person
to make it fit this use-case. The main difficulty in the above seems to be associating qualifications with activities, which I think we could perhaps just declare out-of-scope, given the complexity of the qualifications question in connection with safeguarding.
I'm leaning towards:
oa:PrivateTrainer
subclasses schema:Service
(a mirror of oa:FacilityUse
which subclasses schema:Product
). This represents either a specific trainer, or a brand/org of trainers. Perhaps to ensure this is easily compatible with the modelling spec we use schema:organizer
(which is either a person, or an organisation)? Perhaps the "Brand" of the trainer is described in the name
of the oa:PrivateTrainer
(e.g. "AGfitness" is a person). So trainer can advertise themselves however they want within the oa:PrivateTrainer
, but reference whoever is legally running the session via schema:organizer
oa:Slot
as-is, except that we introduce a term such as oa:isSlotFor
which supersedes facilityUse
as a more general reference to the parent (similar to superEvent
). Note that maximumUses
and remainingUses
don't quite make sense here as they are "uses" of a trainer, however the semantics are the same (i.e. there may be one of 5 available trainers that are assigned at random from an organisation).More data to help with this modelling: https://trainers4me.com/2/Fitness/
We should also check this model works for e.g. Sports Physios, as well as 1:1 trainers from the same (example https://online.tm2app.com/centralhealth)
Or in fact reflecting on the above, perhaps the slots should be related to the "practitioners" as above.
We need to review way more systems here to get a feel for this.
Note we also want to link back to the Organizer (e.g. Our Parks, or Central Health), in addition to the actual trainer
{
"@type": "beta:PrivateTrainer",
"@context": [
"https://schema.org",
"https://openactive.io/",
"https://openactive.io/ns-beta"
],
"@id": "https://api.example.com/trainers/1",
"name": "Training with AGfitness",
"identifier": 78854,
"leader": {
"@type": "Person",
"name": "Jessica",
"url": "https://ourparks.org.uk/users/jessicaholland94",
"email": "getfitnow@ourparks.org.uk",
"gender": "https://schema.org/Female",
"knowsLanguage": {
"@type": "schema:Language",
"name": "Spanish",
"alternateName": "es"
},
"schema:qualifications": [
{
"@type": "schema:EducationalOccupationalCredential",
"name": "Athletfit",
"url": "https://example.com/qualifications/athletfit"
},
{
"@type": "schema:EducationalOccupationalCredential",
"name": "Basketball Fit",
"url": "https://example.com/qualifications/basketball-fit"
}
]
},
"activity": [
{
"@type": "Concept",
"id": "https://openactive.io/activity-list#e09776e6-f1b4-421b-b667-5c5913cf97aa",
"prefLabel": "Basketball"
},
{
"@type": "Concept",
"id": "https://openactive.io/activity-list#4d280e1b-a370-4582-ab4d-442b2eeaf5d4",
"prefLabel": "Athletics"
}
],
"location": {
"@type": "Location",
"identifier": 77330,
"name": "London Borough of Lewisham",
"description": "Intro for borough",
"url": "https://ourparks.local/borough/london-borough-lewisham",
"logo": {
"@type": "ImageObject",
"url": "https://ourparks.org.uk/sites/default/files/styles/large/public/boroughs/normal2.png"
}
},
"genderRestriction": "https://openactive.io/NoRestriction",
"ageRange": {
"@type": "QuantitativeValue",
"minValue": 10
},
"eventAttendanceMode": "https://schema.org/OnlineEventAttendanceMode",
"offers": [
{
"@type": "Offer",
"name": "30 minute hire",
"price": 10,
"priceCurrency": "GBP",
"url": "https://profile.everyoneactive.com/booking?Site=0140&Activities=1402CBP20150217&Culture=en-GB"
}
]
}
{
"next": "http://www.example.org/feeds/facility-uses/events?afterChangeNumber=44254329",
"items": [
{
"state": "updated",
"kind": "PrivateTrainer/Slot",
"id": "009/2018-03-01T10:00:00Z",
"modified": 44234351,
"data": {
"@context": "https://openactive.io/",
"@type": "Slot",
"@id": "https://api.example.com/trainers/1/432#/event/2018-03-01T10:00:00Z",
"oa:isSlotFor": "https://api.example.com/trainers/1",
"startDate": "2018-03-01T11:00:00Z",
"endDate": "2018-03-01T11:30:00Z",
"duration": "PT30M",
"remainingUses": 1,
"maximumUses": 1
}
}
],
"license": "https://creativecommons.org/licenses/by/4.0/"
}
I've done an initial version of the models for PrivateTrainer
and PrivateTrainerSlot
. They are surfacing live at the following endpoints (NOTE: data available is a mix between live content and some dummy content)
PrivateTrainer
endpoint: https://ourparks.org.uk/api/private-trainers
PrivateTrainerSlot
endpoint: https://ourparks.org.uk/api/private-trainers/75030
The models look like below:
<?php
namespace OpenActive\Models\OA;
/**
* [NOTICE: This is a beta class, and is highly likely to change in future versions of this library.].
*
*/
class PrivateTrainer extends \OpenActive\Models\SchemaOrg\Organization {
/**
* @return string
*/
public static function getType()
{
return "beta:PrivateTrainer";
}
public static function fieldList() {
$fields = [
"identifier" => "identifier",
"leader" => "leader",
"activity" => "activity",
"deliveryMode" => "deliveryMode"
];
return array_merge(parent::fieldList(), $fields);
}
/**
* A local non-URI identifier for the resource
*
* ```json
* "identifier": "SB1234"
* ```
*
* @var string|int|\OpenActive\Models\OA\PropertyValue|\OpenActive\Models\OA\PropertyValue[]|null
*/
protected $identifier;
/**
* A Person who delivers the Slot/Sessions.
*
* ```json
* "leader": [
* {
* "@type": "Person",
* "familyName": "Smith",
* "givenName": "Nicole",
* "@id": "https://example.com/locations/1234ABCD/leaders/89",
* "identifier": 89
* }
* ]
* ```
*
* @var \OpenActive\Models\OA\Person
*/
protected $leader;
/**
* Specifies the physical activity or activities provided by the PrivateTrainer.
*
* ```json
* "activity": [
* {
* "@id": "https://openactive.io/activity-list#fbdc35a8-3dd0-40ee-a7ca-6ff40b3e5f90",
* "@type": "Concept",
* "prefLabel": "Netball",
* "inScheme": "https://openactive.io/activity-list"
* }
* ]
* ```
*
* @var \OpenActive\Models\OA\Concept[]
*/
protected $activity;
/**
* The deliveryMode of a PrivateTrainer indicates whether they teach online, offline, or a mix.
*
* ```json
* "deliveryMode": ["https://schema.org/OnlineEventAttendanceMode"]
* ```
*
* @var \OpenActive\Enums\SchemaOrg\EventAttendanceModeEnumeration[]|null
*/
protected $deliveryMode;
/**
* @return string|int|\OpenActive\Models\OA\PropertyValue|\OpenActive\Models\OA\PropertyValue[]|null
*/
public function getIdentifier()
{
return $this->identifier;
}
/**
* @param string|int|\OpenActive\Models\OA\PropertyValue|\OpenActive\Models\OA\PropertyValue[]|null $identifier
* @return void
* @throws \OpenActive\Exceptions\InvalidArgumentException If the provided argument is not of a supported type.
*/
public function setIdentifier($identifier)
{
$types = array(
"string",
"int",
"\OpenActive\Models\OA\PropertyValue",
"\OpenActive\Models\OA\PropertyValue[]",
"null",
);
$identifier = self::checkTypes($identifier, $types);
$this->identifier = $identifier;
}
/**
* @return \OpenActive\Models\OA\Person
*/
public function getLeader()
{
return $this->leader;
}
/**
* @param \OpenActive\Models\OA\Person $leader
* @return void
* @throws \OpenActive\Exceptions\InvalidArgumentException If the provided argument is not of a supported type.
*/
public function setLeader($leader)
{
$types = array(
"\OpenActive\Models\OA\Person",
);
$leader = self::checkTypes($leader, $types);
$this->leader = $leader;
}
/**
* @return \OpenActive\Models\OA\Concept[]
*/
public function getActivity()
{
return $this->activity;
}
/**
* @param \OpenActive\Models\OA\Concept[] $activity
* @return void
* @throws \OpenActive\Exceptions\InvalidArgumentException If the provided argument is not of a supported type.
*/
public function setActivity($activity)
{
$types = array(
"\OpenActive\Models\OA\Concept[]",
);
$activity = self::checkTypes($activity, $types);
$this->activity = $activity;
}
/**
* @return \OpenActive\Enums\SchemaOrg\EventAttendanceModeEnumeration[]|null
*/
public function getDeliveryMode()
{
return $this->deliveryMode;
}
/**
* @param \OpenActive\Enums\SchemaOrg\EventAttendanceModeEnumeration[]|null $deliveryMode
* @return void
* @throws \OpenActive\Exceptions\InvalidArgumentException If the provided argument is not of a supported type.
*/
public function setDeliveryMode($deliveryMode)
{
$types = array(
"\OpenActive\Enums\SchemaOrg\EventAttendanceModeEnumeration[]",
"null",
);
$deliveryMode = self::checkTypes($deliveryMode, $types);
$this->deliveryMode = $deliveryMode;
}
}
<?php
namespace OpenActive\Models\OA;
use OpenActive\BaseModel;
use OpenActive\Concerns\Serializer;
use OpenActive\Concerns\TypeChecker;
/**
* [NOTICE: This is a beta class, and is highly likely to change in future versions of this library.].
*
*/
class PrivateTrainerSlot extends BaseModel{
use Serializer, TypeChecker;
/**
* @return string
*/
public static function getType() {
return "beta:PrivateTrainerSlot";
}
/**
* @return array
*/
public static function fieldList() {
return [
"state" => "state",
"identifier" => "identifier",
"dateModified" => "dateModified",
"isSlotFor" => "isSlotFor",
"data" => "data",
];
}
/**
* The date and time at which the slot was last updated.
*
* ```json
* "modified": "2018-01-27T12:00:00Z"
* ```
*
* @var DateTime|null
*/
protected $dateModified;
/**
* The state of the slot: updated, new
*
* ```json
* "state": "updated"
* ```
*
* @var string
*/
protected $state;
/**
* A slot available for PersonalTrainer's availability
*
* ```json
* "data":
* {
* "@type": "Slot",
* "@id": "http://www.example.org/api/facility-uses/432#/event/2018-03-01T10:00:00Z",
* "startDate": "2018-03-01T11:00:00Z",
* "endDate": "2018-03-01T11:30:00Z",
* "duration": "PT30M",
* "remainingUses": 3,
* "maximumUses": 6
* }
* ```
*
* @var \OpenActive\Models\OA\Slot
*/
protected $data;
/**
* A local non-URI identifier for the resource
*
* ```json
* "id": "2011"
* ```
*
* @var string|int|\OpenActive\Models\OA\PropertyValue|\OpenActive\Models\OA\PropertyValue[]|null
*/
protected $identifier;
/**
* Text with link to private instructor info
*
* ```json
* "isSlotFor": "https://api.example.com/trainers/1"
* ```
*
* @var string
*/
protected $isSlotFor;
/**
* @return string|int|\OpenActive\Models\OA\PropertyValue|\OpenActive\Models\OA\PropertyValue[]|null
*/
public function getIdentifier()
{
return $this->identifier;
}
/**
* @param string|int|\OpenActive\Models\OA\PropertyValue|\OpenActive\Models\OA\PropertyValue[]|null $identifier
* @return void
* @throws \OpenActive\Exceptions\InvalidArgumentException If the provided argument is not of a supported type.
*/
public function setIdentifier($identifier)
{
$types = array(
"string",
"int"
);
$identifier = self::checkTypes($identifier, $types);
$this->identifier = $identifier;
}
/**
* @return DateTime|null
*/
public function getDateModified()
{
return $this->dateModified;
}
/**
* @param DateTime|null $dateModified
* @return void
* @throws \OpenActive\Exceptions\InvalidArgumentException If the provided argument is not of a supported type.
*/
public function setModified($dateModified)
{
$types = array(
"DateTime",
"null",
);
$dateModified = self::checkTypes($dateModified, $types);
$this->dateModified = $dateModified;
}
/**
* @return string
*/
public function getState()
{
return $this->state;
}
/**
* @param string $state
* @return void
* @throws \OpenActive\Exceptions\InvalidArgumentException If the provided argument is not of a supported type.
*/
public function setState($state)
{
$types = array(
"string",
);
$state = self::checkTypes($state, $types);
$this->state = $state;
}
/**
* @return \OpenActive\Models\OA\Slot
*/
public function getData()
{
return $this->data;
}
/**
* @param \OpenActive\Models\OA\Slot $data
* @return void
* @throws \OpenActive\Exceptions\InvalidArgumentException If the provided argument is not of a supported type.
*/
public function setData($data)
{
$types = array(
"\OpenActive\Models\OA\Slot",
);
$data = self::checkTypes($data, $types);
$this->data = $data;
}
/**
* @return string
*/
public function getIsSlotFor()
{
return $this->isSlotFor;
}
/**
* @param string $isSlotFor
* @return void
* @throws \OpenActive\Exceptions\InvalidArgumentException If the provided argument is not of a supported type.
*/
public function setIsSlotFor($isSlotFor)
{
$types = array(
"string",
);
$isSlotFor = self::checkTypes($isSlotFor, $types);
$this->isSlotFor = $isSlotFor;
}
}
We may need next
for the PrivateTrainer feed but I don't think it's needed for the PrivateTrainerSlots feed based on our platform logic of publishing only the next 7 days of availability for the given Trainer.
Proposer
Our Parks
Use Case
Private coach sessions to be offered in an open data feed.
Why is this not covered by existing properties?
There is no specification, yet.
Please provide a link to example data
https://ourparks.org.uk/onetoone is the booking page for private sessions. Our Parks does the coach allocation dynamically at this point in time but we're working on a new site section that will display all coaches.
Proposal
PrivateTrainers containing:
Example