diff --git a/Examples/BookingSystem.AspNetCore/BookingSystem.AspNetCore.csproj b/Examples/BookingSystem.AspNetCore/BookingSystem.AspNetCore.csproj index e6d5a34a..62f36fd2 100644 --- a/Examples/BookingSystem.AspNetCore/BookingSystem.AspNetCore.csproj +++ b/Examples/BookingSystem.AspNetCore/BookingSystem.AspNetCore.csproj @@ -3,6 +3,7 @@ netcoreapp3.1 aspnet-BookingSystem.AspNetCore-443B4F82-A20C-41CE-9924-329A0BCF0D14 + Release;Debug @@ -22,4 +23,5 @@ 1701;1702;1591 + diff --git a/Examples/BookingSystem.AspNetCore/Feeds/FacilitiesFeeds.cs b/Examples/BookingSystem.AspNetCore/Feeds/FacilitiesFeeds.cs index e348d1e9..4ea35f33 100644 --- a/Examples/BookingSystem.AspNetCore/Feeds/FacilitiesFeeds.cs +++ b/Examples/BookingSystem.AspNetCore/Feeds/FacilitiesFeeds.cs @@ -1,4 +1,6 @@ -using OpenActive.DatasetSite.NET; +using Bogus; +using BookingSystem.AspNetCore.Helpers; +using OpenActive.DatasetSite.NET; using OpenActive.FakeDatabase.NET; using OpenActive.NET; using OpenActive.NET.Rpde.Version1; @@ -43,80 +45,150 @@ protected override async Task>> GetRpdeItems(long? af var query = db .SelectMulti(q) - .Select(result => new RpdeItem + .Select(result => { - Kind = RpdeKind.FacilityUse, - Id = result.Item1.Id, - Modified = result.Item1.Modified, - State = result.Item1.Deleted ? RpdeState.Deleted : RpdeState.Updated, - Data = result.Item1.Deleted ? null : new FacilityUse + var faker = new Faker() { Random = new Randomizer((int)result.Item1.Id) }; + var isGoldenRecord = faker.Random.Number(0, 1) > 0.75; + var facilityUseRpdeItem = new RpdeItem { - // QUESTION: Should the this.IdTemplate and this.BaseUrl be passed in each time rather than set on - // the parent class? Current thinking is it's more extensible on parent class as function signature remains - // constant as power of configuration through underlying class grows (i.e. as new properties are added) - Id = RenderOpportunityId(new FacilityOpportunity - { - OpportunityType = OpportunityType.FacilityUse, // isIndividual?? - FacilityUseId = result.Item1.Id - }), - Name = result.Item1.Name, - Provider = _appSettings.FeatureFlags.SingleSeller ? new Organization - { - Id = RenderSingleSellerId(), - Name = "Test Seller", - TaxMode = TaxMode.TaxGross, - TermsOfService = new List - { - new PrivacyPolicy - { - Name = "Privacy Policy", - Url = new Uri("https://example.com/privacy.html"), - RequiresExplicitConsent = false - } - }, - IsOpenBookingAllowed = true, - } : new Organization - { - Id = RenderSellerId(new SimpleIdComponents { IdLong = result.Item2.Id }), - Name = result.Item2.Name, - TaxMode = result.Item2.IsTaxGross ? TaxMode.TaxGross : TaxMode.TaxNet, - TermsOfService = new List - { - new PrivacyPolicy - { - Name = "Privacy Policy", - Url = new Uri("https://example.com/privacy.html"), - RequiresExplicitConsent = false - } - }, - IsOpenBookingAllowed = true, - }, - Location = _fakeBookingSystem.Database.GetPlaceById(result.Item1.PlaceId), - Url = new Uri("https://www.example.com/a-session-age"), - FacilityType = new List { - new Concept - { - Id = new Uri(facilityTypeId), - PrefLabel = facilityTypePrefLabel, - InScheme = new Uri("https://openactive.io/facility-types") - } - }, - IndividualFacilityUse = result.Item1.IndividualFacilityUses != null ? result.Item1.IndividualFacilityUses.Select(ifu => new OpenActive.NET.IndividualFacilityUse + Kind = RpdeKind.FacilityUse, + Id = result.Item1.Id, + Modified = result.Item1.Modified, + State = result.Item1.Deleted ? RpdeState.Deleted : RpdeState.Updated, + Data = result.Item1.Deleted ? null : new FacilityUse { + // QUESTION: Should the this.IdTemplate and this.BaseUrl be passed in each time rather than set on + // the parent class? Current thinking is it's more extensible on parent class as function signature remains + // constant as power of configuration through underlying class grows (i.e. as new properties are added) Id = RenderOpportunityId(new FacilityOpportunity { - OpportunityType = OpportunityType.IndividualFacilityUse, - IndividualFacilityUseId = ifu.Id, + OpportunityType = OpportunityType.FacilityUse, // isIndividual?? FacilityUseId = result.Item1.Id }), - Name = ifu.Name - }).ToList() : null, + Identifier = result.Item1.Id, + Name = GetNameAndFacilityTypeForFacility(result.Item1.Name, isGoldenRecord).Name, + Provider = FeedGenerationHelper.GenerateOrganization( + faker, + result.Item2, + _appSettings.FeatureFlags.SingleSeller, + _appSettings.FeatureFlags.SingleSeller ? RenderSingleSellerId() : RenderSellerId(new SimpleIdComponents { IdLong = result.Item2.Id }) + ), + Location = FeedGenerationHelper.GetPlaceById(result.Item1.PlaceId), + Url = new Uri($"https://www.example.com/facilities/{result.Item1.Id}"), + FacilityType = GetNameAndFacilityTypeForFacility(result.Item1.Name, isGoldenRecord).Facility, + IndividualFacilityUse = result.Item1.IndividualFacilityUses != null ? result.Item1.IndividualFacilityUses.Select(ifu => new OpenActive.NET.IndividualFacilityUse + { + Id = RenderOpportunityId(new FacilityOpportunity + { + OpportunityType = OpportunityType.IndividualFacilityUse, + IndividualFacilityUseId = ifu.Id, + FacilityUseId = result.Item1.Id + }), + Name = ifu.Name + }).ToList() : null, + } + }; + + // If this instance of the Reference Implementation is in Lorem Fitsum mode, then generate a comprehensive data. + // If it is not (eg for a CI run), return only the minimal properties needed + var IsLoremFitsumMode = _appSettings.FeatureFlags.IsLoremFitsumMode; + if (IsLoremFitsumMode) + { + facilityUseRpdeItem.Data.Description = faker.Lorem.Paragraphs(isGoldenRecord ? 4 : faker.Random.Number(4)); + facilityUseRpdeItem.Data.AttendeeInstructions = FeedGenerationHelper.GenerateAttendeeInstructions(faker, isGoldenRecord); + facilityUseRpdeItem.Data.AccessibilitySupport = FeedGenerationHelper.GenerateAccessibilitySupport(faker, isGoldenRecord); + facilityUseRpdeItem.Data.AccessibilityInformation = faker.Lorem.Paragraphs(isGoldenRecord ? 2 : faker.Random.Number(2)); + facilityUseRpdeItem.Data.IsWheelchairAccessible = isGoldenRecord || faker.Random.Bool() ? faker.Random.Bool() : faker.Random.ListItem(new List { true, false, null, null }); + facilityUseRpdeItem.Data.Category = GenerateCategory(faker, isGoldenRecord); + facilityUseRpdeItem.Data.Image = FeedGenerationHelper.GenerateImages(faker, isGoldenRecord); + facilityUseRpdeItem.Data.Video = isGoldenRecord || faker.Random.Bool() ? new List { new VideoObject { Url = new Uri("https://www.youtube.com/watch?v=xvDZZLqlc-0") } } : null; } + + return facilityUseRpdeItem; }); return query.ToList(); } } + + private (string Name, List Facility) GetNameAndFacilityTypeForFacility(string databaseTitle, bool isGoldenRecord) + { + // If both FACILITY_TYPE_ID and FACILITY_TYPE_PREF_LABEL env vars are set, these override the randomly generated activity. We also use these to generate an appropriate name + if (Environment.GetEnvironmentVariable("FACILITY_TYPE_ID") != null && Environment.GetEnvironmentVariable("FACILITY_TYPE_PREF_LABEL") != null) + { + var name = $"{(isGoldenRecord ? "GOLDEN: " : "")} {Environment.GetEnvironmentVariable("FACILITY_TYPE_PREF_LABEL")} facility"; + var concept = new Concept + { + Id = new Uri(Environment.GetEnvironmentVariable("FACILITY_TYPE_ID")), + PrefLabel = Environment.GetEnvironmentVariable("FACILITY_TYPE_PREF_LABEL"), + InScheme = new Uri("https://openactive.io/activity-list") + }; + + return (name, new List { concept }); + } + + // If there isn't an override, we use the randomly generated name to derive the appropriate activity + Concept facilityConcept; + switch (databaseTitle) + { + case string a when a.Contains("Sports Hall"): + facilityConcept = new Concept + { + Id = new Uri("https://openactive.io/facility-types#da364f9b-8bb2-490e-9e2f-1068790b9e35"), + PrefLabel = "Sports Hall", + InScheme = new Uri("https://openactive.io/facility-types") + }; + break; + case string a when a.Contains("Squash Court"): + facilityConcept = new Concept + { + Id = new Uri("https://openactive.io/facility-types#a1f82b7a-1258-4d9a-8dc5-bfc2ae961651"), + PrefLabel = "Squash Court", + InScheme = new Uri("https://openactive.io/facility-types") + }; + break; + case string a when a.Contains("Badminton Court"): + facilityConcept = new Concept + { + Id = new Uri("https://openactive.io/facility-types#9db5681e-700e-4b30-99a5-355885d94db2"), + PrefLabel = "Badminton Court", + InScheme = new Uri("https://openactive.io/facility-types") + }; + break; + case string a when a.Contains("Cricket Net"): + facilityConcept = new Concept + { + Id = new Uri("https://openactive.io/facility-types#2d333183-6a6d-4a95-aad4-c5699f705b14"), + PrefLabel = "Cricket Net", + InScheme = new Uri("https://openactive.io/facility-types") + }; + break; + default: + facilityConcept = new Concept + { + Id = new Uri("https://openactive.io/facility-types#a1f82b7a-1258-4d9a-8dc5-bfc2ae961651"), + PrefLabel = "Squash Court", + InScheme = new Uri("https://openactive.io/facility-types") + }; + break; + } + + var nameWithGolden = $"{(isGoldenRecord ? "GOLDEN: " : "")}{databaseTitle}"; + return (nameWithGolden, new List { facilityConcept }); + + } + + private List GenerateCategory(Faker faker, bool isGoldenRecord) + { + var listOfPossibleCategories = new List + { + "Bookable Facilities", + "Ball Sports", + }; + + return FeedGenerationHelper.GetRandomElementsOf(faker, listOfPossibleCategories, isGoldenRecord, 1).ToList(); + } + } public class AcmeFacilityUseSlotRpdeGenerator : RpdeFeedModifiedTimestampAndIdLong @@ -144,96 +216,91 @@ protected override async Task>> GetRpdeItems(long? afterTime x.Modified == afterTimestamp && x.Id > afterId && x.Modified < (DateTimeOffset.UtcNow - new TimeSpan(0, 0, 2)).UtcTicks) .Take(RpdePageSize) - .Select(x => new RpdeItem + .Select(x => { - Kind = _appSettings.FeatureFlags.FacilityUseHasSlots ? RpdeKind.FacilityUseSlot : RpdeKind.IndividualFacilityUseSlot, - Id = x.Id, - Modified = x.Modified, - State = x.Deleted ? RpdeState.Deleted : RpdeState.Updated, - Data = x.Deleted ? null : new Slot + var faker = new Faker() { Random = new Randomizer((int)x.Id) }; + return new RpdeItem { - // QUESTION: Should the this.IdTemplate and this.BaseUrl be passed in each time rather than set on - // the parent class? Current thinking is it's more extensible on parent class as function signature remains - // constant as power of configuration through underlying class grows (i.e. as new properties are added) - Id = RenderOpportunityId(new FacilityOpportunity - { - OpportunityType = _appSettings.FeatureFlags.FacilityUseHasSlots ? OpportunityType.FacilityUseSlot : OpportunityType.IndividualFacilityUseSlot, - FacilityUseId = x.FacilityUseId, - SlotId = x.Id, - IndividualFacilityUseId = !_appSettings.FeatureFlags.FacilityUseHasSlots ? x.IndividualFacilityUseId : null, - }), - FacilityUse = _appSettings.FeatureFlags.FacilityUseHasSlots ? - RenderOpportunityId(new FacilityOpportunity - { - OpportunityType = OpportunityType.FacilityUse, - FacilityUseId = x.FacilityUseId - }) - : RenderOpportunityId(new FacilityOpportunity + Kind = _appSettings.FeatureFlags.FacilityUseHasSlots ? RpdeKind.FacilityUseSlot : RpdeKind.IndividualFacilityUseSlot, + Id = x.Id, + Modified = x.Modified, + State = x.Deleted ? RpdeState.Deleted : RpdeState.Updated, + Data = x.Deleted ? null : new Slot { - OpportunityType = OpportunityType.IndividualFacilityUse, - IndividualFacilityUseId = x.IndividualFacilityUseId, - FacilityUseId = x.FacilityUseId, - }), - Identifier = x.Id, - StartDate = (DateTimeOffset)x.Start, - EndDate = (DateTimeOffset)x.End, - Duration = x.End - x.Start, - RemainingUses = x.RemainingUses - x.LeasedUses, - MaximumUses = x.MaximumUses, - Offers = new List { new Offer - { - Id = RenderOfferId(new FacilityOpportunity - { - OfferId = 0, - OpportunityType = _appSettings.FeatureFlags.FacilityUseHasSlots ? OpportunityType.FacilityUseSlot : OpportunityType.IndividualFacilityUseSlot, - FacilityUseId = x.FacilityUseId, - SlotId = x.Id, - IndividualFacilityUseId = !_appSettings.FeatureFlags.FacilityUseHasSlots ? x.IndividualFacilityUseId : null, - }), - Price = x.Price, - PriceCurrency = "GBP", - OpenBookingFlowRequirement = OpenBookingFlowRequirement(x), - ValidFromBeforeStartDate = x.ValidFromBeforeStartDate, - LatestCancellationBeforeStartDate = x.LatestCancellationBeforeStartDate, - OpenBookingPrepayment = _appSettings.FeatureFlags.PrepaymentAlwaysRequired ? null : x.Prepayment.Convert(), - AllowCustomerCancellationFullRefund = x.AllowCustomerCancellationFullRefund, - } - }, - } + // QUESTION: Should the this.IdTemplate and this.BaseUrl be passed in each time rather than set on + // the parent class? Current thinking is it's more extensible on parent class as function signature remains + // constant as power of configuration through underlying class grows (i.e. as new properties are added) + Id = RenderOpportunityId(new FacilityOpportunity + { + OpportunityType = _appSettings.FeatureFlags.FacilityUseHasSlots ? OpportunityType.FacilityUseSlot : OpportunityType.IndividualFacilityUseSlot, + FacilityUseId = x.FacilityUseId, + SlotId = x.Id, + IndividualFacilityUseId = !_appSettings.FeatureFlags.FacilityUseHasSlots ? x.IndividualFacilityUseId : null, + }), + FacilityUse = _appSettings.FeatureFlags.FacilityUseHasSlots ? + RenderOpportunityId(new FacilityOpportunity + { + OpportunityType = OpportunityType.FacilityUse, + FacilityUseId = x.FacilityUseId + }) + : RenderOpportunityId(new FacilityOpportunity + { + OpportunityType = OpportunityType.IndividualFacilityUse, + IndividualFacilityUseId = x.IndividualFacilityUseId, + FacilityUseId = x.FacilityUseId, + }), + Identifier = x.Id, + StartDate = (DateTimeOffset)x.Start, + EndDate = (DateTimeOffset)x.End, + Duration = x.End - x.Start, + RemainingUses = x.RemainingUses - x.LeasedUses, + MaximumUses = x.MaximumUses, + Offers = GenerateOffers(faker, false, x) + } + }; }); return query.ToList(); } } - private static List OpenBookingFlowRequirement(SlotTable slot) + private List GenerateOffers(Faker faker, bool isGoldenRecord, SlotTable slot) { - List openBookingFlowRequirement = null; - - if (slot.RequiresApproval) + var ageRangesForOffers = new List { - openBookingFlowRequirement = openBookingFlowRequirement ?? new List(); - openBookingFlowRequirement.Add(OpenActive.NET.OpenBookingFlowRequirement.OpenBookingApproval); - } + new QuantitativeValue {MinValue = 18, MaxValue = 59, Name = "Adult"}, + new QuantitativeValue { MaxValue = 17, Name = "Junior"}, + new QuantitativeValue {MinValue = 60, Name = "Senior"}, + new QuantitativeValue {MinValue = 18, MaxValue = 59, Name = "Adult (off-peak)"}, + }; - if (slot.RequiresAttendeeValidation) + Offer GenerateOffer(SlotTable slot, QuantitativeValue ageRange) { - openBookingFlowRequirement = openBookingFlowRequirement ?? new List(); - openBookingFlowRequirement.Add(OpenActive.NET.OpenBookingFlowRequirement.OpenBookingAttendeeDetails); + return new Offer + { + Id = RenderOfferId(new FacilityOpportunity + { + OfferId = 0, + OpportunityType = _appSettings.FeatureFlags.FacilityUseHasSlots ? OpportunityType.FacilityUseSlot : OpportunityType.IndividualFacilityUseSlot, + FacilityUseId = slot.FacilityUseId, + SlotId = slot.Id, + IndividualFacilityUseId = !_appSettings.FeatureFlags.FacilityUseHasSlots ? slot.IndividualFacilityUseId : null, + }), + Price = slot.Price, + PriceCurrency = "GBP", + OpenBookingFlowRequirement = FeedGenerationHelper.OpenBookingFlowRequirement(slot.RequiresApproval, slot.RequiresAttendeeValidation, slot.RequiresAdditionalDetails, slot.AllowsProposalAmendment), + ValidFromBeforeStartDate = slot.ValidFromBeforeStartDate, + LatestCancellationBeforeStartDate = slot.LatestCancellationBeforeStartDate, + OpenBookingPrepayment = _appSettings.FeatureFlags.PrepaymentAlwaysRequired ? null : slot.Prepayment.Convert(), + AllowCustomerCancellationFullRefund = slot.AllowCustomerCancellationFullRefund, + AcceptedPaymentMethod = new List { PaymentMethod.Cash, PaymentMethod.PaymentMethodCreditCard }, + }; } - if (slot.RequiresAdditionalDetails) - { - openBookingFlowRequirement = openBookingFlowRequirement ?? new List(); - openBookingFlowRequirement.Add(OpenActive.NET.OpenBookingFlowRequirement.OpenBookingIntakeForm); - } + var allOffersForAllAgeRanges = ageRangesForOffers.Select(ageRange => GenerateOffer(slot, ageRange)).ToList(); - if (slot.AllowsProposalAmendment) - { - openBookingFlowRequirement = openBookingFlowRequirement ?? new List(); - openBookingFlowRequirement.Add(OpenActive.NET.OpenBookingFlowRequirement.OpenBookingNegotiation); - } - return openBookingFlowRequirement; + return FeedGenerationHelper.GetRandomElementsOf(faker, allOffersForAllAgeRanges, isGoldenRecord, 1, 4).ToList(); } + } } \ No newline at end of file diff --git a/Examples/BookingSystem.AspNetCore/Feeds/SessionsFeeds.cs b/Examples/BookingSystem.AspNetCore/Feeds/SessionsFeeds.cs index b803b921..302aa382 100644 --- a/Examples/BookingSystem.AspNetCore/Feeds/SessionsFeeds.cs +++ b/Examples/BookingSystem.AspNetCore/Feeds/SessionsFeeds.cs @@ -8,6 +8,10 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using Bogus; +using BookingSystem.AspNetCore.Helpers; +using ServiceStack; +using System.Globalization; namespace BookingSystem { @@ -85,9 +89,6 @@ public AcmeSessionSeriesRpdeGenerator(AppSettings appSettings, FakeBookingSystem protected override async Task>> GetRpdeItems(long? afterTimestamp, long? afterId) { - var activityId = Environment.GetEnvironmentVariable("ACTIVITY_ID") ?? "https://openactive.io/activity-list#c07d63a0-8eb9-4602-8bcc-23be6deb8f83"; - var activityPrefLabel = Environment.GetEnvironmentVariable("ACTIVITY_PREF_LABEL") ?? "Jet Skiing"; - using (var db = _fakeBookingSystem.Database.Mem.Database.Open()) { var q = db.From() @@ -102,140 +103,352 @@ protected override async Task>> GetRpdeItems(long? var query = db .SelectMulti(q) - .Select(result => new RpdeItem + .Select(result => { - Kind = RpdeKind.SessionSeries, - Id = result.Item1.Id, - Modified = result.Item1.Modified, - State = result.Item1.Deleted ? RpdeState.Deleted : RpdeState.Updated, - Data = result.Item1.Deleted ? null : new SessionSeries + + var faker = new Faker() { Random = new Randomizer((int)result.Item1.Id) }; + // here we randomly decide whether the item is going to be a golden record or not by using Faker + // See the README for more detail on golden records. + var isGoldenRecord = faker.Random.Number(0, 1) > 0.75; + + + var sessionSeriesRpdeItem = new RpdeItem { - // QUESTION: Should the this.IdTemplate and this.BaseUrl be passed in each time rather than set on - // the parent class? Current thinking is it's more extensible on parent class as function signature remains - // constant as power of configuration through underlying class grows (i.e. as new properties are added) - Id = RenderOpportunityId(new SessionOpportunity - { - OpportunityType = OpportunityType.SessionSeries, - SessionSeriesId = result.Item1.Id - }), - Name = result.Item1.Title, - EventAttendanceMode = MapAttendanceMode(result.Item1.AttendanceMode), - Organizer = _appSettings.FeatureFlags.SingleSeller ? new Organization - { - Id = RenderSingleSellerId(), - Name = "Test Seller", - TaxMode = TaxMode.TaxGross, - TermsOfService = new List - { - new PrivacyPolicy - { - Name = "Privacy Policy", - Url = new Uri("https://example.com/privacy.html"), - RequiresExplicitConsent = false - } - }, - IsOpenBookingAllowed = true, - } : result.Item2.IsIndividual ? (ILegalEntity)new Person - { - Id = RenderSellerId(new SimpleIdComponents { IdLong = result.Item2.Id }), - Name = result.Item2.Name, - TaxMode = result.Item2.IsTaxGross ? TaxMode.TaxGross : TaxMode.TaxNet, - IsOpenBookingAllowed = true, - } : (ILegalEntity)new Organization - { - Id = RenderSellerId(new SimpleIdComponents { IdLong = result.Item2.Id }), - Name = result.Item2.Name, - TaxMode = result.Item2.IsTaxGross ? TaxMode.TaxGross : TaxMode.TaxNet, - TermsOfService = new List - { - new PrivacyPolicy - { - Name = "Privacy Policy", - Url = new Uri("https://example.com/privacy.html"), - RequiresExplicitConsent = false - } - }, - IsOpenBookingAllowed = true, - }, - Offers = new List { new Offer - { - Id = RenderOfferId(new SessionOpportunity - { - OfferOpportunityType = OpportunityType.SessionSeries, - SessionSeriesId = result.Item1.Id, - OfferId = 0 - }), - Price = result.Item1.Price, - PriceCurrency = "GBP", - OpenBookingFlowRequirement = OpenBookingFlowRequirement(result.Item1), - ValidFromBeforeStartDate = result.Item1.ValidFromBeforeStartDate, - LatestCancellationBeforeStartDate = result.Item1.LatestCancellationBeforeStartDate, - OpenBookingPrepayment = _appSettings.FeatureFlags.PrepaymentAlwaysRequired ? null : result.Item1.Prepayment.Convert(), - AllowCustomerCancellationFullRefund = result.Item1.AllowCustomerCancellationFullRefund - } - }, - Location = result.Item1.AttendanceMode == AttendanceMode.Online ? null : _fakeBookingSystem.Database.GetPlaceById(result.Item1.PlaceId), - AffiliatedLocation = result.Item1.AttendanceMode == AttendanceMode.Offline ? null : _fakeBookingSystem.Database.GetPlaceById(result.Item1.PlaceId), - Url = new Uri("https://www.example.com/a-session-age"), - Activity = new List + Kind = RpdeKind.SessionSeries, + Id = result.Item1.Id, + Modified = result.Item1.Modified, + State = result.Item1.Deleted ? RpdeState.Deleted : RpdeState.Updated, + Data = result.Item1.Deleted ? null : new SessionSeries { - new Concept + // QUESTION: Should the this.IdTemplate and this.BaseUrl be passed in each time rather than set on + // the parent class? Current thinking is it's more extensible on parent class as function signature remains + // constant as power of configuration through underlying class grows (i.e. as new properties are added) + Id = RenderOpportunityId(new SessionOpportunity { - Id = new Uri(activityId), - PrefLabel = activityPrefLabel, - InScheme = new Uri("https://openactive.io/activity-list") - } + OpportunityType = OpportunityType.SessionSeries, + SessionSeriesId = result.Item1.Id + }), + Identifier = result.Item1.Id, + Name = GetNameAndActivityForSessions(result.Item1.Title, isGoldenRecord).Name, + EventAttendanceMode = MapAttendanceMode(result.Item1.AttendanceMode), + Organizer = GenerateOrganizerOrPerson(faker, result.Item2), + Offers = GenerateOffers(faker, isGoldenRecord, result.Item1), + // location MUST not be provided for fully virtual sessions + Location = result.Item1.AttendanceMode == AttendanceMode.Online ? null : FeedGenerationHelper.GetPlaceById(result.Item1.PlaceId), + Url = new Uri($"https://www.example.com/sessions/{result.Item1.Id}"), + Activity = GetNameAndActivityForSessions(result.Item1.Title, isGoldenRecord).Activity, } + }; + + // If this instance of the Reference Implementation is in Lorem Fitsum mode, then generate a comprehensive data. + // If it is not (eg for a CI run), return only the minimal properties needed + var IsLoremFitsumMode = _appSettings.FeatureFlags.IsLoremFitsumMode; + if (IsLoremFitsumMode) + { + sessionSeriesRpdeItem.Data.Description = faker.Lorem.Paragraphs(isGoldenRecord ? 4 : faker.Random.Number(4)); + sessionSeriesRpdeItem.Data.AttendeeInstructions = FeedGenerationHelper.GenerateAttendeeInstructions(faker, isGoldenRecord); + sessionSeriesRpdeItem.Data.GenderRestriction = faker.Random.Enum(); + sessionSeriesRpdeItem.Data.AccessibilitySupport = FeedGenerationHelper.GenerateAccessibilitySupport(faker, isGoldenRecord); + sessionSeriesRpdeItem.Data.AccessibilityInformation = faker.Lorem.Paragraphs(isGoldenRecord ? 2 : faker.Random.Number(2)); + sessionSeriesRpdeItem.Data.IsWheelchairAccessible = isGoldenRecord || faker.Random.Bool() ? faker.Random.Bool() : faker.Random.ListItem(new List { true, false, null, null }); + sessionSeriesRpdeItem.Data.Category = GenerateCategory(faker, isGoldenRecord); + sessionSeriesRpdeItem.Data.Video = isGoldenRecord || faker.Random.Bool() ? new List { new VideoObject { Url = new Uri("https://www.youtube.com/watch?v=xvDZZLqlc-0") } } : null; + sessionSeriesRpdeItem.Data.Leader = GenerateListOfPersons(faker, isGoldenRecord, 2); + sessionSeriesRpdeItem.Data.Contributor = GenerateListOfPersons(faker, isGoldenRecord, 2); + sessionSeriesRpdeItem.Data.AgeRange = GenerateAgeRange(faker, isGoldenRecord); + sessionSeriesRpdeItem.Data.Image = FeedGenerationHelper.GenerateImages(faker, isGoldenRecord); + sessionSeriesRpdeItem.Data.Level = faker.Random.ListItems(new List { "Beginner", "Intermediate", "Advanced" }, 1).ToList(); + sessionSeriesRpdeItem.Data.IsCoached = isGoldenRecord || faker.Random.Bool() ? faker.Random.Bool() : faker.Random.ListItem(new List { true, false, null, null }); + // beta:affiliatedLocation MAY be provided for fully virtual sessions + sessionSeriesRpdeItem.Data.AffiliatedLocation = (result.Item1.AttendanceMode == AttendanceMode.Offline && faker.Random.Bool()) ? null : FeedGenerationHelper.GetPlaceById(result.Item1.PlaceId); + sessionSeriesRpdeItem.Data.EventSchedule = GenerateSchedules(faker, isGoldenRecord); + sessionSeriesRpdeItem.Data.SchedulingNote = GenerateSchedulingNote(faker, isGoldenRecord); + sessionSeriesRpdeItem.Data.IsAccessibleForFree = result.Item1.Price == 0; + sessionSeriesRpdeItem.Data.Programme = GenerateBrand(faker, isGoldenRecord); + sessionSeriesRpdeItem.Data.IsInteractivityPreferred = result.Item1.AttendanceMode == AttendanceMode.Offline ? null : (isGoldenRecord ? true : faker.Random.ListItem(new List { true, false, null })); + sessionSeriesRpdeItem.Data.IsVirtuallyCoached = result.Item1.AttendanceMode == AttendanceMode.Offline ? null : (isGoldenRecord ? true : faker.Random.ListItem(new List { true, false, null })); + sessionSeriesRpdeItem.Data.ParticipantSuppliedEquipment = result.Item1.AttendanceMode == AttendanceMode.Offline ? null : (isGoldenRecord ? OpenActive.NET.RequiredStatusType.Optional : faker.Random.ListItem(new List { OpenActive.NET.RequiredStatusType.Optional, OpenActive.NET.RequiredStatusType.Required, OpenActive.NET.RequiredStatusType.Unavailable, null })); + } - }); ; + + + return sessionSeriesRpdeItem; + }); return query.ToList(); } } + private static EventAttendanceModeEnumeration MapAttendanceMode(AttendanceMode attendanceMode) + { + switch (attendanceMode) + { + case AttendanceMode.Offline: + return EventAttendanceModeEnumeration.OfflineEventAttendanceMode; + case AttendanceMode.Online: + return EventAttendanceModeEnumeration.OnlineEventAttendanceMode; + case AttendanceMode.Mixed: + return EventAttendanceModeEnumeration.MixedEventAttendanceMode; + default: + throw new OpenBookingException(new OpenBookingError(), $"AttendanceMode Type {attendanceMode} not supported"); + } + } - private static List OpenBookingFlowRequirement(ClassTable @class) + private (string Name, List Activity) GetNameAndActivityForSessions(string databaseTitle, bool isGoldenRecord) { - List openBookingFlowRequirement = null; + // If both ACTIVITY_ID and ACTIVITY_PREF_LABEL env vars are set, these override the randomly generated activity. We also use these to generate an appropriate name + if (Environment.GetEnvironmentVariable("ACTIVITY_ID") != null && Environment.GetEnvironmentVariable("ACTIVITY_PREF_LABEL") != null) + { + var name = $"{(isGoldenRecord ? "GOLDEN: " : "")} {Environment.GetEnvironmentVariable("ACTIVITY_PREF_LABEL")} class"; + var concept = new Concept + { + Id = new Uri(Environment.GetEnvironmentVariable("ACTIVITY_ID")), + PrefLabel = Environment.GetEnvironmentVariable("ACTIVITY_PREF_LABEL"), + InScheme = new Uri("https://openactive.io/activity-list") + }; + + return (name, new List { concept }); + } - if (@class.RequiresApproval) + // If there isn't an override, we use the randomly generated name to derive the appropriate activity + Concept activityConcept; + switch (databaseTitle) { - openBookingFlowRequirement = openBookingFlowRequirement ?? new List(); - openBookingFlowRequirement.Add(OpenActive.NET.OpenBookingFlowRequirement.OpenBookingApproval); + case string a when a.Contains("Yoga"): + activityConcept = new Concept + { + Id = new Uri("https://openactive.io/activity-list#bf1a5e00-cdcf-465d-8c5a-6f57040b7f7e"), + PrefLabel = "Yoga", + InScheme = new Uri("https://openactive.io/activity-list") + }; + break; + case string a when a.Contains("Zumba"): + activityConcept = new Concept + { + Id = new Uri("https://openactive.io/activity-list#78503fa2-ed24-4a80-a224-e2e94581d8a8"), + PrefLabel = "Zumba®", + InScheme = new Uri("https://openactive.io/activity-list") + }; + break; + case string a when a.Contains("Walking"): + activityConcept = new Concept + { + Id = new Uri("https://openactive.io/activity-list#95092977-5a20-4d6e-b312-8fddabe71544"), + PrefLabel = "Walking", + InScheme = new Uri("https://openactive.io/activity-list") + }; + break; + case string a when a.Contains("Cycling"): + activityConcept = new Concept + { + Id = new Uri("https://openactive.io/activity-list#4a19873e-118e-43f4-b86e-05acba8fb1de"), + PrefLabel = "Cycling", + InScheme = new Uri("https://openactive.io/activity-list") + }; + break; + case string a when a.Contains("Running"): + activityConcept = new Concept + { + Id = new Uri("https://openactive.io/activity-list#72ddb2dc-7d75-424e-880a-d90eabe91381"), + PrefLabel = "Running", + InScheme = new Uri("https://openactive.io/activity-list") + }; + break; + case string a when a.Contains("Jumping"): + activityConcept = new Concept + { + Id = new Uri("https://openactive.io/activity-list#8a4abff3-c616-4f33-80a1-398b88c672a3"), + PrefLabel = "World Jumping®", + InScheme = new Uri("https://openactive.io/activity-list") + }; + break; + default: + activityConcept = new Concept + { + Id = new Uri("https://openactive.io/activity-list#c07d63a0-8eb9-4602-8bcc-23be6deb8f83"), + PrefLabel = "Jet Skiing", + InScheme = new Uri("https://openactive.io/activity-list") + }; + break; } - if (@class.RequiresAttendeeValidation) + var nameWithGolden = $"{(isGoldenRecord ? "GOLDEN: " : "")}{databaseTitle}"; + return (nameWithGolden, new List { activityConcept }); + + } + + private QuantitativeValue GenerateAgeRange(Faker faker, bool isGoldenRecord) + { + var ageRange = new QuantitativeValue(); + if (isGoldenRecord || faker.Random.Bool()) ageRange.MaxValue = faker.Random.Number(16, 100); + if (isGoldenRecord || faker.Random.Bool()) ageRange.MinValue = faker.Random.Number(0, ageRange.MaxValue == null ? 100 : (int)ageRange.MaxValue); + + if (ageRange.MaxValue == null && ageRange.MinValue == null) ageRange.MinValue = 0; + return ageRange; + } + + private ILegalEntity GenerateOrganizerOrPerson(Faker faker, SellerTable seller) + { + if (seller.IsIndividual) + return new OpenActive.NET.Person + { + Id = RenderSellerId(new SimpleIdComponents { IdLong = seller.Id }), + Name = seller.Name, + TaxMode = seller.IsTaxGross ? TaxMode.TaxGross : TaxMode.TaxNet, + IsOpenBookingAllowed = true, + Telephone = faker.Phone.PhoneNumber("07### ######") + }; + var organizationId = _appSettings.FeatureFlags.SingleSeller ? RenderSingleSellerId() : RenderSellerId(new SimpleIdComponents { IdLong = seller.Id }); + return FeedGenerationHelper.GenerateOrganization(faker, seller, _appSettings.FeatureFlags.SingleSeller, organizationId); + } + + private List GenerateCategory(Faker faker, bool isGoldenRecord) + { + var listOfPossibleCategories = new List + { + "Group Exercise Classes", + "Toning & Strength", + "Group Exercise - Virtual" + }; + + return FeedGenerationHelper.GetRandomElementsOf(faker, listOfPossibleCategories, isGoldenRecord, 1).ToList(); + } + + private List GenerateListOfPersons(Faker faker, bool isGoldenRecord, int possibleMax) + { + static OpenActive.NET.Person GeneratePerson(Faker faker, bool isGoldenRecord) + { + var id = faker.Finance.Bic(); + var genderIndex = faker.Random.Number(1); + var gender = (Bogus.DataSets.Name.Gender)genderIndex; + var givenName = faker.Name.FirstName(gender); + var familyName = faker.Name.LastName(gender); + var name = $"{givenName} {familyName}"; + var isLiteRecord = isGoldenRecord ? false : faker.Random.Bool(); + + return new OpenActive.NET.Person + { + Id = new Uri($"https://example.com/people/{id}"), + Identifier = id, + Name = name, + GivenName = isLiteRecord ? null : givenName, + FamilyName = isLiteRecord ? null : familyName, + Gender = genderIndex == 1 ? Schema.NET.GenderType.Female : Schema.NET.GenderType.Male, + JobTitle = faker.Random.ListItem(new List { "Leader", "Team leader", "Host", "Instructor", "Coach" }), + Telephone = isLiteRecord ? null : faker.Phone.PhoneNumber("07## ### ####"), + Email = isLiteRecord ? null : faker.Internet.ExampleEmail(), + Url = new Uri($"{faker.Internet.Url()}/profile/{faker.Random.Number(50)}"), + Image = new Schema.NET.ImageObject { Url = new Uri(faker.Internet.Avatar()) } + }; + } + + var output = new List(); + var max = isGoldenRecord ? possibleMax : faker.Random.Number(1, possibleMax); + for (var i = 0; i < max; i++) { - openBookingFlowRequirement = openBookingFlowRequirement ?? new List(); - openBookingFlowRequirement.Add(OpenActive.NET.OpenBookingFlowRequirement.OpenBookingAttendeeDetails); + output.Add(GeneratePerson(faker, isGoldenRecord)); } + return output; + } - if (@class.RequiresAdditionalDetails) + private List GenerateSchedules(Faker faker, bool isGoldenRecord) + { + var schedules = new List(); + PartialSchedule GenerateSchedule(Faker faker) { - openBookingFlowRequirement = openBookingFlowRequirement ?? new List(); - openBookingFlowRequirement.Add(OpenActive.NET.OpenBookingFlowRequirement.OpenBookingIntakeForm); + var startTimeString = $"{faker.Random.Number(min: 10, max: 22)}:{faker.Random.ListItem(new List { "00", "15", "30", "45" })}:00"; + var startTime = new TimeValue(startTimeString); + var duration = faker.Random.ListItem(new List { new TimeSpan(0, 30, 0), new TimeSpan(1, 0, 0), new TimeSpan(1, 30, 0), new TimeSpan(2, 0, 0) }); + var startTimeSpan = TimeSpan.Parse(startTimeString); + + var endTime = new DateTime(startTimeSpan.Add(duration).Ticks); + var endTimeString = endTime.ToString("HH:mm"); + var endTimeTM = new TimeValue(endTimeString); + var startDateFaker = faker.Date.Soon(); + var startDate = new DateValue(startDateFaker); + var endDate = new DateValue(faker.Date.Soon(28, startDateFaker)); + + var partialSchedule = new PartialSchedule + { + StartTime = startTime, + Duration = duration, + EndTime = endTimeTM, + StartDate = startDate, + EndDate = endDate, + RepeatFrequency = faker.Random.ListItem(new List { new TimeSpan(7, 0, 0, 0), new TimeSpan(14, 0, 0, 0) }), + ByDay = faker.Random.EnumValues().ToList() + }; + return partialSchedule; } - if (@class.AllowsProposalAmendment) + for (var i = 0; i < 2; i++) { - openBookingFlowRequirement = openBookingFlowRequirement ?? new List(); - openBookingFlowRequirement.Add(OpenActive.NET.OpenBookingFlowRequirement.OpenBookingNegotiation); + schedules.Add(GenerateSchedule(faker)); } - return openBookingFlowRequirement; + + return FeedGenerationHelper.GetRandomElementsOf(faker, schedules, isGoldenRecord, 1, 1).ToList(); } - private static EventAttendanceModeEnumeration MapAttendanceMode(AttendanceMode attendanceMode) + private string GenerateSchedulingNote(Faker faker, bool isGoldenRecord) { - switch (attendanceMode) + var allSchedulingNotes = new List { - case AttendanceMode.Offline: - return EventAttendanceModeEnumeration.OfflineEventAttendanceMode; - case AttendanceMode.Online: - return EventAttendanceModeEnumeration.OnlineEventAttendanceMode; - case AttendanceMode.Mixed: - return EventAttendanceModeEnumeration.MixedEventAttendanceMode; - default: - throw new OpenBookingException(new OpenBookingError(), $"AttendanceMode Type {attendanceMode} not supported"); + "Sessions are not running during school holidays.", + "Sessions may be cancelled with 15 minutes notice, please keep an eye on your e-mail.", + "Sessions are scheduled with best intentions, but sometimes need to be rescheduled due to venue availability. Ensure that you contact the organizer before turning up." + }; + + if (isGoldenRecord) return faker.Random.ListItem(allSchedulingNotes); + return faker.Random.Bool() ? faker.Random.ListItem(allSchedulingNotes) : null; + } + + private List GenerateOffers(Faker faker, bool isGoldenRecord, ClassTable @class) + { + var ageRangesForOffers = new List + { + new QuantitativeValue {MinValue = 18, MaxValue = 59, Name = "Adult"}, + new QuantitativeValue { MaxValue = 17, Name = "Junior"}, + new QuantitativeValue {MinValue = 60, Name = "Senior"}, + new QuantitativeValue {MinValue = 18, MaxValue = 59, Name = "Adult (off-peak)"}, + }; + + Offer GenerateOffer(ClassTable @class, QuantitativeValue ageRange) + { + return new Offer + { + Id = RenderOfferId(new SessionOpportunity + { + OfferOpportunityType = OpportunityType.SessionSeries, + SessionSeriesId = @class.Id, + OfferId = 0 + }), + Price = @class.Price, + PriceCurrency = "GBP", + Name = ageRange.Name, + OpenBookingFlowRequirement = FeedGenerationHelper.OpenBookingFlowRequirement(@class.RequiresApproval, @class.RequiresAttendeeValidation, @class.RequiresAdditionalDetails, @class.AllowsProposalAmendment), + ValidFromBeforeStartDate = @class.ValidFromBeforeStartDate, + LatestCancellationBeforeStartDate = @class.LatestCancellationBeforeStartDate, + OpenBookingPrepayment = _appSettings.FeatureFlags.PrepaymentAlwaysRequired ? null : @class.Prepayment.Convert(), + AllowCustomerCancellationFullRefund = @class.AllowCustomerCancellationFullRefund, + AcceptedPaymentMethod = new List { PaymentMethod.Cash, PaymentMethod.PaymentMethodCreditCard }, + AgeRestriction = ageRange, + }; } + + var allOffersForAllAgeRanges = ageRangesForOffers.Select(ageRange => GenerateOffer(@class, ageRange)).ToList(); + + return FeedGenerationHelper.GetRandomElementsOf(faker, allOffersForAllAgeRanges, isGoldenRecord, 1, 2).ToList(); + } + + private Brand GenerateBrand(Faker faker, bool isGoldenRecord) + { + return new Brand + { + Name = faker.Random.ListItem(new List { "Keyways Active", "This Girl Can", "Back to Activity", "Mega-active Super Dads" }), + Url = new Uri(faker.Internet.Url()), + Description = faker.Lorem.Paragraphs(isGoldenRecord ? 4 : faker.Random.Number(4)), + Logo = new ImageObject { Url = new Uri(faker.Internet.Avatar()) }, + Video = new List { new VideoObject { Url = new Uri("https://www.youtube.com/watch?v=N268gBOvnzo") } } + }; } } } diff --git a/Examples/BookingSystem.AspNetCore/Helpers/FeedGenerationHelper.cs b/Examples/BookingSystem.AspNetCore/Helpers/FeedGenerationHelper.cs new file mode 100644 index 00000000..cc099062 --- /dev/null +++ b/Examples/BookingSystem.AspNetCore/Helpers/FeedGenerationHelper.cs @@ -0,0 +1,306 @@ +using System; +using OpenActive.NET; +using System.Collections.Generic; +using OpenActive.FakeDatabase.NET; +using Bogus; +using System.Linq; +using Bogus.DataSets; +using OpenActive.Server.NET.OpenBookingHelper; +using System.Security.Policy; + + + +namespace BookingSystem.AspNetCore.Helpers +{ + public static class FeedGenerationHelper + { + public static IList GetRandomElementsOf(Faker faker, IList list, bool isGoldenRecord, int minimumNumberOfElements = 0, int maximumNumberOfElements = 0) + { + // If this is for the golden record, return the whole list so that all the possible data values are returned + if (isGoldenRecord) return list; + + // If maximumNumberOfElements is the default value, use list.Count, if it's been set, use that + var max = maximumNumberOfElements == 0 ? list.Count : maximumNumberOfElements; + // Otherwise return a random number of elements from the list + var randomNumberOfElementsToReturn = faker.Random.Number(minimumNumberOfElements, max); + return faker.Random.ListItems(list, randomNumberOfElementsToReturn); + } + + + public static Place GetPlaceById(long placeId) + { + // Three hardcoded fake places + switch (placeId) + { + case 1: + return new Place + { + Identifier = 1, + Id = new Uri($"https://example.com/place/{placeId}"), + Name = "Post-ercise Plaza", + Description = "Sorting Out Your Fitness One Parcel Lift at a Time! Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", + Address = new PostalAddress + { + StreetAddress = "Kings Mead House", + AddressLocality = "Oxford", + AddressRegion = "Oxfordshire", + PostalCode = "OX1 1AA", + AddressCountry = "GB" + }, + Geo = new GeoCoordinates + { + Latitude = (decimal?)51.7502, + Longitude = (decimal?)-1.2674 + }, + Image = new List { + new ImageObject + { + Url = new Uri("https://upload.wikimedia.org/wikipedia/commons/e/e5/Oxford_StAldates_PostOffice.jpg") + }, + }, + Telephone = "01865 000001", + Url = new Uri("https://en.wikipedia.org/wiki/Post_Office_Limited"), + AmenityFeature = new List + { + new ChangingFacilities { Name = "Changing Facilities", Value = true }, + new Showers { Name = "Showers", Value = true }, + new Lockers { Name = "Lockers", Value = true }, + new Towels { Name = "Towels", Value = false }, + new Creche { Name = "Creche", Value = false }, + new Parking { Name = "Parking", Value = false } + }, + OpeningHoursSpecification = new List + { + new OpeningHoursSpecification {DayOfWeek = new List {Schema.NET.DayOfWeek.Sunday }, Opens = "09:00", Closes = "17:30"}, + new OpeningHoursSpecification {DayOfWeek = new List {Schema.NET.DayOfWeek.Monday }, Opens = "06:30", Closes = "21:30"}, + new OpeningHoursSpecification {DayOfWeek = new List {Schema.NET.DayOfWeek.Tuesday }, Opens = "06:30", Closes = "21:30"}, + new OpeningHoursSpecification {DayOfWeek = new List {Schema.NET.DayOfWeek.Wednesday }, Opens = "06:30", Closes = "21:30"}, + new OpeningHoursSpecification {DayOfWeek = new List {Schema.NET.DayOfWeek.Thursday }, Opens = "06:30", Closes = "21:30"}, + new OpeningHoursSpecification {DayOfWeek = new List {Schema.NET.DayOfWeek.Friday }, Opens = "06:30", Closes = "21:30"}, + new OpeningHoursSpecification {DayOfWeek = new List {Schema.NET.DayOfWeek.Saturday }, Opens = "09:00", Closes = "17:30"} + } + }; + case 2: + return new Place + { + Identifier = 2, + Id = new Uri($"https://example.com/place/{placeId}"), + Name = "Premier Lifters", + Description = "Where your Fitness Goals are Always Inn-Sight. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", + Address = new PostalAddress + { + StreetAddress = "Greyfriars Court, Paradise Square", + AddressLocality = "Oxford", + AddressRegion = "Oxfordshire", + PostalCode = "OX1 1BB", + AddressCountry = "GB" + }, + Geo = new GeoCoordinates + { + Latitude = (decimal?)51.7504933, + Longitude = (decimal?)-1.2620685 + }, + Image = new List { + new ImageObject + { + Url = new Uri("https://upload.wikimedia.org/wikipedia/commons/5/53/Cambridge_Orchard_Park_Premier_Inn.jpg") + }, + }, + Telephone = "01865 000002", + Url = new Uri("https://en.wikipedia.org/wiki/Premier_Inn"), + AmenityFeature = new List + { + new ChangingFacilities { Name = "Changing Facilities", Value = false }, + new Showers { Name = "Showers", Value = false }, + new Lockers { Name = "Lockers", Value = false }, + new Towels { Name = "Towels", Value = true }, + new Creche { Name = "Creche", Value = true }, + new Parking { Name = "Parking", Value = true } + }, + OpeningHoursSpecification = new List + { + new OpeningHoursSpecification {DayOfWeek = new List {Schema.NET.DayOfWeek.Sunday }, Opens = "09:00", Closes = "17:30"}, + new OpeningHoursSpecification {DayOfWeek = new List {Schema.NET.DayOfWeek.Monday }, Opens = "06:30", Closes = "21:30"}, + new OpeningHoursSpecification {DayOfWeek = new List {Schema.NET.DayOfWeek.Tuesday }, Opens = "06:30", Closes = "21:30"}, + new OpeningHoursSpecification {DayOfWeek = new List {Schema.NET.DayOfWeek.Wednesday }, Opens = "06:30", Closes = "21:30"}, + new OpeningHoursSpecification {DayOfWeek = new List {Schema.NET.DayOfWeek.Thursday }, Opens = "06:30", Closes = "21:30"}, + new OpeningHoursSpecification {DayOfWeek = new List {Schema.NET.DayOfWeek.Friday }, Opens = "06:30", Closes = "21:30"}, + new OpeningHoursSpecification {DayOfWeek = new List {Schema.NET.DayOfWeek.Saturday }, Opens = "09:00", Closes = "17:30"} + } + }; + case 3: + return new Place + { + Identifier = 3, + Id = new Uri($"https://example.com/place/{placeId}"), + Name = "Stroll & Stretch", + Description = "Casual Calisthenics in the Heart of Commerce. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", + Address = new PostalAddress + { + StreetAddress = "Norfolk Street", + AddressLocality = "Oxford", + AddressRegion = "Oxfordshire", + PostalCode = "OX1 1UU", + AddressCountry = "GB" + }, + Geo = new GeoCoordinates + { + Latitude = (decimal?)51.749826, + Longitude = (decimal?)-1.261492 + }, + Image = new List { + new ImageObject + { + Url = new Uri("https://upload.wikimedia.org/wikipedia/commons/2/28/Westfield_Garden_State_Plaza_-_panoramio.jpg") + }, + }, + Telephone = "01865 000003", + Url = new Uri("https://en.wikipedia.org/wiki/Shopping_center"), + OpeningHoursSpecification = new List + { + new OpeningHoursSpecification {DayOfWeek = new List {Schema.NET.DayOfWeek.Sunday }, Opens = "09:00", Closes = "17:30"}, + new OpeningHoursSpecification {DayOfWeek = new List {Schema.NET.DayOfWeek.Monday }, Opens = "06:30", Closes = "21:30"}, + new OpeningHoursSpecification {DayOfWeek = new List {Schema.NET.DayOfWeek.Tuesday }, Opens = "06:30", Closes = "21:30"}, + new OpeningHoursSpecification {DayOfWeek = new List {Schema.NET.DayOfWeek.Wednesday }, Opens = "06:30", Closes = "21:30"}, + new OpeningHoursSpecification {DayOfWeek = new List {Schema.NET.DayOfWeek.Thursday }, Opens = "06:30", Closes = "21:30"}, + new OpeningHoursSpecification {DayOfWeek = new List {Schema.NET.DayOfWeek.Friday }, Opens = "06:30", Closes = "21:30"}, + new OpeningHoursSpecification {DayOfWeek = new List {Schema.NET.DayOfWeek.Saturday }, Opens = "09:00", Closes = "17:30"} + } + }; + default: + return null; + } + } + + public static List OpenBookingFlowRequirement(bool requiresApproval, bool requiresAttendeeValidation, bool requiresAdditionalDetails, bool allowsProposalAmendment) + { + List openBookingFlowRequirement = null; + + if (requiresApproval) + { + openBookingFlowRequirement = openBookingFlowRequirement ?? new List(); + openBookingFlowRequirement.Add(OpenActive.NET.OpenBookingFlowRequirement.OpenBookingApproval); + } + + if (requiresAttendeeValidation) + { + openBookingFlowRequirement = openBookingFlowRequirement ?? new List(); + openBookingFlowRequirement.Add(OpenActive.NET.OpenBookingFlowRequirement.OpenBookingAttendeeDetails); + } + + if (requiresAdditionalDetails) + { + openBookingFlowRequirement = openBookingFlowRequirement ?? new List(); + openBookingFlowRequirement.Add(OpenActive.NET.OpenBookingFlowRequirement.OpenBookingIntakeForm); + } + + if (allowsProposalAmendment) + { + openBookingFlowRequirement = openBookingFlowRequirement ?? new List(); + openBookingFlowRequirement.Add(OpenActive.NET.OpenBookingFlowRequirement.OpenBookingNegotiation); + } + return openBookingFlowRequirement; + } + + public static string GenerateAttendeeInstructions(Faker faker, bool isGoldenRecord) + { + var listOfPossibleInstructions = new List(){ + "wear sportswear/gym clothes", + "wear comfortable loose clothing", + "come as you are", + "bring trainers", + "wear flat shoes", + "no footwear required" + }; + + return $"Clothing instructions: {string.Join(", ", GetRandomElementsOf(faker, listOfPossibleInstructions, isGoldenRecord, 1))}"; + } + + public static List GenerateAccessibilitySupport(Faker faker, bool isGoldenRecord) + { + var listOfAccessibilitySupports = new List + { + new Concept {Id = new Uri("https://openactive.io/accessibility-support#1393f2dc-3fcc-4be9-a99f-f1e51f5ad277"), PrefLabel = "Visual Impairment", InScheme = new Uri("https://openactive.io/accessibility-support")}, + new Concept {Id = new Uri("https://openactive.io/accessibility-support#2bfb7228-5969-4927-8435-38b5005a8771"), PrefLabel = "Hearing Impairment", InScheme = new Uri("https://openactive.io/accessibility-support")}, + new Concept {Id = new Uri("https://openactive.io/accessibility-support#40b9b11f-bdd3-4aeb-8984-2ecf74a14c7a"), PrefLabel = "Mental health issues", InScheme = new Uri("https://openactive.io/accessibility-support")} + }; + + return GetRandomElementsOf(faker, listOfAccessibilitySupports, isGoldenRecord, 1, 2).ToList(); + } + + public static List GenerateImages(Faker faker, bool isGoldenRecord) + { + static Uri GenerateImageUrl(int width, int height, int seed) + { + return new Uri($"https://picsum.photos/{width}/{height}?image={seed}"); + } + + var images = new List(); + var min = isGoldenRecord ? 4 : 1; + var imageCount = faker.Random.Number(min, 3); + for (var i = 0; i < imageCount; i++) + { + var imageSeed = faker.Random.Number(1083); + var thumbnails = new List { + new ImageObject{Url = GenerateImageUrl(672, 414, imageSeed), Width = 672, Height = 414}, + new ImageObject{Url = GenerateImageUrl(300, 200, imageSeed), Width = 300, Height = 200}, + new ImageObject{Url = GenerateImageUrl(100, 100, imageSeed), Width = 100, Height = 100} + }; + var image = new ImageObject + { + Url = GenerateImageUrl(1024, 724, imageSeed), + Thumbnail = GetRandomElementsOf(faker, thumbnails, isGoldenRecord, 1, 1).ToList() + }; + images.Add(image); + } + return images; + } + + public static Organization GenerateOrganization(Faker faker, SellerTable seller, bool isSingleSeller, Uri organizationId) + { + if (isSingleSeller) + return new Organization + { + Id = organizationId, + Name = "Test Seller", + TaxMode = TaxMode.TaxGross, + TermsOfService = new List + { + new PrivacyPolicy + { + Name = "Privacy Policy", + Url = new Uri("https://example.com/privacy.html"), + RequiresExplicitConsent = false + } + }, + IsOpenBookingAllowed = true, + Telephone = faker.Phone.PhoneNumber("0#### ######"), + SameAs = new List { new Uri("https://socialmedia.com/testseller") } + }; + + return new Organization + { + Id = organizationId, + Name = seller.Name, + TaxMode = seller.IsTaxGross ? TaxMode.TaxGross : TaxMode.TaxNet, + TermsOfService = new List + { + new PrivacyPolicy + { + Name = "Privacy Policy", + Url = new Uri("https://example.com/privacy.html"), + RequiresExplicitConsent = false + } + }, + IsOpenBookingAllowed = true, + Url = new Uri(faker.Internet.Url()), + Telephone = faker.Phone.PhoneNumber("0#### ######"), + SameAs = new List { new Uri($"https://socialmedia.com/{seller.Name.Replace(" ", "")}") } + }; + + + } + + } +} + diff --git a/Examples/BookingSystem.AspNetCore/Properties/launchSettings.json b/Examples/BookingSystem.AspNetCore/Properties/launchSettings.json index 30b0586b..b6b622f3 100644 --- a/Examples/BookingSystem.AspNetCore/Properties/launchSettings.json +++ b/Examples/BookingSystem.AspNetCore/Properties/launchSettings.json @@ -6,7 +6,6 @@ "profiles": { "BookingSystem.AspNetCore": { "commandName": "Project", - "launchBrowser": true, "launchUrl": "https://localhost:5001/openactive", "applicationUrl": "https://localhost:5001", "environmentVariables": { diff --git a/Examples/BookingSystem.AspNetCore/README.md b/Examples/BookingSystem.AspNetCore/README.md index 692ebc06..38bd0a32 100644 --- a/Examples/BookingSystem.AspNetCore/README.md +++ b/Examples/BookingSystem.AspNetCore/README.md @@ -2,8 +2,68 @@ An example OpenActive.Server.NET implementation. -This implementation is also used as a reference implementation for the [Test Suite](https://github.com/openactive/openactive-test-suite) to run its tests against. +This implementation is also used as a reference implementation for the [Test Suite](https://github.com/openactive/openactive-test-suite) to run its tests against and therefore is often to as Reference Implementation. +Until there are more reference implementations, all references to Reference Implementation refer to this implementation and Reference Implementation and BookingSystem.AspNetCore can be used interchangeably. -## Running Locally +## Running Locally using Visual Studio + +In Visual Studio, run the BookingSystem.AspNetCore project + +When it's finished building, it will open a page in your browser on port 5001. + +Head to `http://localhost:5001/openactive` to check that the project is running correctly. You should see an Open Data landing page. See the [project contribution documentation](/CONTRIBUTING.md) for details on how to run BookingSystem.AspNetCore locally. + +## Running Locally using the CLI + +Open a terminal in `Examples/BookingSystem.AspNetCore` directory + +Run: + +```sh +dotnet run +``` + +If you want to start BookingSystem.AspNetCore in a specific environment run the following: + +```sh +ASPNETCORE_ENVIRONMENT=no-auth dotnet run --no-launch-profile --project ./BookingSystem.AspNetCore.csproj --configuration Release --no-build +``` + +The above example starts the BookingSystem.AspNetCore in `no-auth` mode. + +## BookingSystem.AspNetCore Data Generation + +BookingSystem.AspNetCore has three main uses that make it very important in the OpenActive ecosystem: +- For data publishers / booking systems: It is used to demonstrate the properties and shape of data and APIs, according to the OpenActive specifications +- For data users / brokers: It is used as a trial integration where testing can be done with no ramifications +- For contributors: It is used to ensure the Test Suite tests are correct and passing, for different combinations of Open Booking API features. + +The data for the sample feeds are generated in two places: +- BookingSystem.AspNetCore/Feeds/*Feeds.cs +- OpenActive.FakeDatabase.NET/Fakes/FakeBookingSystem.cs + +The FakeBookingSystem within OpenActive.FakeDatabase.NET acts as the interface to an example database. +The example Feeds within BookingSystem.AspNetCore query this interface and translate the data to conform with the OpenActive Modelling Spec. + +Due to this split of functionality, the sample data in the feeds are created/transformed in both files, depending on whether they are important to booking +or not. For example, `Price` is important to booking and there is generated in FakeBookingSystem at startup and stored in the in-memory database. However `Terms Of Service` is not +needed for booking, and therefore is generated at request time. + +### Lorem Fitsum mode +When BookingSystem.AspNetCore is run in Lorem Fitsum (a play on [Lorem Ipsum](https://en.wikipedia.org/wiki/Lorem_ipsum)) mode, the data generated contains all the possible fields specified by the OpenActive Modelling Specification. +They are unrealistic representations of data, and the presence of all the fields should not be relied on when developing front-end representations of the data. +However it is very useful for data consumers and deciding on how to present the data to the users. + +Lorem Fitsum mode can be running by setting the environment variable `IS_LOREM_FITSUM_MODE` to `true`. +In Visual Studio this can be done in Properties > BookingSystem.AspNetCore Properties > Run > Default > Environment Variables. +In the CLI this can be done by running the following command for example: + +```sh +IS_LOREM_FITSUM_MODE=true dotnet run --no-launch-profile --project ./BookingSystem.AspNetCore.csproj --configuration Release --no-build +``` + +### Golden Records +Golden records are randomly generated records that have maximally enriched properties in the generated data. For example where a record might have one image normally, a golden record will have four. + diff --git a/Examples/BookingSystem.AspNetCore/Settings/AppSettings.cs b/Examples/BookingSystem.AspNetCore/Settings/AppSettings.cs index aa120720..07edbb7c 100644 --- a/Examples/BookingSystem.AspNetCore/Settings/AppSettings.cs +++ b/Examples/BookingSystem.AspNetCore/Settings/AppSettings.cs @@ -20,6 +20,7 @@ public class FeatureSettings public bool OnlyFreeOpportunities { get; set; } = false; public bool PrepaymentAlwaysRequired { get; set; } = false; public bool FacilityUseHasSlots { get; set; } = false; + public bool IsLoremFitsumMode { get; set; } = false; } public class PaymentSettings diff --git a/Examples/BookingSystem.AspNetCore/Startup.cs b/Examples/BookingSystem.AspNetCore/Startup.cs index fb4b61b8..c02b7b91 100644 --- a/Examples/BookingSystem.AspNetCore/Startup.cs +++ b/Examples/BookingSystem.AspNetCore/Startup.cs @@ -20,14 +20,22 @@ public Startup(IConfiguration configuration) configuration.Bind(AppSettings); // Provide a simple way to disable token auth for some testing scenarios - if (System.Environment.GetEnvironmentVariable("DISABLE_TOKEN_AUTH") == "true") { + if (System.Environment.GetEnvironmentVariable("DISABLE_TOKEN_AUTH") == "true") + { AppSettings.FeatureFlags.EnableTokenAuth = false; } // Provide a simple way to enable FacilityUseHasSlots for some testing scenarios - if (System.Environment.GetEnvironmentVariable("FACILITY_USE_HAS_SLOTS") == "true") { + if (System.Environment.GetEnvironmentVariable("FACILITY_USE_HAS_SLOTS") == "true") + { AppSettings.FeatureFlags.FacilityUseHasSlots = true; } + + // Provide a simple way to enable CI mode + if (System.Environment.GetEnvironmentVariable("IS_LOREM_FITSUM_MODE") == "true") + { + AppSettings.FeatureFlags.IsLoremFitsumMode = true; + } } public AppSettings AppSettings { get; } diff --git a/Examples/BookingSystem.AspNetCore/Stores/FacilityStore.cs b/Examples/BookingSystem.AspNetCore/Stores/FacilityStore.cs index 8737ba2e..a8381c34 100644 --- a/Examples/BookingSystem.AspNetCore/Stores/FacilityStore.cs +++ b/Examples/BookingSystem.AspNetCore/Stores/FacilityStore.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using BookingSystem.AspNetCore.Helpers; using OpenActive.DatasetSite.NET; using OpenActive.FakeDatabase.NET; using OpenActive.NET; @@ -320,7 +321,7 @@ protected override async Task GetOrderItems(List { new Concept { @@ -374,11 +375,11 @@ protected override async Task GetOrderItems(List { new Concept diff --git a/Examples/BookingSystem.AspNetFramework/Extensions/BookedOrderItemHelper.cs b/Examples/BookingSystem.AspNetFramework/Extensions/BookedOrderItemHelper.cs index 5afcd484..997599fd 100644 --- a/Examples/BookingSystem.AspNetFramework/Extensions/BookedOrderItemHelper.cs +++ b/Examples/BookingSystem.AspNetFramework/Extensions/BookedOrderItemHelper.cs @@ -33,7 +33,7 @@ public static void AddPropertiesToBookedOrderItem(IOrderItemContext ctx, BookedO new PropertyValue() { Name = "Pin Code", - Description = bookedOrderItemInfo.PinCode, + Description = bookedOrderItemInfo.PinCode } }; ctx.ResponseOrderItem.AccessPass = new List diff --git a/Examples/BookingSystem.AspNetFramework/Feeds/FacilitiesFeeds.cs b/Examples/BookingSystem.AspNetFramework/Feeds/FacilitiesFeeds.cs index e348d1e9..9bf1b691 100644 --- a/Examples/BookingSystem.AspNetFramework/Feeds/FacilitiesFeeds.cs +++ b/Examples/BookingSystem.AspNetFramework/Feeds/FacilitiesFeeds.cs @@ -1,4 +1,6 @@ -using OpenActive.DatasetSite.NET; +using Bogus; +using BookingSystem.AspNetCore.Helpers; +using OpenActive.DatasetSite.NET; using OpenActive.FakeDatabase.NET; using OpenActive.NET; using OpenActive.NET.Rpde.Version1; @@ -43,80 +45,155 @@ protected override async Task>> GetRpdeItems(long? af var query = db .SelectMulti(q) - .Select(result => new RpdeItem + .Select(result => { - Kind = RpdeKind.FacilityUse, - Id = result.Item1.Id, - Modified = result.Item1.Modified, - State = result.Item1.Deleted ? RpdeState.Deleted : RpdeState.Updated, - Data = result.Item1.Deleted ? null : new FacilityUse + var faker = new Faker() { Random = new Randomizer((int)result.Item1.Id) }; + var isGoldenRecord = faker.Random.Bool(); + + return new RpdeItem { - // QUESTION: Should the this.IdTemplate and this.BaseUrl be passed in each time rather than set on - // the parent class? Current thinking is it's more extensible on parent class as function signature remains - // constant as power of configuration through underlying class grows (i.e. as new properties are added) - Id = RenderOpportunityId(new FacilityOpportunity - { - OpportunityType = OpportunityType.FacilityUse, // isIndividual?? - FacilityUseId = result.Item1.Id - }), - Name = result.Item1.Name, - Provider = _appSettings.FeatureFlags.SingleSeller ? new Organization - { - Id = RenderSingleSellerId(), - Name = "Test Seller", - TaxMode = TaxMode.TaxGross, - TermsOfService = new List - { - new PrivacyPolicy - { - Name = "Privacy Policy", - Url = new Uri("https://example.com/privacy.html"), - RequiresExplicitConsent = false - } - }, - IsOpenBookingAllowed = true, - } : new Organization - { - Id = RenderSellerId(new SimpleIdComponents { IdLong = result.Item2.Id }), - Name = result.Item2.Name, - TaxMode = result.Item2.IsTaxGross ? TaxMode.TaxGross : TaxMode.TaxNet, - TermsOfService = new List - { - new PrivacyPolicy - { - Name = "Privacy Policy", - Url = new Uri("https://example.com/privacy.html"), - RequiresExplicitConsent = false - } - }, - IsOpenBookingAllowed = true, - }, - Location = _fakeBookingSystem.Database.GetPlaceById(result.Item1.PlaceId), - Url = new Uri("https://www.example.com/a-session-age"), - FacilityType = new List { - new Concept - { - Id = new Uri(facilityTypeId), - PrefLabel = facilityTypePrefLabel, - InScheme = new Uri("https://openactive.io/facility-types") - } - }, - IndividualFacilityUse = result.Item1.IndividualFacilityUses != null ? result.Item1.IndividualFacilityUses.Select(ifu => new OpenActive.NET.IndividualFacilityUse + Kind = RpdeKind.FacilityUse, + Id = result.Item1.Id, + Modified = result.Item1.Modified, + State = result.Item1.Deleted ? RpdeState.Deleted : RpdeState.Updated, + Data = result.Item1.Deleted ? null : new FacilityUse { + // QUESTION: Should the this.IdTemplate and this.BaseUrl be passed in each time rather than set on + // the parent class? Current thinking is it's more extensible on parent class as function signature remains + // constant as power of configuration through underlying class grows (i.e. as new properties are added) Id = RenderOpportunityId(new FacilityOpportunity { - OpportunityType = OpportunityType.IndividualFacilityUse, - IndividualFacilityUseId = ifu.Id, + OpportunityType = OpportunityType.FacilityUse, // isIndividual?? FacilityUseId = result.Item1.Id }), - Name = ifu.Name - }).ToList() : null, - } + Identifier = result.Item1.Id, + Name = GetNameAndFacilityTypeForFacility(result.Item1.Name, isGoldenRecord).Name, + Description = faker.Lorem.Paragraphs(isGoldenRecord ? 4 : faker.Random.Number(4)), + Provider = FeedGenerationHelper.GenerateOrganization( + faker, + result.Item2, + _appSettings.FeatureFlags.SingleSeller, + _appSettings.FeatureFlags.SingleSeller ? RenderSingleSellerId() : RenderSellerId(new SimpleIdComponents { IdLong = result.Item2.Id }) + ), + Url = new Uri($"https://www.example.com/facilities/{result.Item1.Id}"), + AttendeeInstructions = FeedGenerationHelper.GenerateAttendeeInstructions(faker, isGoldenRecord), + AccessibilitySupport = FeedGenerationHelper.GenerateAccessibilitySupport(faker, isGoldenRecord), + AccessibilityInformation = faker.Lorem.Paragraphs(isGoldenRecord ? 2 : faker.Random.Number(2)), + IsWheelchairAccessible = isGoldenRecord || faker.Random.Bool() ? faker.Random.Bool() : faker.Random.ListItem(new List { true, false, null, null }), + Category = GenerateCategory(faker, isGoldenRecord), + Image = FeedGenerationHelper.GenerateImages(faker, isGoldenRecord), + Video = isGoldenRecord || faker.Random.Bool() ? new List { new VideoObject { Url = new Uri("https://www.youtube.com/watch?v=xvDZZLqlc-0") } } : null, + Location = FeedGenerationHelper.GetPlaceById(result.Item1.PlaceId), + FacilityType = GetNameAndFacilityTypeForFacility(result.Item1.Name, isGoldenRecord).Facility, + IndividualFacilityUse = result.Item1.IndividualFacilityUses != null ? result.Item1.IndividualFacilityUses.Select(ifu => new OpenActive.NET.IndividualFacilityUse + { + Id = RenderOpportunityId(new FacilityOpportunity + { + OpportunityType = OpportunityType.IndividualFacilityUse, + IndividualFacilityUseId = ifu.Id, + FacilityUseId = result.Item1.Id + }), + Name = ifu.Name + }).ToList() : null, + } + }; }); return query.ToList(); } } + + private (string Name, List Facility) GetNameAndFacilityTypeForFacility(string databaseTitle, bool isGoldenRecord) + { + // If both FACILITY_TYPE_ID and FACILITY_TYPE_PREF_LABEL env vars are set, these override the randomly generated activity. We also use these to generate an appropriate name + if (Environment.GetEnvironmentVariable("FACILITY_TYPE_ID") != null && Environment.GetEnvironmentVariable("FACILITY_TYPE_PREF_LABEL") != null) + { + var name = $"{(isGoldenRecord ? "GOLDEN: " : "")} {Environment.GetEnvironmentVariable("FACILITY_TYPE_PREF_LABEL")} facility"; + var concept = new Concept + { + Id = new Uri(Environment.GetEnvironmentVariable("FACILITY_TYPE_ID")), + PrefLabel = Environment.GetEnvironmentVariable("FACILITY_TYPE_PREF_LABEL"), + InScheme = new Uri("https://openactive.io/activity-list") + }; + + return (name, new List { concept }); + } + + // If there isn't an override, we use the randomly generated name to derive the appropriate activity + Concept facilityConcept; + switch (databaseTitle) + { + case string a when a.Contains("Sports Hall"): + facilityConcept = new Concept + { + Id = new Uri("https://openactive.io/facility-types#da364f9b-8bb2-490e-9e2f-1068790b9e35"), + PrefLabel = "Sports Hall", + InScheme = new Uri("https://openactive.io/facility-types") + }; + break; + case string a when a.Contains("Squash Court"): + facilityConcept = new Concept + { + Id = new Uri("https://openactive.io/facility-types#a1f82b7a-1258-4d9a-8dc5-bfc2ae961651"), + PrefLabel = "Squash Court", + InScheme = new Uri("https://openactive.io/facility-types") + }; + break; + case string a when a.Contains("Badminton Court"): + facilityConcept = new Concept + { + Id = new Uri("https://openactive.io/facility-types#9db5681e-700e-4b30-99a5-355885d94db2"), + PrefLabel = "Badminton Court", + InScheme = new Uri("https://openactive.io/facility-types") + }; + break; + case string a when a.Contains("Cricket Net"): + facilityConcept = new Concept + { + Id = new Uri("https://openactive.io/facility-types#2d333183-6a6d-4a95-aad4-c5699f705b14"), + PrefLabel = "Cricket Net", + InScheme = new Uri("https://openactive.io/facility-types") + }; + break; + default: + facilityConcept = new Concept + { + Id = new Uri("https://openactive.io/facility-types#a1f82b7a-1258-4d9a-8dc5-bfc2ae961651"), + PrefLabel = "Squash Court", + InScheme = new Uri("https://openactive.io/facility-types") + }; + break; + } + + var nameWithGolden = $"{(isGoldenRecord ? "GOLDEN: " : "")}{databaseTitle}"; + return (nameWithGolden, new List { facilityConcept }); + + } + + private List GenerateCategory(Faker faker, bool isGoldenRecord) + { + var listOfPossibleCategories = new List + { + "Bookable Facilities", + "Ball Sports", + }; + + return FeedGenerationHelper.GetRandomElementsOf(faker, listOfPossibleCategories, isGoldenRecord, 1).ToList(); + } + + private List GenerateOpeningHours(Faker faker) + { + return new List + { + new OpeningHoursSpecification {DayOfWeek = new List {Schema.NET.DayOfWeek.Sunday }, Opens = $"{faker.Random.Number(9,12)}:00", Closes = $"{faker.Random.Number(15,17)}:30"}, + new OpeningHoursSpecification {DayOfWeek = new List {Schema.NET.DayOfWeek.Monday }, Opens = $"{faker.Random.Number(6,10)}:00", Closes = $"{faker.Random.Number(18,21)}:30"}, + new OpeningHoursSpecification {DayOfWeek = new List {Schema.NET.DayOfWeek.Tuesday }, Opens = $"{faker.Random.Number(6,10)}:00", Closes = $"{faker.Random.Number(18,21)}:30"}, + new OpeningHoursSpecification {DayOfWeek = new List {Schema.NET.DayOfWeek.Wednesday }, Opens = $"{faker.Random.Number(6,10)}:00", Closes = $"{faker.Random.Number(18,21)}:30"}, + new OpeningHoursSpecification {DayOfWeek = new List {Schema.NET.DayOfWeek.Thursday }, Opens = $"{faker.Random.Number(6,10)}:00", Closes = $"{faker.Random.Number(18,21)}:30"}, + new OpeningHoursSpecification {DayOfWeek = new List {Schema.NET.DayOfWeek.Friday }, Opens = $"{faker.Random.Number(6,10)}:00", Closes = $"{faker.Random.Number(18,21)}:30"}, + new OpeningHoursSpecification {DayOfWeek = new List {Schema.NET.DayOfWeek.Saturday }, Opens = $"{faker.Random.Number(9,12)}:00", Closes = $"{faker.Random.Number(15,17)}:30"} + }; + } } public class AcmeFacilityUseSlotRpdeGenerator : RpdeFeedModifiedTimestampAndIdLong @@ -144,96 +221,91 @@ protected override async Task>> GetRpdeItems(long? afterTime x.Modified == afterTimestamp && x.Id > afterId && x.Modified < (DateTimeOffset.UtcNow - new TimeSpan(0, 0, 2)).UtcTicks) .Take(RpdePageSize) - .Select(x => new RpdeItem + .Select(x => { - Kind = _appSettings.FeatureFlags.FacilityUseHasSlots ? RpdeKind.FacilityUseSlot : RpdeKind.IndividualFacilityUseSlot, - Id = x.Id, - Modified = x.Modified, - State = x.Deleted ? RpdeState.Deleted : RpdeState.Updated, - Data = x.Deleted ? null : new Slot + var faker = new Faker() { Random = new Randomizer((int)x.Id) }; + return new RpdeItem { - // QUESTION: Should the this.IdTemplate and this.BaseUrl be passed in each time rather than set on - // the parent class? Current thinking is it's more extensible on parent class as function signature remains - // constant as power of configuration through underlying class grows (i.e. as new properties are added) - Id = RenderOpportunityId(new FacilityOpportunity - { - OpportunityType = _appSettings.FeatureFlags.FacilityUseHasSlots ? OpportunityType.FacilityUseSlot : OpportunityType.IndividualFacilityUseSlot, - FacilityUseId = x.FacilityUseId, - SlotId = x.Id, - IndividualFacilityUseId = !_appSettings.FeatureFlags.FacilityUseHasSlots ? x.IndividualFacilityUseId : null, - }), - FacilityUse = _appSettings.FeatureFlags.FacilityUseHasSlots ? - RenderOpportunityId(new FacilityOpportunity + Kind = _appSettings.FeatureFlags.FacilityUseHasSlots ? RpdeKind.FacilityUseSlot : RpdeKind.IndividualFacilityUseSlot, + Id = x.Id, + Modified = x.Modified, + State = x.Deleted ? RpdeState.Deleted : RpdeState.Updated, + Data = x.Deleted ? null : new Slot { - OpportunityType = OpportunityType.FacilityUse, - FacilityUseId = x.FacilityUseId - }) - : RenderOpportunityId(new FacilityOpportunity - { - OpportunityType = OpportunityType.IndividualFacilityUse, - IndividualFacilityUseId = x.IndividualFacilityUseId, - FacilityUseId = x.FacilityUseId, - }), - Identifier = x.Id, - StartDate = (DateTimeOffset)x.Start, - EndDate = (DateTimeOffset)x.End, - Duration = x.End - x.Start, - RemainingUses = x.RemainingUses - x.LeasedUses, - MaximumUses = x.MaximumUses, - Offers = new List { new Offer - { - Id = RenderOfferId(new FacilityOpportunity - { - OfferId = 0, - OpportunityType = _appSettings.FeatureFlags.FacilityUseHasSlots ? OpportunityType.FacilityUseSlot : OpportunityType.IndividualFacilityUseSlot, - FacilityUseId = x.FacilityUseId, - SlotId = x.Id, - IndividualFacilityUseId = !_appSettings.FeatureFlags.FacilityUseHasSlots ? x.IndividualFacilityUseId : null, - }), - Price = x.Price, - PriceCurrency = "GBP", - OpenBookingFlowRequirement = OpenBookingFlowRequirement(x), - ValidFromBeforeStartDate = x.ValidFromBeforeStartDate, - LatestCancellationBeforeStartDate = x.LatestCancellationBeforeStartDate, - OpenBookingPrepayment = _appSettings.FeatureFlags.PrepaymentAlwaysRequired ? null : x.Prepayment.Convert(), - AllowCustomerCancellationFullRefund = x.AllowCustomerCancellationFullRefund, - } - }, - } + // QUESTION: Should the this.IdTemplate and this.BaseUrl be passed in each time rather than set on + // the parent class? Current thinking is it's more extensible on parent class as function signature remains + // constant as power of configuration through underlying class grows (i.e. as new properties are added) + Id = RenderOpportunityId(new FacilityOpportunity + { + OpportunityType = _appSettings.FeatureFlags.FacilityUseHasSlots ? OpportunityType.FacilityUseSlot : OpportunityType.IndividualFacilityUseSlot, + FacilityUseId = x.FacilityUseId, + SlotId = x.Id, + IndividualFacilityUseId = !_appSettings.FeatureFlags.FacilityUseHasSlots ? x.IndividualFacilityUseId : null, + }), + FacilityUse = _appSettings.FeatureFlags.FacilityUseHasSlots ? + RenderOpportunityId(new FacilityOpportunity + { + OpportunityType = OpportunityType.FacilityUse, + FacilityUseId = x.FacilityUseId + }) + : RenderOpportunityId(new FacilityOpportunity + { + OpportunityType = OpportunityType.IndividualFacilityUse, + IndividualFacilityUseId = x.IndividualFacilityUseId, + FacilityUseId = x.FacilityUseId, + }), + Identifier = x.Id, + StartDate = (DateTimeOffset)x.Start, + EndDate = (DateTimeOffset)x.End, + Duration = x.End - x.Start, + RemainingUses = x.RemainingUses - x.LeasedUses, + MaximumUses = x.MaximumUses, + Offers = GenerateOffers(faker, false, x) + } + }; }); return query.ToList(); } } - private static List OpenBookingFlowRequirement(SlotTable slot) + private List GenerateOffers(Faker faker, bool isGoldenRecord, SlotTable slot) { - List openBookingFlowRequirement = null; - - if (slot.RequiresApproval) + var ageRangesForOffers = new List { - openBookingFlowRequirement = openBookingFlowRequirement ?? new List(); - openBookingFlowRequirement.Add(OpenActive.NET.OpenBookingFlowRequirement.OpenBookingApproval); - } + new QuantitativeValue {MinValue = 18, MaxValue = 59, Name = "Adult"}, + new QuantitativeValue { MaxValue = 17, Name = "Junior"}, + new QuantitativeValue {MinValue = 60, Name = "Senior"}, + new QuantitativeValue {MinValue = 18, MaxValue = 59, Name = "Adult (off-peak)"}, + }; - if (slot.RequiresAttendeeValidation) + Offer GenerateOffer(SlotTable slot, QuantitativeValue ageRange) { - openBookingFlowRequirement = openBookingFlowRequirement ?? new List(); - openBookingFlowRequirement.Add(OpenActive.NET.OpenBookingFlowRequirement.OpenBookingAttendeeDetails); + return new Offer + { + Id = RenderOfferId(new FacilityOpportunity + { + OfferId = 0, + OpportunityType = _appSettings.FeatureFlags.FacilityUseHasSlots ? OpportunityType.FacilityUseSlot : OpportunityType.IndividualFacilityUseSlot, + FacilityUseId = slot.FacilityUseId, + SlotId = slot.Id, + IndividualFacilityUseId = !_appSettings.FeatureFlags.FacilityUseHasSlots ? slot.IndividualFacilityUseId : null, + }), + Price = slot.Price, + PriceCurrency = "GBP", + OpenBookingFlowRequirement = FeedGenerationHelper.OpenBookingFlowRequirement(slot.RequiresApproval, slot.RequiresAttendeeValidation, slot.RequiresAdditionalDetails, slot.AllowsProposalAmendment), + ValidFromBeforeStartDate = slot.ValidFromBeforeStartDate, + LatestCancellationBeforeStartDate = slot.LatestCancellationBeforeStartDate, + OpenBookingPrepayment = _appSettings.FeatureFlags.PrepaymentAlwaysRequired ? null : slot.Prepayment.Convert(), + AllowCustomerCancellationFullRefund = slot.AllowCustomerCancellationFullRefund, + AcceptedPaymentMethod = new List { PaymentMethod.Cash, PaymentMethod.PaymentMethodCreditCard }, + }; } - if (slot.RequiresAdditionalDetails) - { - openBookingFlowRequirement = openBookingFlowRequirement ?? new List(); - openBookingFlowRequirement.Add(OpenActive.NET.OpenBookingFlowRequirement.OpenBookingIntakeForm); - } + var allOffersForAllAgeRanges = ageRangesForOffers.Select(ageRange => GenerateOffer(slot, ageRange)).ToList(); - if (slot.AllowsProposalAmendment) - { - openBookingFlowRequirement = openBookingFlowRequirement ?? new List(); - openBookingFlowRequirement.Add(OpenActive.NET.OpenBookingFlowRequirement.OpenBookingNegotiation); - } - return openBookingFlowRequirement; + return FeedGenerationHelper.GetRandomElementsOf(faker, allOffersForAllAgeRanges, isGoldenRecord, 1, 4).ToList(); } + } } \ No newline at end of file diff --git a/Examples/BookingSystem.AspNetFramework/Feeds/SessionsFeeds.cs b/Examples/BookingSystem.AspNetFramework/Feeds/SessionsFeeds.cs index b803b921..3ab86dea 100644 --- a/Examples/BookingSystem.AspNetFramework/Feeds/SessionsFeeds.cs +++ b/Examples/BookingSystem.AspNetFramework/Feeds/SessionsFeeds.cs @@ -8,6 +8,10 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using Bogus; +using BookingSystem.AspNetCore.Helpers; +using ServiceStack; +using System.Globalization; namespace BookingSystem { @@ -85,9 +89,6 @@ public AcmeSessionSeriesRpdeGenerator(AppSettings appSettings, FakeBookingSystem protected override async Task>> GetRpdeItems(long? afterTimestamp, long? afterId) { - var activityId = Environment.GetEnvironmentVariable("ACTIVITY_ID") ?? "https://openactive.io/activity-list#c07d63a0-8eb9-4602-8bcc-23be6deb8f83"; - var activityPrefLabel = Environment.GetEnvironmentVariable("ACTIVITY_PREF_LABEL") ?? "Jet Skiing"; - using (var db = _fakeBookingSystem.Database.Mem.Database.Open()) { var q = db.From() @@ -102,140 +103,341 @@ protected override async Task>> GetRpdeItems(long? var query = db .SelectMulti(q) - .Select(result => new RpdeItem + .Select(result => { - Kind = RpdeKind.SessionSeries, - Id = result.Item1.Id, - Modified = result.Item1.Modified, - State = result.Item1.Deleted ? RpdeState.Deleted : RpdeState.Updated, - Data = result.Item1.Deleted ? null : new SessionSeries + var intt = (int)result.Item1.Modified; + + var faker = new Faker() { Random = new Randomizer((int)result.Item1.Id) }; + // here we randomly decide whether the item is going to be a golden record or not by using Faker + // See the README for more detail on golden records. + var isGoldenRecord = faker.Random.Bool(); + + return new RpdeItem { - // QUESTION: Should the this.IdTemplate and this.BaseUrl be passed in each time rather than set on - // the parent class? Current thinking is it's more extensible on parent class as function signature remains - // constant as power of configuration through underlying class grows (i.e. as new properties are added) - Id = RenderOpportunityId(new SessionOpportunity - { - OpportunityType = OpportunityType.SessionSeries, - SessionSeriesId = result.Item1.Id - }), - Name = result.Item1.Title, - EventAttendanceMode = MapAttendanceMode(result.Item1.AttendanceMode), - Organizer = _appSettings.FeatureFlags.SingleSeller ? new Organization - { - Id = RenderSingleSellerId(), - Name = "Test Seller", - TaxMode = TaxMode.TaxGross, - TermsOfService = new List - { - new PrivacyPolicy - { - Name = "Privacy Policy", - Url = new Uri("https://example.com/privacy.html"), - RequiresExplicitConsent = false - } - }, - IsOpenBookingAllowed = true, - } : result.Item2.IsIndividual ? (ILegalEntity)new Person + Kind = RpdeKind.SessionSeries, + Id = result.Item1.Id, + Modified = result.Item1.Modified, + State = result.Item1.Deleted ? RpdeState.Deleted : RpdeState.Updated, + Data = result.Item1.Deleted ? null : new SessionSeries { - Id = RenderSellerId(new SimpleIdComponents { IdLong = result.Item2.Id }), - Name = result.Item2.Name, - TaxMode = result.Item2.IsTaxGross ? TaxMode.TaxGross : TaxMode.TaxNet, - IsOpenBookingAllowed = true, - } : (ILegalEntity)new Organization - { - Id = RenderSellerId(new SimpleIdComponents { IdLong = result.Item2.Id }), - Name = result.Item2.Name, - TaxMode = result.Item2.IsTaxGross ? TaxMode.TaxGross : TaxMode.TaxNet, - TermsOfService = new List - { - new PrivacyPolicy - { - Name = "Privacy Policy", - Url = new Uri("https://example.com/privacy.html"), - RequiresExplicitConsent = false - } - }, - IsOpenBookingAllowed = true, - }, - Offers = new List { new Offer - { - Id = RenderOfferId(new SessionOpportunity - { - OfferOpportunityType = OpportunityType.SessionSeries, - SessionSeriesId = result.Item1.Id, - OfferId = 0 - }), - Price = result.Item1.Price, - PriceCurrency = "GBP", - OpenBookingFlowRequirement = OpenBookingFlowRequirement(result.Item1), - ValidFromBeforeStartDate = result.Item1.ValidFromBeforeStartDate, - LatestCancellationBeforeStartDate = result.Item1.LatestCancellationBeforeStartDate, - OpenBookingPrepayment = _appSettings.FeatureFlags.PrepaymentAlwaysRequired ? null : result.Item1.Prepayment.Convert(), - AllowCustomerCancellationFullRefund = result.Item1.AllowCustomerCancellationFullRefund - } - }, - Location = result.Item1.AttendanceMode == AttendanceMode.Online ? null : _fakeBookingSystem.Database.GetPlaceById(result.Item1.PlaceId), - AffiliatedLocation = result.Item1.AttendanceMode == AttendanceMode.Offline ? null : _fakeBookingSystem.Database.GetPlaceById(result.Item1.PlaceId), - Url = new Uri("https://www.example.com/a-session-age"), - Activity = new List - { - new Concept + // QUESTION: Should the this.IdTemplate and this.BaseUrl be passed in each time rather than set on + // the parent class? Current thinking is it's more extensible on parent class as function signature remains + // constant as power of configuration through underlying class grows (i.e. as new properties are added) + Id = RenderOpportunityId(new SessionOpportunity { - Id = new Uri(activityId), - PrefLabel = activityPrefLabel, - InScheme = new Uri("https://openactive.io/activity-list") - } + OpportunityType = OpportunityType.SessionSeries, + SessionSeriesId = result.Item1.Id + }), + Identifier = result.Item1.Id, + Name = GetNameAndActivityForSessions(result.Item1.Title, isGoldenRecord).Name, + EventAttendanceMode = MapAttendanceMode(result.Item1.AttendanceMode), + Description = faker.Lorem.Paragraphs(isGoldenRecord ? 4 : faker.Random.Number(4)), + AttendeeInstructions = FeedGenerationHelper.GenerateAttendeeInstructions(faker, isGoldenRecord), + GenderRestriction = faker.Random.Enum(), + AgeRange = GenerateAgeRange(faker, isGoldenRecord), + Level = faker.Random.ListItems(new List { "Beginner", "Intermediate", "Advanced" }, 1).ToList(), + Organizer = GenerateOrganizerOrPerson(faker, result.Item2), + AccessibilitySupport = FeedGenerationHelper.GenerateAccessibilitySupport(faker, isGoldenRecord), + AccessibilityInformation = faker.Lorem.Paragraphs(isGoldenRecord ? 2 : faker.Random.Number(2)), + IsWheelchairAccessible = isGoldenRecord || faker.Random.Bool() ? faker.Random.Bool() : faker.Random.ListItem(new List { true, false, null, null }), + Category = GenerateCategory(faker, isGoldenRecord), + Image = FeedGenerationHelper.GenerateImages(faker, isGoldenRecord), + Video = isGoldenRecord || faker.Random.Bool() ? new List { new VideoObject { Url = new Uri("https://www.youtube.com/watch?v=xvDZZLqlc-0") } } : null, + Leader = GenerateListOfPersons(faker, isGoldenRecord, 2), + Contributor = GenerateListOfPersons(faker, isGoldenRecord, 2), + IsCoached = isGoldenRecord || faker.Random.Bool() ? faker.Random.Bool() : faker.Random.ListItem(new List { true, false, null, null }), + Offers = GenerateOffers(faker, isGoldenRecord, result.Item1), + // location MUST not be provided for fully virtual sessions + Location = result.Item1.AttendanceMode == AttendanceMode.Online ? null : FeedGenerationHelper.GetPlaceById(result.Item1.PlaceId), + // beta:affiliatedLocation MAY be provided for fully virtual sessions + AffiliatedLocation = (result.Item1.AttendanceMode == AttendanceMode.Offline && faker.Random.Bool()) ? null : FeedGenerationHelper.GetPlaceById(result.Item1.PlaceId), + EventSchedule = GenerateSchedules(faker, isGoldenRecord), + SchedulingNote = GenerateSchedulingNote(faker, isGoldenRecord), + IsAccessibleForFree = result.Item1.Price == 0, + Url = new Uri($"https://www.example.com/sessions/{result.Item1.Id}"), + Activity = GetNameAndActivityForSessions(result.Item1.Title, isGoldenRecord).Activity, + Programme = GenerateBrand(faker, isGoldenRecord), + IsInteractivityPreferred = result.Item1.AttendanceMode == AttendanceMode.Offline ? null : (isGoldenRecord ? true : faker.Random.ListItem(new List { true, false, null })), + IsVirtuallyCoached = result.Item1.AttendanceMode == AttendanceMode.Offline ? null : (isGoldenRecord ? true : faker.Random.ListItem(new List { true, false, null })), + ParticipantSuppliedEquipment = result.Item1.AttendanceMode == AttendanceMode.Offline ? null : (isGoldenRecord ? OpenActive.NET.RequiredStatusType.Optional : faker.Random.ListItem(new List { OpenActive.NET.RequiredStatusType.Optional, OpenActive.NET.RequiredStatusType.Required, OpenActive.NET.RequiredStatusType.Unavailable, null })), } - } - }); ; + }; + }); return query.ToList(); } } + private static EventAttendanceModeEnumeration MapAttendanceMode(AttendanceMode attendanceMode) + { + switch (attendanceMode) + { + case AttendanceMode.Offline: + return EventAttendanceModeEnumeration.OfflineEventAttendanceMode; + case AttendanceMode.Online: + return EventAttendanceModeEnumeration.OnlineEventAttendanceMode; + case AttendanceMode.Mixed: + return EventAttendanceModeEnumeration.MixedEventAttendanceMode; + default: + throw new OpenBookingException(new OpenBookingError(), $"AttendanceMode Type {attendanceMode} not supported"); + } + } - private static List OpenBookingFlowRequirement(ClassTable @class) + private (string Name, List Activity) GetNameAndActivityForSessions(string databaseTitle, bool isGoldenRecord) { - List openBookingFlowRequirement = null; + // If both ACTIVITY_ID and ACTIVITY_PREF_LABEL env vars are set, these override the randomly generated activity. We also use these to generate an appropriate name + if (Environment.GetEnvironmentVariable("ACTIVITY_ID") != null && Environment.GetEnvironmentVariable("ACTIVITY_PREF_LABEL") != null) + { + var name = $"{(isGoldenRecord ? "GOLDEN: " : "")} {Environment.GetEnvironmentVariable("ACTIVITY_PREF_LABEL")} class"; + var concept = new Concept + { + Id = new Uri(Environment.GetEnvironmentVariable("ACTIVITY_ID")), + PrefLabel = Environment.GetEnvironmentVariable("ACTIVITY_PREF_LABEL"), + InScheme = new Uri("https://openactive.io/activity-list") + }; - if (@class.RequiresApproval) + return (name, new List { concept }); + } + + // If there isn't an override, we use the randomly generated name to derive the appropriate activity + Concept activityConcept; + switch (databaseTitle) { - openBookingFlowRequirement = openBookingFlowRequirement ?? new List(); - openBookingFlowRequirement.Add(OpenActive.NET.OpenBookingFlowRequirement.OpenBookingApproval); + case string a when a.Contains("Yoga"): + activityConcept = new Concept + { + Id = new Uri("https://openactive.io/activity-list#bf1a5e00-cdcf-465d-8c5a-6f57040b7f7e"), + PrefLabel = "Yoga", + InScheme = new Uri("https://openactive.io/activity-list") + }; + break; + case string a when a.Contains("Zumba"): + activityConcept = new Concept + { + Id = new Uri("https://openactive.io/activity-list#78503fa2-ed24-4a80-a224-e2e94581d8a8"), + PrefLabel = "Zumba®", + InScheme = new Uri("https://openactive.io/activity-list") + }; + break; + case string a when a.Contains("Walking"): + activityConcept = new Concept + { + Id = new Uri("https://openactive.io/activity-list#95092977-5a20-4d6e-b312-8fddabe71544"), + PrefLabel = "Walking", + InScheme = new Uri("https://openactive.io/activity-list") + }; + break; + case string a when a.Contains("Cycling"): + activityConcept = new Concept + { + Id = new Uri("https://openactive.io/activity-list#4a19873e-118e-43f4-b86e-05acba8fb1de"), + PrefLabel = "Cycling", + InScheme = new Uri("https://openactive.io/activity-list") + }; + break; + case string a when a.Contains("Running"): + activityConcept = new Concept + { + Id = new Uri("https://openactive.io/activity-list#72ddb2dc-7d75-424e-880a-d90eabe91381"), + PrefLabel = "Running", + InScheme = new Uri("https://openactive.io/activity-list") + }; + break; + case string a when a.Contains("Jumping"): + activityConcept = new Concept + { + Id = new Uri("https://openactive.io/activity-list#8a4abff3-c616-4f33-80a1-398b88c672a3"), + PrefLabel = "World Jumping®", + InScheme = new Uri("https://openactive.io/activity-list") + }; + break; + default: + activityConcept = new Concept + { + Id = new Uri("https://openactive.io/activity-list#c07d63a0-8eb9-4602-8bcc-23be6deb8f83"), + PrefLabel = "Jet Skiing", + InScheme = new Uri("https://openactive.io/activity-list") + }; + break; } - if (@class.RequiresAttendeeValidation) + var nameWithGolden = $"{(isGoldenRecord ? "GOLDEN: " : "")}{databaseTitle}"; + return (nameWithGolden, new List { activityConcept }); + + } + + private QuantitativeValue GenerateAgeRange(Faker faker, bool isGoldenRecord) + { + var ageRange = new QuantitativeValue(); + if (isGoldenRecord || faker.Random.Bool()) ageRange.MaxValue = faker.Random.Number(16, 100); + if (isGoldenRecord || faker.Random.Bool()) ageRange.MinValue = faker.Random.Number(0, ageRange.MaxValue == null ? 100 : (int)ageRange.MaxValue); + + if (ageRange.MaxValue == null && ageRange.MinValue == null) ageRange.MinValue = 0; + return ageRange; + } + + private ILegalEntity GenerateOrganizerOrPerson(Faker faker, SellerTable seller) + { + if (seller.IsIndividual) + return new OpenActive.NET.Person + { + Id = RenderSellerId(new SimpleIdComponents { IdLong = seller.Id }), + Name = seller.Name, + TaxMode = seller.IsTaxGross ? TaxMode.TaxGross : TaxMode.TaxNet, + IsOpenBookingAllowed = true, + Telephone = faker.Phone.PhoneNumber("07### ######") + }; + var organizationId = _appSettings.FeatureFlags.SingleSeller ? RenderSingleSellerId() : RenderSellerId(new SimpleIdComponents { IdLong = seller.Id }); + return FeedGenerationHelper.GenerateOrganization(faker, seller, _appSettings.FeatureFlags.SingleSeller, organizationId); + } + + private List GenerateCategory(Faker faker, bool isGoldenRecord) + { + var listOfPossibleCategories = new List + { + "Group Exercise Classes", + "Toning & Strength", + "Group Exercise - Virtual" + }; + + return FeedGenerationHelper.GetRandomElementsOf(faker, listOfPossibleCategories, isGoldenRecord, 1).ToList(); + } + + private List GenerateListOfPersons(Faker faker, bool isGoldenRecord, int possibleMax) + { + static OpenActive.NET.Person GeneratePerson(Faker faker, bool isGoldenRecord) + { + var id = faker.Finance.Bic(); + var genderIndex = faker.Random.Number(1); + var gender = (Bogus.DataSets.Name.Gender)genderIndex; + var givenName = faker.Name.FirstName(gender); + var familyName = faker.Name.LastName(gender); + var name = $"{givenName} {familyName}"; + var isLiteRecord = isGoldenRecord ? false : faker.Random.Bool(); + + return new OpenActive.NET.Person + { + Id = new Uri($"https://example.com/people/{id}"), + Identifier = id, + Name = name, + GivenName = isLiteRecord ? null : givenName, + FamilyName = isLiteRecord ? null : familyName, + Gender = genderIndex == 1 ? Schema.NET.GenderType.Female : Schema.NET.GenderType.Male, + JobTitle = faker.Random.ListItem(new List { "Leader", "Team leader", "Host", "Instructor", "Coach" }), + Telephone = isLiteRecord ? null : faker.Phone.PhoneNumber("07## ### ####"), + Email = isLiteRecord ? null : faker.Internet.ExampleEmail(), + Url = new Uri($"{faker.Internet.Url()}/profile/{faker.Random.Number(50)}"), + Image = new Schema.NET.ImageObject { Url = new Uri(faker.Internet.Avatar()) } + }; + } + + var output = new List(); + var max = isGoldenRecord ? possibleMax : faker.Random.Number(1, possibleMax); + for (var i = 0; i < max; i++) { - openBookingFlowRequirement = openBookingFlowRequirement ?? new List(); - openBookingFlowRequirement.Add(OpenActive.NET.OpenBookingFlowRequirement.OpenBookingAttendeeDetails); + output.Add(GeneratePerson(faker, isGoldenRecord)); } + return output; + } - if (@class.RequiresAdditionalDetails) + private List GenerateSchedules(Faker faker, bool isGoldenRecord) + { + var schedules = new List(); + PartialSchedule GenerateSchedule(Faker faker) { - openBookingFlowRequirement = openBookingFlowRequirement ?? new List(); - openBookingFlowRequirement.Add(OpenActive.NET.OpenBookingFlowRequirement.OpenBookingIntakeForm); + var startTimeString = $"{faker.Random.Number(min: 10, max: 22)}:{faker.Random.ListItem(new List { "00", "15", "30", "45" })}:00"; + var startTime = new TimeValue(startTimeString); + var duration = faker.Random.ListItem(new List { new TimeSpan(0, 30, 0), new TimeSpan(1, 0, 0), new TimeSpan(1, 30, 0), new TimeSpan(2, 0, 0) }); + var startTimeSpan = TimeSpan.Parse(startTimeString); + + var endTime = new DateTime(startTimeSpan.Add(duration).Ticks); + var endTimeString = endTime.ToString("HH:mm"); + var endTimeTM = new TimeValue(endTimeString); + var startDateFaker = faker.Date.Soon(); + var startDate = new DateValue(startDateFaker); + var endDate = new DateValue(faker.Date.Soon(28, startDateFaker)); + + var partialSchedule = new PartialSchedule + { + StartTime = startTime, + Duration = duration, + EndTime = endTimeTM, + StartDate = startDate, + EndDate = endDate, + RepeatFrequency = faker.Random.ListItem(new List { new TimeSpan(7, 0, 0, 0), new TimeSpan(14, 0, 0, 0) }), + ByDay = faker.Random.EnumValues().ToList() + }; + return partialSchedule; } - if (@class.AllowsProposalAmendment) + for (var i = 0; i < 2; i++) { - openBookingFlowRequirement = openBookingFlowRequirement ?? new List(); - openBookingFlowRequirement.Add(OpenActive.NET.OpenBookingFlowRequirement.OpenBookingNegotiation); + schedules.Add(GenerateSchedule(faker)); } - return openBookingFlowRequirement; + + return FeedGenerationHelper.GetRandomElementsOf(faker, schedules, isGoldenRecord, 1, 1).ToList(); } - private static EventAttendanceModeEnumeration MapAttendanceMode(AttendanceMode attendanceMode) + private string GenerateSchedulingNote(Faker faker, bool isGoldenRecord) { - switch (attendanceMode) + var allSchedulingNotes = new List { - case AttendanceMode.Offline: - return EventAttendanceModeEnumeration.OfflineEventAttendanceMode; - case AttendanceMode.Online: - return EventAttendanceModeEnumeration.OnlineEventAttendanceMode; - case AttendanceMode.Mixed: - return EventAttendanceModeEnumeration.MixedEventAttendanceMode; - default: - throw new OpenBookingException(new OpenBookingError(), $"AttendanceMode Type {attendanceMode} not supported"); + "Sessions are not running during school holidays.", + "Sessions may be cancelled with 15 minutes notice, please keep an eye on your e-mail.", + "Sessions are scheduled with best intentions, but sometimes need to be rescheduled due to venue availability. Ensure that you contact the organizer before turning up." + }; + + if (isGoldenRecord) return faker.Random.ListItem(allSchedulingNotes); + return faker.Random.Bool() ? faker.Random.ListItem(allSchedulingNotes) : null; + } + + private List GenerateOffers(Faker faker, bool isGoldenRecord, ClassTable @class) + { + var ageRangesForOffers = new List + { + new QuantitativeValue {MinValue = 18, MaxValue = 59, Name = "Adult"}, + new QuantitativeValue { MaxValue = 17, Name = "Junior"}, + new QuantitativeValue {MinValue = 60, Name = "Senior"}, + new QuantitativeValue {MinValue = 18, MaxValue = 59, Name = "Adult (off-peak)"}, + }; + + Offer GenerateOffer(ClassTable @class, QuantitativeValue ageRange) + { + return new Offer + { + Id = RenderOfferId(new SessionOpportunity + { + OfferOpportunityType = OpportunityType.SessionSeries, + SessionSeriesId = @class.Id, + OfferId = 0 + }), + Price = @class.Price, + PriceCurrency = "GBP", + Name = ageRange.Name, + OpenBookingFlowRequirement = FeedGenerationHelper.OpenBookingFlowRequirement(@class.RequiresApproval, @class.RequiresAttendeeValidation, @class.RequiresAdditionalDetails, @class.AllowsProposalAmendment), + ValidFromBeforeStartDate = @class.ValidFromBeforeStartDate, + LatestCancellationBeforeStartDate = @class.LatestCancellationBeforeStartDate, + OpenBookingPrepayment = _appSettings.FeatureFlags.PrepaymentAlwaysRequired ? null : @class.Prepayment.Convert(), + AllowCustomerCancellationFullRefund = @class.AllowCustomerCancellationFullRefund, + AcceptedPaymentMethod = new List { PaymentMethod.Cash, PaymentMethod.PaymentMethodCreditCard }, + AgeRestriction = ageRange, + }; } + + var allOffersForAllAgeRanges = ageRangesForOffers.Select(ageRange => GenerateOffer(@class, ageRange)).ToList(); + + return FeedGenerationHelper.GetRandomElementsOf(faker, allOffersForAllAgeRanges, isGoldenRecord, 1, 2).ToList(); + } + + private Brand GenerateBrand(Faker faker, bool isGoldenRecord) + { + return new Brand + { + Name = faker.Random.ListItem(new List { "Keyways Active", "This Girl Can", "Back to Activity", "Mega-active Super Dads" }), + Url = new Uri(faker.Internet.Url()), + Description = faker.Lorem.Paragraphs(isGoldenRecord ? 4 : faker.Random.Number(4)), + Logo = new ImageObject { Url = new Uri(faker.Internet.Avatar()) }, + Video = new List { new VideoObject { Url = new Uri("https://www.youtube.com/watch?v=N268gBOvnzo") } } + }; } } } diff --git a/Examples/BookingSystem.AspNetFramework/Settings/EngineConfig.cs b/Examples/BookingSystem.AspNetFramework/Settings/EngineConfig.cs index 5dafa9fa..70ab090c 100644 --- a/Examples/BookingSystem.AspNetFramework/Settings/EngineConfig.cs +++ b/Examples/BookingSystem.AspNetFramework/Settings/EngineConfig.cs @@ -166,6 +166,9 @@ public static StoreBookingEngine CreateStoreBookingEngine(AppSettings appSetting ), HasSingleSeller = appSettings.FeatureFlags.SingleSeller, + // IdempotencyStore used for storing the response to Order Creation B/P requests + IdempotencyStore = new AcmeIdempotencyStore(), + OpenDataFeeds = new Dictionary { { OpportunityType.ScheduledSession, new AcmeScheduledSessionRpdeGenerator(fakeBookingSystem) diff --git a/Examples/BookingSystem.AspNetFramework/Stores/FacilityStore.cs b/Examples/BookingSystem.AspNetFramework/Stores/FacilityStore.cs index 8737ba2e..a8381c34 100644 --- a/Examples/BookingSystem.AspNetFramework/Stores/FacilityStore.cs +++ b/Examples/BookingSystem.AspNetFramework/Stores/FacilityStore.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using BookingSystem.AspNetCore.Helpers; using OpenActive.DatasetSite.NET; using OpenActive.FakeDatabase.NET; using OpenActive.NET; @@ -320,7 +321,7 @@ protected override async Task GetOrderItems(List { new Concept { @@ -374,11 +375,11 @@ protected override async Task GetOrderItems(List GetSuccessfulOrderCreationResponse(string idempotencyKey) + { + return new ValueTask((string)_cache.Get(idempotencyKey)); + } + + protected override ValueTask SetSuccessfulOrderCreationResponse(string idempotencyKey, string responseJson) + { + var policy = new CacheItemPolicy(); + policy.AbsoluteExpiration = DateTimeOffset.Now.AddMinutes(5); + _cache.Set(idempotencyKey, responseJson, policy); + return new ValueTask(); + } + } +} \ No newline at end of file diff --git a/Examples/BookingSystem.AspNetFramework/Stores/SessionStore.cs b/Examples/BookingSystem.AspNetFramework/Stores/SessionStore.cs index ff596c47..318cc82b 100644 --- a/Examples/BookingSystem.AspNetFramework/Stores/SessionStore.cs +++ b/Examples/BookingSystem.AspNetFramework/Stores/SessionStore.cs @@ -8,7 +8,7 @@ using OpenActive.FakeDatabase.NET; using RequiredStatusType = OpenActive.FakeDatabase.NET.RequiredStatusType; using System.Threading.Tasks; - +using BookingSystem.AspNetCore.Helpers; namespace BookingSystem { @@ -361,7 +361,7 @@ protected override async Task GetOrderItems(List { new Concept diff --git a/Fakes/OpenActive.FakeDatabase.NET/FakeBookingSystem.cs b/Fakes/OpenActive.FakeDatabase.NET/FakeBookingSystem.cs index f30ab2e9..ed8e3bbd 100644 --- a/Fakes/OpenActive.FakeDatabase.NET/FakeBookingSystem.cs +++ b/Fakes/OpenActive.FakeDatabase.NET/FakeBookingSystem.cs @@ -644,118 +644,6 @@ await db.InsertAsync(new OrderTable } } - public OpenActive.NET.Place GetPlaceById(long placeId) - { - // Three hardcoded fake places - switch (placeId) - { - case 1: - return new OpenActive.NET.Place - { - Identifier = 1, - Name = "Post-ercise Plaza", - Description = "Sorting Out Your Fitness One Parcel Lift at a Time! Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - Address = new OpenActive.NET.PostalAddress - { - StreetAddress = "Kings Mead House", - AddressLocality = "Oxford", - AddressRegion = "Oxfordshire", - PostalCode = "OX1 1AA", - AddressCountry = "GB" - }, - Geo = new OpenActive.NET.GeoCoordinates - { - Latitude = (decimal?)51.7502, - Longitude = (decimal?)-1.2674 - }, - Image = new List { - new OpenActive.NET.ImageObject - { - Url = new Uri("https://upload.wikimedia.org/wikipedia/commons/e/e5/Oxford_StAldates_PostOffice.jpg") - }, - }, - Telephone = "01865 000001", - Url = new Uri("https://en.wikipedia.org/wiki/Post_Office_Limited"), - AmenityFeature = new List - { - new OpenActive.NET.ChangingFacilities { Name = "Changing Facilities", Value = true }, - new OpenActive.NET.Showers { Name = "Showers", Value = true }, - new OpenActive.NET.Lockers { Name = "Lockers", Value = true }, - new OpenActive.NET.Towels { Name = "Towels", Value = false }, - new OpenActive.NET.Creche { Name = "Creche", Value = false }, - new OpenActive.NET.Parking { Name = "Parking", Value = false } - } - }; - case 2: - return new OpenActive.NET.Place - { - Identifier = 2, - Name = "Premier Lifters", - Description = "Where your Fitness Goals are Always Inn-Sight. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - Address = new OpenActive.NET.PostalAddress - { - StreetAddress = "Greyfriars Court, Paradise Square", - AddressLocality = "Oxford", - AddressRegion = "Oxfordshire", - PostalCode = "OX1 1BB", - AddressCountry = "GB" - }, - Geo = new OpenActive.NET.GeoCoordinates - { - Latitude = (decimal?)51.7504933, - Longitude = (decimal?)-1.2620685 - }, - Image = new List { - new OpenActive.NET.ImageObject - { - Url = new Uri("https://upload.wikimedia.org/wikipedia/commons/5/53/Cambridge_Orchard_Park_Premier_Inn.jpg") - }, - }, - Telephone = "01865 000002", - Url = new Uri("https://en.wikipedia.org/wiki/Premier_Inn"), - AmenityFeature = new List - { - new OpenActive.NET.ChangingFacilities { Name = "Changing Facilities", Value = false }, - new OpenActive.NET.Showers { Name = "Showers", Value = false }, - new OpenActive.NET.Lockers { Name = "Lockers", Value = false }, - new OpenActive.NET.Towels { Name = "Towels", Value = true }, - new OpenActive.NET.Creche { Name = "Creche", Value = true }, - new OpenActive.NET.Parking { Name = "Parking", Value = true } - } - }; - case 3: - return new OpenActive.NET.Place - { - Identifier = 3, - Name = "Stroll & Stretch", - Description = "Casual Calisthenics in the Heart of Commerce. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - Address = new OpenActive.NET.PostalAddress - { - StreetAddress = "Norfolk Street", - AddressLocality = "Oxford", - AddressRegion = "Oxfordshire", - PostalCode = "OX1 1UU", - AddressCountry = "GB" - }, - Geo = new OpenActive.NET.GeoCoordinates - { - Latitude = (decimal?)51.749826, - Longitude = (decimal?)-1.261492 - }, - Image = new List { - new OpenActive.NET.ImageObject - { - Url = new Uri("https://upload.wikimedia.org/wikipedia/commons/2/28/Westfield_Garden_State_Plaza_-_panoramio.jpg") - }, - }, - Telephone = "01865 000003", - Url = new Uri("https://en.wikipedia.org/wiki/Shopping_center"), - }; - default: - return null; - } - } - public async Task<(bool, FacilityUseTable, SlotTable, BookedOrderItemInfo)> GetSlotAndBookedOrderItemInfoBySlotId(Guid uuid, long? slotId) { using (var db = await Mem.Database.OpenAsync()) @@ -1497,13 +1385,13 @@ private static async Task CreateFakeFacilitiesAndSlots(IDbConnection db, bool fa var slotId = 0; List<(FacilityUseTable facility, List slots)> facilitiesAndSlots = opportunitySeeds.Select((seed) => { - var facilityUseName = $"{Faker.Commerce.ProductMaterial()} {Faker.PickRandomParam("Sports Hall", "Swimming Pool Hall", "Running Hall", "Jumping Hall")}"; + var facilityUseName = $"{Faker.Commerce.ProductMaterial()} {Faker.PickRandomParam("Sports Hall", "Squash Court", "Badminton Court", "Cricket Net")}"; var facility = new FacilityUseTable { Id = seed.Id, Deleted = false, Name = facilityUseName, - SellerId = Faker.Random.Bool(0.8f) ? Faker.Random.Long(1, 2) : Faker.Random.Long(3, 5), // distribution: 80% 1-2, 20% 3-5 + SellerId = Faker.Random.Bool(0.8f) ? Faker.Random.Long(1, 2) : Faker.Random.Long(3, 4), // distribution: 80% 1-2, 20% 3-5 PlaceId = Faker.PickRandom(new[] { 1, 2, 3 }) };