diff --git a/docs/config-template.yaml b/docs/config-template.yaml index 9e91429..5a6969d 100644 --- a/docs/config-template.yaml +++ b/docs/config-template.yaml @@ -143,19 +143,23 @@ choices: - regdesk early: description: 'Early Bird Discount' - price: -500 + price: -1500 vat_percent: 19 visible_for: - regdesk default: true read_only: true - door: - description: 'At The Door Fee' - price: 1000 + constraint: '!day-wed,!day-thu,!day-fri,!day-sat' + constraint_msg: 'Early Bird Discount does not apply to Day Tickets' + late: + description: 'Late Fee' + price: 1500 vat_percent: 19 visible_for: - regdesk read_only: true + constraint: '!day-wed,!day-thu,!day-fri,!day-sat' + constraint_msg: 'Late Fee does not apply to Day Tickets' stage: description: 'Entrance Fee (Stage Ticket)' price: 500 diff --git a/internal/service/attendeesrv/attendeesrv.go b/internal/service/attendeesrv/attendeesrv.go index 758bc12..3c4e28d 100644 --- a/internal/service/attendeesrv/attendeesrv.go +++ b/internal/service/attendeesrv/attendeesrv.go @@ -217,8 +217,16 @@ func userAlreadyHasAnotherRegistration(ctx context.Context, identity string, exp } func checkNoForbiddenChanges(ctx context.Context, what string, key string, choiceConfig config.ChoiceConfig, originalChoices map[string]bool, newChoices map[string]bool) error { - if choiceConfig.AdminOnly || choiceConfig.ReadOnly { - if originalChoices[key] != newChoices[key] { + if originalChoices[key] != newChoices[key] { + // tolerate removing a read-only choice that has a constraint that forbids it anyway + if choiceConfig.ReadOnly { + if originalChoices[key] && !newChoices[key] { + if canAllowRemovalDueToConstraint(ctx, what, key, choiceConfig, originalChoices, newChoices) { + return nil + } + } + } + if choiceConfig.AdminOnly || choiceConfig.ReadOnly { if !ctxvalues.HasApiToken(ctx) && !ctxvalues.IsAuthorizedAsGroup(ctx, config.OidcAdminGroup()) { return fmt.Errorf("forbidden select or deselect of %s %s - only an admin can do that", what, key) } @@ -227,6 +235,23 @@ func checkNoForbiddenChanges(ctx context.Context, what string, key string, choic return nil } +func canAllowRemovalDueToConstraint(ctx context.Context, what string, key string, choiceConfig config.ChoiceConfig, originalChoices map[string]bool, newChoices map[string]bool) bool { + if choiceConfig.Constraint != "" { + constraints := strings.Split(choiceConfig.Constraint, ",") + for _, cn := range constraints { + constraintK := cn + if strings.HasPrefix(cn, "!") { + constraintK = strings.TrimPrefix(cn, "!") + if newChoices[constraintK] { + aulogging.Logger.Ctx(ctx).Info().Printf("can allow removal of read only %s %s - it would violate a constraint for %s anyway", what, key, constraintK) + return true + } + } + } + } + return false +} + func checkNoForbiddenChangesAfterPayment(ctx context.Context, what string, key string, choiceConfig config.ChoiceConfig, configuration map[string]config.ChoiceConfig, originalChoices map[string]bool, newChoices map[string]bool, currentStatus status.Status) error { if ctxvalues.HasApiToken(ctx) || ctxvalues.IsAuthorizedAsGroup(ctx, config.OidcAdminGroup()) { return nil diff --git a/test/acceptance/attendee_acc_test.go b/test/acceptance/attendee_acc_test.go index 9436334..3e5a1d7 100644 --- a/test/acceptance/attendee_acc_test.go +++ b/test/acceptance/attendee_acc_test.go @@ -763,6 +763,43 @@ func TestCreateNewAttendee_AutomaticGroupFlag_CannotSet(t *testing.T) { }) } +func TestCreateNewAttendee_ReadonlyDefaultPackageWithConstraintRemovable(t *testing.T) { + docs.Given("given the configuration for login-only registration after normal reg is open") + tstSetup(true, false, true) + defer tstShutdown() + + docs.Given("given a logged in user") + token := tstValidUserToken(t, 101) + + docs.When("when they create a new attendee and remove a read-only default package with matching constraint (stage)") + attendeeSent := tstBuildValidAttendee("na63-") + attendeeSent.Packages = "room-none,day-sat,boat-trip" + response := tstPerformPost("/api/rest/v1/attendees", tstRenderJson(attendeeSent), token) + + docs.Then("then the attendee is successfully created") + require.Equal(t, http.StatusCreated, response.status, "unexpected http response status") + require.Regexp(t, "^\\/api\\/rest\\/v1\\/attendees\\/[1-9][0-9]*$", response.location, "invalid location header in response") +} + +func TestCreateNewAttendee_ReadonlyDefaultPackageNoConstraintNotRemovable(t *testing.T) { + docs.Given("given the configuration for login-only registration after normal reg is open") + tstSetup(true, false, true) + defer tstShutdown() + + docs.Given("given a logged in user") + token := tstValidUserToken(t, 101) + + docs.When("when they create a new attendee and try to remove a read-only default package with no matching constraint (room-none)") + attendeeSent := tstBuildValidAttendee("na65-") + attendeeSent.Packages = "day-sat" + response := tstPerformPost("/api/rest/v1/attendees", tstRenderJson(attendeeSent), token) + + docs.Then("then the attempt is rejected as invalid (400) with an appropriate error response") + tstRequireErrorResponse(t, response, http.StatusBadRequest, "attendee.data.invalid", url.Values{ + "packages": []string{"forbidden select or deselect of package room-none - only an admin can do that"}, + }) +} + // --- update attendee --- func TestUpdateExistingAttendee_Self(t *testing.T) { diff --git a/test/testconfig-base.yaml b/test/testconfig-base.yaml index d6129c7..ccf0992 100644 --- a/test/testconfig-base.yaml +++ b/test/testconfig-base.yaml @@ -140,13 +140,12 @@ choices: constraint: '!attendance,!stage' constraint_msg: 'Must disable Convention Ticket and Stage Ticket for Day Guests.' day-sat: - description: 'Day Guest (Saturday)' + description: 'Day Guest (Saturday) Self Booking Allowed due to constraints' price: 6000 vat_percent: 19 - read_only: true at-least-one-mandatory: true - constraint: '!attendance,!stage' - constraint_msg: 'Must disable Convention Ticket and Stage Ticket for Day Guests.' + constraint: '!stage,!attendance' + constraint_msg: 'Must disable Stage Ticket for Saturday Day Guests.' options: art: description: 'Artist'