openactive / modelling-opportunity-data

OpenActive Modelling Opportunity Data specification
https://www.openactive.io/modelling-opportunity-data/
Other
6 stars 6 forks source link

Private coach sessions #249

Open nickopris opened 4 years ago

nickopris commented 4 years ago

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

{
  "identifier": 78854,
  "trainer": {
    "@type": "Person",
    "@context": [
      "https://schema.org",
      "https://openactive.io/",
      "https://openactive.io/ns-beta"
    ],
    "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"
    }
  },
  "qualifications": [
    {
      "id": "https://openactive.io/activity-list#e09776e6-f1b4-421b-b667-5c5913cf97aa",
      "prefLabel": "Basketball",
      "name": "Basketball Fit"
    },
    {
      "id": "https://openactive.io/activity-list#4d280e1b-a370-4582-ab4d-442b2eeaf5d4",
      "prefLabel": "Athletics",
      "name": "Athletfit"
    }
  ],
  "location": {
    "@type": "Brand",
    "@context": [
      "https://schema.org",
      "https://openactive.io/",
      "https://openactive.io/ns-beta"
    ],
    "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",
    "@context": [
      "https://schema.org",
      "https://openactive.io/",
      "https://openactive.io/ns-beta"
    ],
    "minValue": 10
  },
  "deliveryMode": [
    "https://schema.org/OnlineEventAttendanceMode",
    "https://schema.org/OfflineEventAttendanceMode"
  ],
  "daysAvailable": [
    "https://schema.org/Monday",
    "https://schema.org/Friday",
    "https://schema.org/Saturday"
  ]
}
thill-odi commented 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.

thill-odi commented 4 years ago

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.

nickevansuk commented 4 years ago

Main question for me on the use case is this:

In terms of modelling:

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 Persons.

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.

nickopris commented 4 years ago

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?

nickevansuk commented 4 years ago

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.

nickopris commented 4 years ago

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.

thill-odi commented 4 years ago

Reviewing the above, I'm a little unclear on the proposal. Is it:

  1. 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.

  2. 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.

nickevansuk commented 4 years ago

I'm leaning towards:

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) Screenshot 2020-09-21 at 12 52 32

nickevansuk commented 4 years ago

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.

nickevansuk commented 4 years ago

Note we also want to link back to the Organizer (e.g. Our Parks, or Central Health), in addition to the actual trainer

nickevansuk commented 4 years ago
{
  "@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/"
  }
nickopris commented 4 years ago

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:

PrivateTrainer

<?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;
  }
}

PrivateTrainerSlot

<?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.