From 8162b2fc45841022ea2ef8108c77eb3dc4dc8eba Mon Sep 17 00:00:00 2001 From: Sander Roosendaal Date: Tue, 27 Nov 2018 17:49:02 +0100 Subject: [PATCH 1/7] initial incomplete version --- rowers/models.py | 180 ++++++++- rowers/mytypes.py | 6 + rowers/plannedsessions.py | 167 +++++++- .../templates/indoorvirtualeventcreate.html | 61 +++ rowers/templates/menu_racing.html | 5 + rowers/templates/racelist.html | 6 +- rowers/templates/virtualevent.html | 64 ++- rowers/templates/virtualeventcreate.html | 2 +- rowers/urls.py | 3 + rowers/views.py | 373 ++++++++++++++++-- 10 files changed, 815 insertions(+), 52 deletions(-) create mode 100644 rowers/templates/indoorvirtualeventcreate.html diff --git a/rowers/models.py b/rowers/models.py index 3a711c35..34d4aa1d 100644 --- a/rowers/models.py +++ b/rowers/models.py @@ -1664,6 +1664,7 @@ class PlannedSession(models.Model): ('cycletarget','Total for a time period'), ('coursetest','OTW test over a course'), ('race','Virtual Race'), + ('indoorrace','Indoor Virtual Race'), ) sessionmodechoices = ( @@ -1772,7 +1773,7 @@ class PlannedSession(models.Model): else: self.sessionunit = 'None' - if self.sessiontype == 'test': + if self.sessiontype == 'test' or self.sessiontype == 'indoorrace': if self.sessionmode not in ['distance','time']: if self.sessionvalue < 100: self.sessionmode = 'time' @@ -1937,7 +1938,126 @@ def get_course_timezone(course): return timezone_str +class IndoorVirtualRaceForm(ModelForm): + registration_closure = forms.SplitDateTimeField(widget=AdminSplitDateTime(),required=False) + evaluation_closure = forms.SplitDateTimeField(widget=AdminSplitDateTime(),required=True) + timezone = forms.ChoiceField(initial='UTC', + choices=[(x,x) for x in pytz.common_timezones], + label='Time Zone') + + class Meta: + model = VirtualRace + fields = [ + 'name', + 'startdate', + 'start_time', + 'enddate', + 'end_time', + 'timezone', + 'sessionvalue', + 'sessionunit', + 'registration_form', + 'registration_closure', + 'evaluation_closure', + 'comment', + 'contact_phone', + 'contact_email', + ] + + dateTimeOptions = { + 'format': 'yyyy-mm-dd', + 'autoclose': True, + } + + widgets = { + 'comment': forms.Textarea, + 'startdate': AdminDateWidget(), + 'enddate': AdminDateWidget(), + 'start_time': AdminTimeWidget(), + 'end_time': AdminTimeWidget(), + 'registration_closure':AdminSplitDateTime(), + 'evaluation_closure':AdminSplitDateTime(), + } + + def __init__(self,*args,**kwargs): + super(IndoorVirtualRaceForm, self).__init__(*args, **kwargs) + self.fields['sessionunit'].choices = [('min','minutes'),('m','meters')] + + def clean(self): + cd = self.cleaned_data + timezone_str = cd['timezone'] + + start_time = cd['start_time'] + if start_time is None: + raise forms.ValidationError( + 'Must have start time', + code='missing_yparam1' + ) + start_date = cd['startdate'] + startdatetime = datetime.datetime.combine(start_date,start_time) + startdatetime = pytz.timezone(timezone_str).localize( + startdatetime + ) + + end_time = cd['end_time'] + if end_time is None: + raise forms.ValidationError( + 'Must have end time', + code='missing endtime' + ) + + end_date = cd['enddate'] + enddatetime = datetime.datetime.combine(end_date,end_time) + enddatetime = pytz.timezone(timezone_str).localize( + enddatetime + ) + + registration_closure = cd['registration_closure'] + + registration_form = cd['registration_form'] + + try: + evaluation_closure = cd['evaluation_closure'] + except KeyError: + evaluation_closure = enddatetime+datetime.timedelta(days=1) + cd['evaluation_closure'] = evaluation_closure + + if registration_form == 'manual': + try: + registration_closure = pytz.timezone( + timezone_str + ).localize( + registration_closure.replace(tzinfo=None) + ) + except AttributeError: + registration_closure = startdatetime + elif registration_form == 'windowstart': + registration_closure = startdatetime + elif registration_form == 'windowend': + registration_closure = enddatetime + else: + registration_closure = evaluation_closure + + + if registration_closure <= timezone.now(): + raise forms.ValidationError("Registration Closure cannot be in the past") + + + if startdatetime > enddatetime: + raise forms.ValidationError("The Start of the Race Window should be before the End of the Race Window") + + + if cd['evaluation_closure'] <= enddatetime: + raise forms.ValidationError("Evaluation closure deadline should be after the Race Window closes") + + if cd['evaluation_closure'] <= timezone.now(): + raise forms.ValidationError("Evaluation closure cannot be in the past") + + + return cd + + class VirtualRaceForm(ModelForm): course = forms.ModelChoiceField(queryset = GeoCourse.objects, empty_label=None) registration_closure = forms.SplitDateTimeField(widget=AdminSplitDateTime(),required=False) @@ -2309,6 +2429,54 @@ class VirtualRaceResult(models.Model): s = self.sex, ) +# Virtual Race results (for keeping results when workouts are deleted) +class IndoorVirtualRaceResult(models.Model): + boatclasses = (type for type in mytypes.workouttypes if type[0] in mytypes.otetypes) + userid = models.IntegerField(default=0) + teamname = models.CharField(max_length=80,verbose_name = 'Team Name', + blank=True,null=True) + username = models.CharField(max_length=150) + workoutid = models.IntegerField(null=True) + weightcategory = models.CharField(default="hwt",max_length=10, + choices=weightcategories, + verbose_name='Weight Category') + race = models.ForeignKey(VirtualRace) + duration = models.TimeField(default=datetime.time(1,0)) + distance = models.IntegerField(default=0) + boatclass = models.CharField(choices=boatclasses, + max_length=40, + default='rower', + verbose_name = 'Ergometer Class') + coursecompleted = models.BooleanField(default=False) + sex = models.CharField(default="not specified", + max_length=30, + choices=sexcategories, + verbose_name='Gender') + + age = models.IntegerField(null=True) + + def __unicode__(self): + rr = Rower.objects.get(id=self.userid) + name = '{u1} {u2}'.format( + u1 = rr.user.first_name, + u2 = rr.user.last_name, + ) + if self.teamname: + return u'Entry for {n} for "{r}" in {c} with {t} ({s})'.format( + n = name, + r = self.race, + t = self.teamname, + c = self.boatclass, + s = self.sex, + ) + else: + return u'Entry for {n} for "{r}" in {c} ({s})'.format( + n = name, + r = self.race, + c = self.boatclass, + s = self.sex, + ) + class CourseTestResult(models.Model): userid = models.IntegerField(default=0) @@ -2318,6 +2486,16 @@ class CourseTestResult(models.Model): distance = models.IntegerField(default=0) coursecompleted = models.BooleanField(default=False) +class IndoorVirtualRaceResultForm(ModelForm): + class Meta: + model = IndoorVirtualRaceResult + fields = ['teamname','weightcategory','boatclass','age'] + + + def __init__(self, *args, **kwargs): + super(IndoorVirtualRaceResultForm, self).__init__(*args, **kwargs) + + class VirtualRaceResultForm(ModelForm): class Meta: model = VirtualRaceResult diff --git a/rowers/mytypes.py b/rowers/mytypes.py index e0b9fa7a..ad704756 100644 --- a/rowers/mytypes.py +++ b/rowers/mytypes.py @@ -225,6 +225,12 @@ otwtypes = ( 'churchboat' ) +otetypes = ( + 'rower', + 'dynamic', + 'slides' + ) + rowtypes = ( 'water', 'rower', diff --git a/rowers/plannedsessions.py b/rowers/plannedsessions.py index 3b274605..8edd5390 100644 --- a/rowers/plannedsessions.py +++ b/rowers/plannedsessions.py @@ -20,7 +20,7 @@ from rowers.models import ( Rower, Workout,Team, GeoCourse, TrainingMicroCycle,TrainingMesoCycle,TrainingMacroCycle, TrainingPlan,PlannedSession,VirtualRaceResult,CourseTestResult, - get_course_timezone + get_course_timezone, IndoorVirtualRaceResult ) from rowers.courses import get_time_course @@ -574,6 +574,56 @@ def update_plannedsession(ps,cd): return 1,'Planned Session Updated' +def update_indoorvirtualrace(ps,cd): + for attr, value in cd.items(): + if attr == 'comment': + value.replace("\r\n", " "); + value.replace("\n", " "); + setattr(ps, attr, value) + + timezone_str = cd['timezone'] + + # correct times + + startdatetime = datetime.combine(cd['startdate'],cd['start_time']) + enddatetime = datetime.combine(cd['enddate'],cd['end_time']) + + startdatetime = pytz.timezone(timezone_str).localize( + startdatetime + ) + enddatetime = pytz.timezone(timezone_str).localize( + enddatetime + ) + ps.evaluation_closure = pytz.timezone(timezone_str).localize( + ps.evaluation_closure.replace(tzinfo=None) + ) + + registration_form = cd['registration_form'] + registration_closure = cd['registration_closure'] + if registration_form == 'manual': + try: + registration_closure = pytz.timezone( + timezone_str + ).localize( + registration_closure.replace(tzinfo=None) + ) + except AttributeError: + registration_closure = startdatetime + elif registration_form == 'windowstart': + registration_closure = startdatetime + elif registration_form == 'windowend': + registration_closure = enddatetime + else: + registration_closure = ps.evaluation_closure + + ps.registration_closure = registration_closure + + ps.timezone = timezone_str + + ps.save() + + return 1,'Virtual Race Updated' + def update_virtualrace(ps,cd): for attr, value in cd.items(): if attr == 'comment': @@ -708,6 +758,10 @@ def race_can_resubmit(r,race): return False def race_can_adddiscipline(r,race): + + if race.sessiontype != 'race': + return False + records = VirtualRaceResult.objects.filter( userid=r.id, race=race) @@ -813,6 +867,116 @@ def remove_rower_race(r,race,recordid=None): return 1 # Low Level functions - to be called by higher level methods +def add_workout_indoorrace(ws,race,r,recordid=0): + result = 0 + comments = [] + errors = [] + + start_time = race.start_time + start_date = race.startdate + startdatetime = datetime.combine(start_date,start_time) + startdatetime = pytz.timezone(race.timezone).localize( + startdatetime + ) + + end_time = race.end_time + end_date = race.enddate + enddatetime = datetime.combine(end_date,end_time) + enddatetime = pytz.timezone(race.timezone).localize( + enddatetime + ) + + # check if all sessions have same date + dates = [w.date for w in ws] + if (not all(d == dates[0] for d in dates)) and race.sessiontype not in ['challenge','cycletarget']: + errors.append('For tests and training sessions, selected workouts must all be done on the same date') + return result,comments,errors,0 + + if len(ws)>1 and race.sessiontype == 'test': + errors.append('For tests, you can only attach one workout') + return result,comments,errors,0 + + + + ids = [w.id for w in ws] + ids = list(set(ids)) + + if len(ids)>1 and race.sessiontype in ['test','coursetest','race','indoorrace']: + errors.append('For tests, you can only attach one workout') + return result,comments,errors,0 + + + + username = r.user.first_name+' '+r.user.last_name + if r.birthdate: + age = calculate_age(r.birthdate) + else: + age = None + + record = IndoorVirtualRaceResult.objects.get( + userid=r.id, + race=race, + id=recordid + ) + + records = IndoorVirtualRaceResult.objects.filter( + userid=r.id, + race=race, + workoutid = ws[0].id + ) + + if not record: + errors.append("Couldn't find this entry") + return result,comments,errors,0 + + if race.sessionmode == 'distance': + if ws[0].distance != race.sessionvalue: + errors.append('Your workout did not have the correct distance') + return 0,comments, errors, 0 + else: + record.distance = ws[0].distance + record.duration = ws[0].duration + else: + t = ws[0].duration + seconds = t.second+t.minute*60.+t.hour*3600.+t.microsecond/1.e6 + if seconds != race.sessionvalue*60.: + errors.append('Your workout did not have the correct duration') + return 0, comments, errors, 0 + else: + record.distance = ws[0].distance + record.duration = ws[0].duration + + + if ws[0].weightcategory != record.weightcategory: + errors.append('Your workout weight category did not match the weight category you registered') + return 0,comments, errors,0 + + # start adding sessions + if ws[0].startdatetime>=startdatetime and ws[0].startdatetime<=enddatetime: + ws[0].plannedsession = race + ws[0].save() + result += 1 + + else: + errors.append('Workout %i did not match the race window' % ws[0].id) + return result,comments,errors,0 + + if result>0: + for otherrecord in records: + otherrecord.workoutid = None + otherrecord.coursecompleted = False + otherrecord.save() + + record.coursecompleted = True + record.workoutid = ws[0].id + record.save() + + add_workouts_plannedsession(ws,race,r) + + + return result,comments,errors,0 + + def add_workout_race(ws,race,r,splitsecond=0,recordid=0): result = 0 comments = [] @@ -895,7 +1059,6 @@ def add_workout_race(ws,race,r,splitsecond=0,recordid=0): if result>0: for otherrecord in records: - print otherrecord otherrecord.workoutid = None otherrecord.coursecompleted = False otherrecord.save() diff --git a/rowers/templates/indoorvirtualeventcreate.html b/rowers/templates/indoorvirtualeventcreate.html new file mode 100644 index 00000000..912d21fa --- /dev/null +++ b/rowers/templates/indoorvirtualeventcreate.html @@ -0,0 +1,61 @@ +{% extends "newbase.html" %} +{% load staticfiles %} +{% load rowerfilters %} + +{% block title %}New Virtual Race{% endblock %} + +{% block main %} + +

