-
Notifications
You must be signed in to change notification settings - Fork 1
/
Festival.groovy
436 lines (345 loc) · 12.7 KB
/
Festival.groovy
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
package ie.festivals
import grails.util.Holders
import ie.festivals.enums.FestivalSource
import ie.festivals.enums.FestivalType
import ie.festivals.notify.FestivalSubscription
import ie.festivals.tag.EuropeTagLib
import ie.festivals.util.HtmlUtils
import org.apache.commons.lang.StringUtils
import static ie.festivals.util.StringUtils.isWebUrl
class Festival implements Cloneable, Named {
@Lazy
private skiddleConfig = grailsApplication.config.festival.skiddle
// if types are defined for these beans, they need to be added to transients
def springSecurityService
def grailsApplication
static final String ELLIPSE = "\u2026";
static final CLONEABLE_PROPERTIES = [
'name',
'type',
'website',
'freeEntry',
'hasLineup',
'twitterUsername',
'longitude',
'latitude',
'videoUrl',
'countryName',
'addressLine1',
'addressLine2',
'city',
'region',
'postCode',
'countryCode',
'synopsis'].asImmutable()
// Audit fields
Date dateCreated
Date lastUpdated
User createdBy
String name
Festival previousOccurrence
/**
* This field allows the Skiddle affiliate ticket URL to festivals that were not imported from the
* Skiddle feed. This should be used if a festival that has already been manually entered subsequently
* appears in a Skiddle feed.
*/
String skiddleUrl
FestivalSource source = FestivalSource.HUMAN
/**
* If festival has an "early bird" price, this is the day it expires on. Some festivals offer multiple early bird
* prices, but currently we only support one-at-a-time
*/
Date earlyBirdExpiry
Date start
Date end
FestivalType type
String website
Boolean freeEntry = false
Boolean hasLineup = true
String lineupUrl
String twitterUsername
String ticketInfo
String synopsis
/**
* Transient property that indicates the distance in KMs of a point from the festival
*/
Float distance
Float longitude
Float latitude
Boolean approved = true
String videoUrl
/**
* The name of the country is saved to the DB (as opposed to being resolved using countryCode), so that it can
* be indexed by the searchable plugin
*/
String countryName
// Address fields as per the "Generic Formats" http://www.uxmatters.com/mt/archives/2008/06/international-address-fields-in-web-forms.php
String addressLine1
String addressLine2
String city
String region
String postCode
void setVideoUrl(String url) {
this.videoUrl = url?.trim()
}
/**
* 3-letter ISO country code
*/
String countryCode
@Override
public Object clone() throws CloneNotSupportedException {
Map<String, Object> source = this.properties
Map cloneableProperties = source.findAll {
it.key in CLONEABLE_PROPERTIES
}
Festival clone = new Festival(cloneableProperties)
clone.previousOccurrence = this
clone
}
static transients = ['fullAddress', 'multiDayDuration', 'finished', 'distance']
// each festival can have multiple Skiddle IDs because consider each day of a multi-day festival as a separate event
static hasMany = [
performances : Performance,
subscriptions: FestivalSubscription,
favorites : FavoriteFestival,
ratings : Rating,
reminders : Reminder,
reviews : Review
]
static searchable = {
mapping {
spellCheck "include"
}
only = ['name', 'addressLine1', 'addressLine2', 'city', 'region', 'postCode', 'countryName', 'approved', 'type',
'start', 'end']
// hits on the name are more important than other fields
name boost: 2.0
// We want these fields included in the index because they're shown on the results page, but we don't want
// them to be searchable
start index: 'no', store: 'yes'
end index: 'no', store: 'yes'
}
Boolean isFinished() {
(new Date() - end) > 0
}
Boolean isMultiDayDuration() {
if (end && start) {
end - start > 0
}
}
void setTicketInfo(String info) {
// don't normalize the ticket info for imported festivals (Eventbrite, Skiddle, etc.)
this.ticketInfo = source == FestivalSource.HUMAN ? HtmlUtils.normalize(info, null) : info
}
void setSynopsis(String info) {
this.synopsis = HtmlUtils.normalize(info, null)
}
void setName(String festivalName) {
this.name = festivalName?.trim()
}
void setAddressLine1(String line1) {
this.addressLine1 = line1?.trim()
}
void setAddressLine2(String line2) {
this.addressLine2 = line2?.trim()
}
void setTwitterUsername(String username) {
username = username?.trim()
if (username && username[0] == '@') {
username = username[1..-1]
}
this.twitterUsername = username
}
void setEarlyBirdExpiry(Date earlyBird) {
if (!freeEntry) {
this.earlyBirdExpiry = earlyBird?.clearTime()
}
}
void setFreeEntry(Boolean isFree) {
if (isFree) {
earlyBirdExpiry = null
}
this.freeEntry = isFree
}
void setStart(Date start) {
this.start = start?.clearTime()
}
void setEnd(Date end) {
this.end = end?.clearTime()
}
void setCity(String city) {
city = city?.trim()
this.city = city ?: null
}
String getFullAddress(boolean excludeCountry = false) {
[addressLine1, addressLine2, city, region, postCode, countryName].with {
if (excludeCountry) {
pop()
}
retainAll { it?.trim() }
join(', ')
}
}
void setCountryCode(String code) {
this.countryCode = code
this.countryName = EuropeTagLib.ISO3166_3[code]
// Ignore any post codes entered for ireland
if (countryCode == 'irl') {
postCode = null
}
}
void setSkiddleUrl(String url) {
url = url?.trim()
if (url) {
def ourSkiddleTag = skiddleConfig.tag
url = StringUtils.replaceOnce(url, 'sktag=XXX', "sktag=$ourSkiddleTag")
this.skiddleUrl = url
}
}
void setPostCode(String code) {
postCode = countryCode == 'irl' ? null : code
}
@Override
String toString() {
name
}
def beforeInsert() {
if (source == FestivalSource.HUMAN) {
createdBy = springSecurityService.currentUser
}
}
static constraints = {
// the festival author could be deleted after they add a festival
createdBy nullable: true
previousOccurrence nullable: true
name blank: false, shared: 'unlimitedSize', validator: { name, self ->
Integer maxNameLength = Holders.config.festival.utf8mb4MaxLength
boolean nameTooLong = name.size() > maxNameLength
// if the name of a festival imported from Eventbrite, Skiddle etc. is too long, truncate it
if (nameTooLong && self.source != FestivalSource.HUMAN) {
self.name = name[0..maxNameLength - 2] + ELLIPSE
}
return self.name.size() <= maxNameLength
}
end validator: { end, self ->
if (end < self.start) {
return 'before.start'
}
// end date can't be earlier than latest performer - only need to check this for updates
if (self.id) {
Date latestPerformance = Performance.createCriteria().get {
eq('festival', self)
eq('deleted', false)
projections {
max "date"
}
}
if (latestPerformance?.clearTime() > end) {
return 'before.last.performer'
}
}
}
start validator: { start, self ->
// start date can't be after earliest performer - only need to check this for updates
if (self.id) {
def criteria = Performance.createCriteria()
Date firstPerformer = criteria.get {
eq('festival', self)
eq('deleted', false)
projections {
min "date"
}
}
if (firstPerformer && firstPerformer < start) {
return 'after.first.performer'
}
}
}
earlyBirdExpiry nullable: true, validator: { earlyBird, self ->
if (!earlyBird) {
return true
}
// if we're creating a new festival then early bird should not be earlier than today
// this constraint is questionable and we may want to remove it in future
boolean newFestivalEarlyBirdExpired = !self.id && earlyBird < new Date().clearTime()
if (newFestivalEarlyBirdExpired || self.end < earlyBird) {
return 'badRange'
}
// free festival can't have an early bird price
if (self.freeEntry) {
return 'freeFestival'
}
}
countryCode size: 3..3
performances nullable: true, shared: 'unlimitedSize'
subscriptions shared: 'unlimitedSize'
favorites shared: 'unlimitedSize'
ratings shared: 'unlimitedSize'
reminders shared: 'unlimitedSize'
reviews shared: 'unlimitedSize'
// the Grails url constraint rejects some valid URLs because of their suffix, e.g. http://example.rocks
// so use a custom URL validator instead https://jira.grails.org/browse/GRAILS-11764
website nullable: true, shared: 'unlimitedSize', validator: {
if (!isWebUrl(it, true)) {
'url.invalid'
}
}
lineupUrl nullable: true, shared: 'unlimitedSize', validator: {
if (!isWebUrl(it, true)) {
'url.invalid'
}
}
skiddleUrl nullable: true, url: true
ticketInfo nullable: true, shared: 'unlimitedSize'
synopsis nullable: true, shared: 'unlimitedSize'
twitterUsername nullable: true, validator: { username ->
if (username && isWebUrl(username)) {
return 'url.invalid'
}
}
addressLine1 nullable: true
addressLine2 nullable: true
region nullable: true
postCode nullable: true
longitude nullable: true, range: -180..180, validator: { longVal, Festival self ->
// The combination of this custom constraint and 'nullable: true' means regular users can provide null,
// but admins must provide a value between -180 and 180
if (longVal == null && self.approved) {
'default.null.message'
}
}
latitude nullable: true, range: -90..90, validator: { latVal, Festival self ->
// The combination of this custom constraint and 'nullable: true' means regular users can provide null,
// but admins must provide a value between -90 and 90
if (latVal == null && self.approved) {
'default.null.message'
}
}
type nullable: true, validator: { FestivalType type, Festival self ->
// Type is optional if festival was parsed from Skiddle XML file because there's no way to
// determine it in this case. When approving a Skiddle festival, type must be provided
if (!type && self.approved) {
'default.null.message'
}
}
city nullable: true, validator: { String city, Festival self ->
// Some Eventbrite festivals don't provide a city, but when approving a festival, a city must be specified
if (!city && self.approved) {
'default.null.message'
}
}
videoUrl nullable: true, matches: 'http://www.youtube.com/embed/.+'
}
static mapping = {
ticketInfo type: 'text'
synopsis type: 'text'
website type: 'text'
def maxEnumLength = Holders.config.festival.utf8mb4MaxLength
// Add an index to improve queries. Another candidates is freeEntry but as I don't really know whether I
// should define individual indices for each column or compound indices, keep it simple.
type index: 'type_index', length: maxEnumLength
countryCode index: 'country_index'
source length: maxEnumLength
cache true
}
}