Skip to content

Commit

Permalink
Merge pull request #242 from eurofurence/issue-239-multi-packages
Browse files Browse the repository at this point in the history
implement multiple package occurences
  • Loading branch information
Jumpy-Squirrel authored Dec 8, 2024
2 parents 22bb045 + edce442 commit c01371f
Show file tree
Hide file tree
Showing 17 changed files with 644 additions and 103 deletions.
94 changes: 67 additions & 27 deletions api/openapi-spec/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1704,7 +1704,6 @@ components:
- email
- phone
- birthday
- packages
properties:
id:
type: integer
Expand Down Expand Up @@ -1818,8 +1817,26 @@ components:
example: art,anim,music,suit
packages:
type: string
description: A comma separated list of packages as declared in configuration. Packages are the things that cost money, like being a supersponsor or a day guest for a certain day. They can be configured with respect to who may add / remove them, if they are on by default, and whether they are visible if not selected (admin only, normal user, completely disabled). There is also configuration as to which packages are mutually exclusive, such as sponsor and supersponsor.
example: room-none,attendance,sponsor
description: |-
A comma separated list of packages as declared in configuration.
Packages are the things that cost money, like being a supersponsor or a day guest for a certain day. They can be
configured with respect to who may add / remove them, if they are on by default, and whether they are visible if
not selected (admin only, normal user, completely disabled). There is also configuration as to which packages are
mutually exclusive, such as sponsor and supersponsor.
If configured with a max_count greater than 1, certain packages may occur multiple times in this list.
Any order of packages is accepted in requests, but in responses the list of packages will always be sorted
in alphabetical order.
Note: If packages_list is also specified in a request, it takes precedence over this field. In responses,
both fields will always be present and filled.
DEPRECATED - see packages_list
example: attendance,room-none,sponsor
packages_list:
$ref: '#/components/schemas/PackagesList'
user_comments:
type: string
description: Optional comments the attendee wishes to make regarding their registration. Not processed in any way.
Expand Down Expand Up @@ -1981,32 +1998,20 @@ components:
- suit
packages:
type: string
description: DEPRECATED - use packages_list for new implementations. A comma separated list of packages as declared in configuration. Packages are the things that cost money, like being a supersponsor or a day guest for a certain day. They can be configured with respect to who may add / remove them, if they are on by default, and whether they are visible if not selected (admin only, normal user, completely disabled). There is also configuration as to which packages are mutually exclusive, such as sponsor and supersponsor.
description: |-
DEPRECATED - use packages_list for new implementations.
A comma separated list of packages as declared in configuration, sorted alphabetically by package name.
Packages are the things that cost money, like being a supersponsor or a day guest for a certain day. They can be
configured with respect to who may add / remove them, if they are on by default, and whether they are visible if
not selected (admin only, normal user, completely disabled). There is also configuration as to which packages are
mutually exclusive, such as sponsor and supersponsor.
If configured with a max_count greater than 1, certain packages may occur multiple times in this list.
example: room-none,attendance,sponsor
packages_list:
type: array
items:
type: object
required:
- name
- count
properties:
name:
type: string
example: attendance
description: the code for the package
count:
type: integer
example: 1
description: the number of times the package was purchased (at the moment, only the value 1 is supported, but this may change in the future).
description: A sorted list of packages as declared in configuration. Packages are the things that cost money, like being a supersponsor or a day guest for a certain day. They can be configured with respect to who may add / remove them, if they are on by default, and whether they are visible if not selected (admin only, normal user, completely disabled). There is also configuration as to which packages are mutually exclusive, such as sponsor and supersponsor.
example:
- name: attendance
count: 1
- name: room-none
count: 1
- name: sponsor
count: 1
$ref: '#/components/schemas/PackagesList'
user_comments:
type: string
description: Optional comments the attendee wishes to make regarding their registration. Not processed in any way.
Expand Down Expand Up @@ -2305,6 +2310,41 @@ components:
- configuration
- balances
- all
PackagesList:
type: array
items:
type: object
required:
- name
- count
properties:
name:
type: string
example: attendance
description: the code for the package
count:
type: integer
example: 1
description: the number of times the package was purchased (at the moment, only the value 1 is supported, but this may change in the future).
description: |-
A sorted list of packages as declared in configuration.
Packages are the things that cost money, like being a supersponsor or a day guest for a certain day. They can be
configured with respect to who may add / remove them, if they are on by default, and whether they are visible if
not selected (admin only, normal user, completely disabled). There is also configuration as to which packages are mutually exclusive, such as sponsor and supersponsor.
It is an error to specify a package multiple times in the list. Requests need not sort the package list by name,
but responses sent by this service always have the package list sorted by package name.
The count field can be left unspecified when sending this data structure, that means a count of 1, but it will always be present
and filled in responses sent by this service.
example:
- name: attendance
count: 1
- name: room-none
count: 1
- name: sponsor
count: 1
AdminInfo:
type: object
required:
Expand Down
9 changes: 5 additions & 4 deletions internal/api/v1/attendee/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,10 @@ type AttendeeDto struct {
RegistrationLanguage string `json:"registration_language"` // one out of configurable subset of RFC 5646 locales (default en-US)

// comma separated lists, allowed choices are convention dependent
Flags string `json:"flags"` // hc,anon,ev
Packages string `json:"packages"` // room-none,attendance,stage,sponsor,sponsor2
Options string `json:"options"` // art,anim,music,suit
Flags string `json:"flags"` // hc,anon,ev
Packages string `json:"packages"` // room-none,attendance,stage,sponsor,sponsor2
PackagesList []PackageState `json:"packages_list"`
Options string `json:"options"` // art,anim,music,suit

// comments
UserComments string `json:"user_comments"`
Expand Down Expand Up @@ -137,5 +138,5 @@ type ChoiceState struct {

type PackageState struct {
Name string `json:"name"`
Count int `json:"count"`
Count int `json:"count"` // defaults to 1 if unset in requests
}
1 change: 1 addition & 0 deletions internal/repository/config/structure.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ type (
VisibleFor []string `yaml:"visible_for"` // list of permissions which allow seeing the flag/option/package. Admin can always see everything, "self" can always see non-admin_only, but you can add it for admin_only fields. This field also controls who else can see the info based on their permissions admin field. Example: "self,sponsordesk"
Group string `yaml:"group"` // set if attendee has this group during initial registration
Mandatory bool `yaml:"at-least-one-mandatory"` // one of these MUST be chosen (no constraint if not set on any choices)
MaxCount int `yaml:"max_count"` // only supported for packages, 0 means 1 so can be left out of config
Constraint string `yaml:"constraint"`
ConstraintMsg string `yaml:"constraint_msg"`
}
Expand Down
6 changes: 6 additions & 0 deletions internal/repository/config/validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ func setConfigurationDefaults(c *Application) {
if c.Security.Cors.AllowOrigin == "" {
c.Security.Cors.AllowOrigin = "*"
}
for name, pkgConf := range c.Choices.Packages {
if pkgConf.MaxCount == 0 {
pkgConf.MaxCount = 1
c.Choices.Packages[name] = pkgConf
}
}
if len(c.SpokenLanguages) == 0 {
c.SpokenLanguages = []string{"en-US"}
}
Expand Down
95 changes: 70 additions & 25 deletions internal/service/attendeesrv/attendeesrv.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"fmt"
aulogging "github.com/StephanHCB/go-autumn-logging"
"github.com/eurofurence/reg-attendee-service/internal/api/v1/attendee"
"github.com/eurofurence/reg-attendee-service/internal/api/v1/status"
"github.com/eurofurence/reg-attendee-service/internal/entity"
"github.com/eurofurence/reg-attendee-service/internal/repository/config"
Expand Down Expand Up @@ -140,12 +141,18 @@ func (s *AttendeeServiceImplData) CanChangeEmailTo(ctx context.Context, original
}

func (s *AttendeeServiceImplData) CanChangeChoiceTo(ctx context.Context, what string, originalChoiceStr string, newChoiceStr string, configuration map[string]config.ChoiceConfig) error {
return s.CanChangeChoiceToCurrentStatus(ctx, what, originalChoiceStr, newChoiceStr, configuration, "irrelevant")
originalChoicesMap := choiceStrToMap(originalChoiceStr, configuration)
newChoicesMap := choiceStrToMap(newChoiceStr, configuration)
return s.canChangeChoiceLowlevel(ctx, what, originalChoicesMap, newChoicesMap, configuration, "irrelevant")
}

func (s *AttendeeServiceImplData) CanChangeChoiceToCurrentStatus(ctx context.Context, what string, originalChoiceStr string, newChoiceStr string, configuration map[string]config.ChoiceConfig, currentStatus status.Status) error {
originalChoices := choiceStrToMap(originalChoiceStr, configuration)
newChoices := choiceStrToMap(newChoiceStr, configuration)
func (s *AttendeeServiceImplData) CanChangeChoiceToCurrentStatus(ctx context.Context, what string, originalChoice []attendee.PackageState, newChoice []attendee.PackageState, configuration map[string]config.ChoiceConfig, currentStatus status.Status) error {
originalChoicesMap := choiceListToMap(originalChoice, configuration)
newChoicesMap := choiceListToMap(newChoice, configuration)
return s.canChangeChoiceLowlevel(ctx, what, originalChoicesMap, newChoicesMap, configuration, currentStatus)
}

func (s *AttendeeServiceImplData) canChangeChoiceLowlevel(ctx context.Context, what string, originalChoices map[string]int, newChoices map[string]int, configuration map[string]config.ChoiceConfig, currentStatus status.Status) error {
oneIsMandatory := false
satisfiesOneIsMandatory := false
mandatoryList := make([]string, 0)
Expand All @@ -164,7 +171,7 @@ func (s *AttendeeServiceImplData) CanChangeChoiceToCurrentStatus(ctx context.Con
if v.Mandatory {
oneIsMandatory = true
mandatoryList = append(mandatoryList, k)
if newChoices[k] {
if newChoices[k] > 0 {
satisfiesOneIsMandatory = true
}
}
Expand Down Expand Up @@ -221,11 +228,11 @@ func userAlreadyHasAnotherRegistration(ctx context.Context, identity string, exp
return count != expectedCount, nil
}

func checkNoForbiddenChanges(ctx context.Context, what string, key string, choiceConfig config.ChoiceConfig, originalChoices map[string]bool, newChoices map[string]bool) error {
func checkNoForbiddenChanges(ctx context.Context, what string, key string, choiceConfig config.ChoiceConfig, originalChoices map[string]int, newChoices map[string]int) error {
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 originalChoices[key] > 0 && newChoices[key] == 0 {
if canAllowRemovalDueToConstraint(ctx, what, key, choiceConfig, originalChoices, newChoices) {
return nil
}
Expand All @@ -240,14 +247,14 @@ 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 {
func canAllowRemovalDueToConstraint(ctx context.Context, what string, key string, choiceConfig config.ChoiceConfig, originalChoices map[string]int, newChoices map[string]int) 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] {
if newChoices[constraintK] > 0 {
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
}
Expand All @@ -257,13 +264,13 @@ func canAllowRemovalDueToConstraint(ctx context.Context, what string, key string
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 {
func checkNoForbiddenChangesAfterPayment(ctx context.Context, what string, key string, choiceConfig config.ChoiceConfig, configuration map[string]config.ChoiceConfig, originalChoices map[string]int, newChoices map[string]int, currentStatus status.Status) error {
if ctxvalues.HasApiToken(ctx) || ctxvalues.IsAuthorizedAsGroup(ctx, config.OidcAdminGroup()) {
return nil
}

if currentStatus == status.PartiallyPaid || currentStatus == status.Paid || currentStatus == status.CheckedIn {
if originalChoices[key] && !newChoices[key] && choiceConfig.Price > 0 {
if originalChoices[key] > 0 && newChoices[key] == 0 && choiceConfig.Price > 0 {
oldDues := calcTotalDuesHelper(configuration, originalChoices)
newDues := calcTotalDuesHelper(configuration, newChoices)

Expand All @@ -276,28 +283,28 @@ func checkNoForbiddenChangesAfterPayment(ctx context.Context, what string, key s
return nil
}

func calcTotalDuesHelper(configuration map[string]config.ChoiceConfig, choices map[string]bool) (dues int64) {
for k, selected := range choices {
func calcTotalDuesHelper(configuration map[string]config.ChoiceConfig, choices map[string]int) (dues int64) {
for k, count := range choices {
choiceConfig, ok := configuration[k]
if ok && selected {
dues += choiceConfig.Price
if ok && count > 0 {
dues += choiceConfig.Price * int64(count)
}
}
return dues
}

func checkNoConstraintViolation(key string, choiceConfig config.ChoiceConfig, newChoices map[string]bool) error {
func checkNoConstraintViolation(key string, choiceConfig config.ChoiceConfig, newChoices map[string]int) error {
if choiceConfig.Constraint != "" {
constraints := strings.Split(choiceConfig.Constraint, ",")
for _, cn := range constraints {
constraintK := cn
if strings.HasPrefix(cn, "!") {
constraintK = strings.TrimPrefix(cn, "!")
if newChoices[key] && newChoices[constraintK] {
if newChoices[key] > 0 && newChoices[constraintK] > 0 {
return errors.New("cannot pick both " + key + " and " + constraintK + " - constraint violated")
}
} else {
if newChoices[key] && !newChoices[constraintK] {
if newChoices[key] > 0 && newChoices[constraintK] == 0 {
return errors.New("when picking " + key + ", must also pick " + constraintK + " - constraint violated")
}
}
Expand All @@ -306,31 +313,69 @@ func checkNoConstraintViolation(key string, choiceConfig config.ChoiceConfig, ne
return nil
}

func choiceStrToMap(choiceStr string, configuration map[string]config.ChoiceConfig) map[string]bool {
result := make(map[string]bool)
// choiceStrToMap converts a choice representation in the entity to a map of counts
//
// Can be used for packages, flags, options.
func choiceStrToMap(choiceStr string, configuration map[string]config.ChoiceConfig) map[string]int {
result := make(map[string]int)
// ensure all available keys present
for k, _ := range configuration {
result[k] = false
result[k] = 0
}
if choiceStr != "" {
choices := strings.Split(choiceStr, ",")
for _, pickedKey := range choices {
if pickedKey != "" {
result[pickedKey] = true
currentValue, present := result[pickedKey]
if present {
result[pickedKey] = currentValue + 1
} else {
aulogging.Logger.NoCtx().Warn().Printf("encountered non-configured choice key %s - maybe configuration changed after initial reg? This needs fixing! - continuing", pickedKey)
result[pickedKey] = 1
}
}
}
}
return result
}

func commaSeparatedStrToMap(choiceStr string, allowedValues []string) map[string]bool {
// choiceListToMap converts a choice list to a map of counts
//
// Can be used for packages, flags, options.
func choiceListToMap(choiceList []attendee.PackageState, configuration map[string]config.ChoiceConfig) map[string]int {
result := make(map[string]int)
// ensure all available keys present
for k, _ := range configuration {
result[k] = 0
}
for _, entry := range choiceList {
currentValue, present := result[entry.Name]
if present {
if entry.Count == 0 {
entry.Count = 1
}
result[entry.Name] = currentValue + entry.Count
} else {
aulogging.Logger.NoCtx().Warn().Printf("encountered non-configured choice key '%s' - maybe configuration changed after initial reg? This needs fixing! - continuing", entry.Name)
result[entry.Name] = 1
}
}
return result
}

// commaSeparatedStrToMap converts a comma separated string representation in the entity to a map of booleans
//
// Can be used for permissions, languages, etc.
//
// IMPORTANT: do not use for choices (packages, flags, options), use choiceStrToMap instead to achieve better validation
func commaSeparatedStrToMap(commaSeparatedStr string, allowedValues []string) map[string]bool {
result := make(map[string]bool)
// ensure all available values present
for _, k := range allowedValues {
result[k] = false
}
if choiceStr != "" {
choices := strings.Split(choiceStr, ",")
if commaSeparatedStr != "" {
choices := strings.Split(commaSeparatedStr, ",")
for _, pickedKey := range choices {
if pickedKey != "" {
result[pickedKey] = true
Expand Down
8 changes: 4 additions & 4 deletions internal/service/attendeesrv/dues.go
Original file line number Diff line number Diff line change
Expand Up @@ -158,16 +158,16 @@ func (s *AttendeeServiceImplData) packageDuesByVAT(ctx context.Context, attendee
}

packageConfigs := config.PackagesConfig()
for key, selected := range choiceStrToMap(attendee.Packages, packageConfigs) {
if selected {
for key, count := range choiceStrToMap(attendee.Packages, packageConfigs) {
if count > 0 {
packageConfig, ok := packageConfigs[key]
if !ok {
aulogging.Logger.Ctx(ctx).Warn().Printf("attendee id %d has unknown package %s in db - ignoring during dues calculation", attendee.ID, key)
} else {
vatStr := fmt.Sprintf("%.6f", packageConfig.VatPercent)

previous, _ := result[vatStr]
result[vatStr] = previous + packageConfig.Price
result[vatStr] = previous + packageConfig.Price*int64(count)
}
}
}
Expand All @@ -180,7 +180,7 @@ func (s *AttendeeServiceImplData) considerGuest(ctx context.Context, adminInfo *
if !ok {
aulogging.Logger.Ctx(ctx).Warn().Print("admin only flag 'guest' not configured, skipping")
}
return isGuest
return isGuest > 0
}

func (s *AttendeeServiceImplData) oldDuesByVAT(transactionHistory []paymentservice.Transaction) map[string]int64 {
Expand Down
Loading

0 comments on commit c01371f

Please sign in to comment.