New Indoor Virtual Race

+ + + + +{% endblock %} + +{% block scripts %} +{% endblock %} + +{% block sidebar %} +{% include 'menu_racing.html' %} +{% endblock %} diff --git a/rowers/templates/menu_racing.html b/rowers/templates/menu_racing.html index 6aabdaff..4a06ef2d 100644 --- a/rowers/templates/menu_racing.html +++ b/rowers/templates/menu_racing.html @@ -10,6 +10,11 @@  New Race +
  • + +  New Indoor Race + +
  •  Courses diff --git a/rowers/templates/racelist.html b/rowers/templates/racelist.html index 2784b896..092de0d2 100644 --- a/rowers/templates/racelist.html +++ b/rowers/templates/racelist.html @@ -8,7 +8,8 @@ Event Country Course - Distance + + Click for Details @@ -26,7 +27,8 @@ {{ race.name }} {{ race.course.country }} {{ race.course.name }} - {{ race.sessionvalue }} m + {{ race.sessionvalue }} + {{ race.sessionunit }} {% if rower %} {% if race|can_register:rower %} diff --git a/rowers/templates/virtualevent.html b/rowers/templates/virtualevent.html index a731cf32..338d1805 100644 --- a/rowers/templates/virtualevent.html +++ b/rowers/templates/virtualevent.html @@ -14,6 +14,7 @@

    {{ race.name }}

    + +{% endblock %} + +{% block sidebar %} +{% include 'menu_racing.html' %} +{% endblock %} diff --git a/rowers/templates/disqualificationemail.html b/rowers/templates/disqualificationemail.html new file mode 100644 index 00000000..1c7dca91 --- /dev/null +++ b/rowers/templates/disqualificationemail.html @@ -0,0 +1,37 @@ +{% extends "emailbase.html" %} +{% block body %} +

    Dear {{ username }},

    + +

    + Unfortunately, the result that you have submitted + for the virtual race {{ racename }} + has been rejected by the race organizer. +

    + +

    + The reason for the rejection was: {{ reason }}. +

    + +

    + The race organizer added the following explanation: +

    + +

    + {{ message }} +

    + +

    + The decision to reject your result is the sole responsibility of + the race organizer. If you disagree with the decision, please do contact + him or her. +

    + +

    + You are still registered for the race and can submit a result. +

    + +

    + Best Regards, the Rowsandall Team +

    +{% endblock %} + diff --git a/rowers/templates/indoorvirtualeventcreate.html b/rowers/templates/indoorvirtualeventcreate.html index 912d21fa..69970198 100644 --- a/rowers/templates/indoorvirtualeventcreate.html +++ b/rowers/templates/indoorvirtualeventcreate.html @@ -39,12 +39,18 @@
  • diff --git a/rowers/templates/virtualevent.html b/rowers/templates/virtualevent.html index 07dae4e3..e6a86fe2 100644 --- a/rowers/templates/virtualevent.html +++ b/rowers/templates/virtualevent.html @@ -140,7 +140,11 @@
  • + {% if race|is_final %} +

    Final Results

    + {% else %}

    Results

    + {% endif %}

    {% if results or dns %} @@ -184,7 +188,7 @@ Details - {% if race.manager == request.user %} + {% if race.manager == request.user and not race|is_final %} Disqualify @@ -287,11 +291,13 @@ up and manage small regattas.

    - On the water races are rowed on the course shown. You cannot submit results rowed + On the water races are rowed on the course shown. + You cannot submit results rowed on other bodies of water.

    - Indoor races are open for all, wherever you live. However, be aware of the + Indoor races are open for all, wherever you live. + However, be aware of the time zone for the race window.

    @@ -334,7 +340,12 @@ refereed or staffed to provide for participants safety. Individual participants are entirely responsible for their safety while participating in a virtual race. -

    +

    +

    + Until the evaluation closure time, the race organizer can + review and reject entries. If you are disqualified in this + way, you will receive an email with the reason. +

  • diff --git a/rowers/templates/virtualeventcreate.html b/rowers/templates/virtualeventcreate.html index d2489440..131ce9d1 100644 --- a/rowers/templates/virtualeventcreate.html +++ b/rowers/templates/virtualeventcreate.html @@ -37,15 +37,17 @@
  • -

    -

    +

    All times are local times in the race course time zone

    +

    Adding a contact phone number and email is not mandatory, but we + strongly recommend it.

    +

    If your event has a registration closure deadline, participants + have to enter (and can withdraw) before the registration closure time.

    +

    Participants can submit results until the evaluation closure time.

    +

    Until one hour after evaluation closure time, the race organizer + can review and reject submitted results ("disqualification"). If + you as the race organizer intend to use this functionality, it + is strongly recommended that you fill out a contact email or phone + number.

  • diff --git a/rowers/templatetags/rowerfilters.py b/rowers/templatetags/rowerfilters.py index 0142f292..c9b81bd3 100644 --- a/rowers/templatetags/rowerfilters.py +++ b/rowers/templatetags/rowerfilters.py @@ -370,6 +370,14 @@ def is_past_due(self): def is_not_past_due(self): return datetime.date.today() <= self.date +@register.filter +def is_closed(race): + return race.evaluation_closure < timezone.now() + +@register.filter +def is_final(race): + return race.evaluation_closure < timezone.now()-datetime.timedelta(hours=1) + @register.filter def userurl(path,member): pattern = re.compile('user\/\d+') diff --git a/rowers/views.py b/rowers/views.py index 2b048024..307ba0de 100644 --- a/rowers/views.py +++ b/rowers/views.py @@ -49,7 +49,8 @@ from rowers.forms import ( VirtualRaceSelectForm,WorkoutRaceSelectForm,CourseSelectForm, RaceResultFilterForm,PowerIntervalUpdateForm,FlexAxesForm, FlexOptionsForm,DataFrameColumnsForm,OteWorkoutTypeForm, - MetricsForm, + MetricsForm,DisqualificationForm,disqualificationreasons, + disqualifiers ) from django.core.urlresolvers import reverse, reverse_lazy @@ -156,6 +157,7 @@ from rowers.tasks import handle_makeplot,handle_otwsetpower,handle_sendemailtcx, from rowers.tasks import ( handle_sendemail_unrecognized,handle_sendemailnewcomment, handle_sendemailsummary, + handle_send_disqualification_email, handle_sendemailfile, handle_sendemailkml, handle_sendemailnewresponse, handle_updatedps, @@ -15837,9 +15839,6 @@ def virtualevent_disqualify_view(request,raceid=0,recordid=0): r = getrower(request.user) - # datum moet voor race evaluation date zijn (ook in template controleren) - - try: race = VirtualRace.objects.get(id=raceid) except VirtualRace.DoesNotExist: @@ -15853,24 +15852,111 @@ def virtualevent_disqualify_view(request,raceid=0,recordid=0): else: recordobj = IndoorVirtualRaceResult - if timezone.now() > race.evaluation_closure: - try: - record = recordobj.objects.get(id=recordid) + # datum moet voor race evaluation date zijn (ook in template controleren) + try: + record = recordobj.objects.get(id=recordid) + except recordobj.DoesNotExist: + messages.error(request,"We couldn't find the record") + if timezone.now() > race.evaluation_closure+datetime.timedelta(hours=1): + messages.error(request,"The evaluation is already closed and the results are official") + url = reverse(virtualevent_view,kwargs={'id':raceid}) + + return HttpResponseRedirect(url) + + if request.method == 'POST': + form = DisqualificationForm(request.POST) + if form.is_valid(): + message = form.cleaned_data['message'] + reason = form.cleaned_data['reason'] + disqualifier = disqualifiers[reason] + + u = User.objects.get(id=record.userid) + name = record.username + + job = myqueue(queue,handle_send_disqualification_email, + u.email, name, + disqualifier,message,race.name) + messages.info(request,"We have invalidated the result for: "+str(record)) record.coursecompleted = False record.save() - print record.coursecompleted - except recordobj.DoesNotExist: - messages.error(request,"We couldn't find the record") + + url = reverse(virtualevent_view,kwargs={'id':raceid}) + + return HttpResponseRedirect(url) + else: - messages.error(request,"The evaluation is already closed and the results are official") + form = DisqualificationForm(request.POST) - url = reverse(virtualevent_view,kwargs={'id':raceid}) + workout = Workout.objects.get(id=record.workoutid) - return HttpResponseRedirect(url) + g = GraphImage.objects.filter(workout=workout).order_by("-creationdatetime") + for i in g: + try: + width,height = Image.open(i.filename).size + i.width = width + i.height = height + i.save() + except: + pass + + script, div = interactive_chart(record.workoutid) + + f1 = workout.csvfilename + rowdata = rdata(f1) + hascoordinates = 1 + if rowdata != 0: + try: + latitude = rowdata.df[' latitude'] + if not latitude.std(): + hascoordinates = 0 + except KeyError, AttributeError: + hascoordinates = 0 + else: + hascoordinates = 0 + + if hascoordinates: + mapscript, mapdiv = leaflet_chart(rowdata.df[' latitude'], + rowdata.df[' longitude'], + workout.name) + else: + mapscript = "" + mapdiv = "" + + breadcrumbs = [ + { + 'url':reverse(virtualevents_view), + 'name': 'Racing' + }, + { + 'url':reverse(virtualevent_view, + kwargs={'id':race.id}), + 'name': race.name + }, + { + 'url':reverse(virtualevent_disqualify_view, + kwargs={'raceid':raceid, + 'recordid':recordid}), + 'name': 'Disqualify Entry' + }, + ] + + + return render(request,"disqualification_view.html", + {'workout':workout, + 'active':'nav-racing', + 'graphs':g, + 'interactiveplot':script, + 'the_div':div, + 'mapscript':mapscript, + 'mapdiv':mapdiv, + 'form':form, + 'race':race, + 'record':record, + }) def virtualevent_view(request,id=0): From 1c79a03f276203b50c079d041bfb905acab0bf39 Mon Sep 17 00:00:00 2001 From: Sander Roosendaal Date: Wed, 28 Nov 2018 15:39:25 +0100 Subject: [PATCH 7/7] added explanation --- rowers/templates/indoorvirtualeventcreate.html | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/rowers/templates/indoorvirtualeventcreate.html b/rowers/templates/indoorvirtualeventcreate.html index 69970198..1492d6aa 100644 --- a/rowers/templates/indoorvirtualeventcreate.html +++ b/rowers/templates/indoorvirtualeventcreate.html @@ -45,12 +45,16 @@

    If your event has a registration closure deadline, participants have to enter (and can withdraw) before the registration closure time.

    Participants can submit results until the evaluation closure time.

    -

    Until one hour after evaluation closure time, the race organizer - can review and reject submitted results ("disqualification"). If - you as the race organizer intend to use this functionality, it - is strongly recommended that you fill out a contact email or phone - number. -

    +

    Until one hour after evaluation closure time, the race organizer + can review and reject submitted results ("disqualification"). If + you as the race organizer intend to use this functionality, it + is strongly recommended that you fill out a contact email or phone + number. +

    +

    + If you require a screenshot of the PM monitor, do mention this + in the comment. +