diff --git a/rowers/alerts.py b/rowers/alerts.py
new file mode 100644
index 00000000..0865d07e
--- /dev/null
+++ b/rowers/alerts.py
@@ -0,0 +1,172 @@
+from rowers.models import Alert, Condition, User, Rower, Workout
+from rowers.teams import coach_getcoachees
+from rowers.dataprep import getsmallrowdata_db,getrowdata_db
+import datetime
+## BASIC operations
+
+# create alert
+def create_alert(manager, rower, measured,period=7, emailalert=True,
+ reststrokes=False, workouttype='water',
+ name='',**kwargs):
+
+ # check if manager is coach of rower. If not return 0
+ if manager.rower != rower:
+ if rower not in coach_getcoachees(manager.rower):
+ return 0,'You are not allowed to create this alert'
+
+ m = Condition(
+ metric = measured['metric'],
+ value1 = measured['value1'],
+ value2 = measured['value2'],
+ condition=measured['condition']
+ )
+
+ m.save()
+
+ alert = Alert(name=name,
+ manager=manager,
+ rower=rower,
+ measured=m,
+ reststrokes=reststrokes,
+ period=period,
+ emailalert=emailalert,
+ workouttype=workouttype
+ )
+
+ alert.save()
+
+ if 'filter' in kwargs:
+ filters = kwargs['filter']
+ for f in filters:
+ if f['metric'] and f['condition']:
+ m = Condition(
+ metric = f['metric'],
+ value1 = f['value1'],
+ value2 = f['value2'],
+ condition = f['condition']
+ )
+
+ m.save()
+
+ alert.filter.add(m)
+
+
+ return alert.id,'Your alert was created'
+
+
+
+# update alert
+def alert_add_filters(alert,filters):
+ for f in alert.filter.all():
+ alert.filter.remove(f)
+ f.delete()
+
+ for f in filters:
+ metric = f['metric']
+ value1 = f['value1']
+ value2 = f['value2']
+ condition = f['condition']
+
+ if condition and metric and value1:
+ m = Condition(
+ metric = f['metric'],
+ value1 = f['value1'],
+ value2 = f['value2'],
+ condition = f['condition']
+ )
+
+ m.save()
+
+ alert.filter.add(m)
+
+ return 1
+
+# get alert stats
+# nperiod = 0: current period, i.e. next_run - n days to today
+# nperiod = 1: 1 period ago , i.e. next_run -2n days to next_run -n days
+def alert_get_stats(alert,nperiod=0):
+ # get strokes
+ workstrokesonly = not alert.reststrokes
+ startdate = (alert.next_run - datetime.timedelta(days=(nperiod+1)*alert.period-1))
+ enddate = alert.next_run - datetime.timedelta(days=(nperiod)*alert.period)
+ columns = [alert.measured.metric]
+
+ for condition in alert.filter.all():
+ columns += condition.metric
+
+ workouts = Workout.objects.filter(date__gte=startdate,date__lte=enddate,user=alert.rower,
+ workouttype=alert.workouttype)
+ ids = [w.id for w in workouts]
+
+ df = getsmallrowdata_db(columns,ids=ids,doclean=True,workstrokesonly=workstrokesonly)
+
+ if df.empty:
+ return {
+ 'workouts':len(workouts),
+ 'startdate':startdate,
+ 'enddate':enddate,
+ 'nr_strokes':0,
+ 'nr_strokes_qualifying':0,
+ }
+
+ # check if filters are in columns list
+ pdcolumns = set(df.columns)
+
+ # drop strokes through filter
+ if set(columns) <= pdcolumns:
+ for condition in alert.filter.all():
+ if condition.condition == '>':
+ mask = df[condition.metric] > condition.value1
+ df.loc[mask,alert.measured.metric] = np.nan
+ elif condition.condition == '<':
+ mask = df[condition.metric] < condition.value1
+ df.loc[mask,alert.measured.metric] = np.nan
+ elif condition.condition == 'between':
+ mask = df[condition.metric] > condition.value1
+ mask2 = df[condition.metric] < condition.value2
+ df.loc[mask & mask2,alert.measured.metric] = np.nan
+ elif condition.condition == '=':
+ mask = df[condition.metric] == condition.value1
+ df.loc[mask,alert.measured.metric] = np.nan
+
+ df.dropna(inplace=True,axis=0)
+ else:
+ return {
+ 'workouts':len(workouts),
+ 'startdate':startdate,
+ 'enddate':enddate,
+ 'nr_strokes':0,
+ 'nr_strokes_qualifying':0,
+ }
+
+
+ # count strokes
+ nr_strokes = len(df)
+
+
+ # count qualifying
+ if alert.measured.condition == '>':
+ mask = df[alert.measured.metric] > alert.measured.value1
+ df2 = df[mask].copy()
+ elif alert.measured.condition == '<':
+ mask = df[alert.measured.metric] > alert.measured.value1
+ df2 = df[mask].copy()
+ elif alert.measured.condition == 'between':
+ mask = df[alert.measured.metric] > alert.measured.value1
+ mask2 = df[alert.measured.metric] < alert.measured.value2
+ df2 = df[mask & mask2].copy()
+ else:
+ mask = df[alert.measured.metric] == alert.measured.value1
+ df2 = df[mask].copy()
+
+ nr_strokes_qualifying = len(df2)
+
+ return {
+ 'workouts':len(workouts),
+ 'startdate':startdate,
+ 'enddate':enddate,
+ 'nr_strokes':nr_strokes,
+ 'nr_strokes_qualifying':nr_strokes_qualifying
+ }
+
+# run alert report
diff --git a/rowers/management/commands/processalerts.py b/rowers/management/commands/processalerts.py
new file mode 100644
index 00000000..b9efe371
--- /dev/null
+++ b/rowers/management/commands/processalerts.py
@@ -0,0 +1,62 @@
+#!/srv/venv/bin/python
+
+import sys
+import os
+
+PY3K = sys.version_info >= (3,0)
+
+from django.core.management.base import BaseCommand
+from rowers.models import Alert, Condition, User
+from rowers.tasks import handle_send_email_alert
+
+from rowers import alerts
+
+from rowers.utils import myqueue
+
+
+import django_rq
+queue = django_rq.get_queue('default')
+queuelow = django_rq.get_queue('low')
+queuehigh = django_rq.get_queue('low')
+
+
+import datetime
+
+class Command(BaseCommand):
+ def add_arguments(self, parser):
+ parser.add_argument(
+ '--testing',
+ action='store_true',
+ dest='testing',
+ default=False,
+ help="Run in testing mode, don't send emails",
+ )
+
+ def handle(self, *args, **options):
+ if 'testing' in options:
+ testing = options['testing']
+ else:
+ testing = False
+
+ todaysalerts = Alert.objects.filter(next_run <= datetime.date.today(),emailalert=True)
+
+ for alert in todaysalerts:
+ stats = alerts.alert_get_stats(alert)
+
+ # send email
+ job = myqueue(queue,handle_send_email_alert,
+ alert.manager.email,
+ alert.manager.first_name,
+ alert.manager.last_name,
+ stats,debug=True)
+
+ # advance next_run
+ if not testing:
+ alert.next_run = datetime.date.today() + datetime.timedelta(days=alert.period)
+ alert.save()
+
+ if testing:
+ print('{nr} alerts found'.format(nr = len(todaysalerts)))
+
+ self.stdout.write(self.style.SUCCESS(
+ 'Successfully processed alerts'))
diff --git a/rowers/models.py b/rowers/models.py
index 7b42bf24..9caf9810 100644
--- a/rowers/models.py
+++ b/rowers/models.py
@@ -1011,6 +1011,83 @@ class BaseFavoriteFormSet(BaseFormSet):
if not yparam2:
yparam2 = 'None'
+
+
+class Condition(models.Model):
+ conditionchoices = (
+ ('<','<'),
+ ('>','>'),
+ ('=','='),
+ ('between','between')
+ )
+ metric = models.CharField(max_length=50,choices=parchoicesy1,verbose_name='Metric')
+ value1 = models.FloatField(default=0)
+ value2 = models.FloatField(default=0,null=True,blank=True)
+ condition = models.CharField(max_length=20,choices=conditionchoices,null=True)
+
+class ConditionEditForm(ModelForm):
+ class Meta:
+ model = Condition
+ fields = ['metric','condition','value1','value2']
+
+ def clean(self):
+ cd = self.cleaned_data
+ if cd['condition'] == 'between' and cd['value2'] is None:
+ raise forms.ValidationError('When using between, you must fill value 1 and value 2')
+
+class BaseConditionFormSet(BaseFormSet):
+ def clean(self):
+ if any(self.errors):
+ return
+
+ for form in self.forms:
+ if form.cleaned_data:
+ metric = form.cleaned_data['metric']
+ condition = form.cleaned_data['condition']
+ value1 = form.cleaned_data['value1']
+ value2 = form.cleaned_data['value2']
+
+
+
+rowchoices = []
+for key,value in mytypes.workouttypes:
+ if key in mytypes.rowtypes:
+ rowchoices.append((key,value))
+
+
+
+class Alert(models.Model):
+ name = models.CharField(max_length=150,verbose_name='Alert Name',null=True,blank=True)
+ manager = models.ForeignKey(User, on_delete=models.CASCADE)
+ rower = models.ForeignKey(Rower, on_delete=models.CASCADE)
+ measured = models.OneToOneField(Condition,verbose_name='Measuring',on_delete=models.CASCADE,
+ related_name='measured')
+ filter = models.ManyToManyField(Condition, related_name='filters',verbose_name='Filters')
+ reststrokes = models.BooleanField(default=False,null=True,verbose_name='Include Rest Strokes')
+ period = models.IntegerField(default=7,verbose_name='Reporting Period (days)')
+ next_run = models.DateField(default=timezone.now)
+ emailalert = models.BooleanField(default=True,verbose_name='Send email alerts')
+ workouttype = models.CharField(choices=rowchoices,max_length=50,
+ verbose_name='Exercise/Boat Class',default='water')
+
+
+ def __str__(self):
+ stri = u'Alert {name} on {metric} for {workouttype}'.format(
+ name = self.name,
+ metric = self.measured.metric,
+ workouttype = self.workouttype
+ )
+
+ return stri
+
+class AlertEditForm(ModelForm):
+ class Meta:
+ model = Alert
+ fields = ['name','reststrokes','period','emailalert','workouttype']
+ widgets = {
+ 'reststrokes':forms.CheckboxInput()
+ }
+
class BasePlannedSessionFormSet(BaseFormSet):
def clean(self):
if any(self.serrors):
diff --git a/rowers/opaque.py b/rowers/opaque.py
index 949572d9..0927df99 100644
--- a/rowers/opaque.py
+++ b/rowers/opaque.py
@@ -54,7 +54,7 @@ class OpaqueEncoder:
def decode_hex(self, s):
"""Decode an 8-character hex string, returning the original integer."""
- return self.transcode(int(s, 16))
+ return self.transcode(int(str(s), 16))
def decode_base64(self, s):
"""Decode a 6-character base64 string, returning the original integer."""
diff --git a/rowers/tasks.py b/rowers/tasks.py
index 065ecca4..c6f8316c 100644
--- a/rowers/tasks.py
+++ b/rowers/tasks.py
@@ -756,6 +756,35 @@ def handle_updatedps(useremail, workoutids, debug=False,**kwargs):
return 1
+@app.task
+def handle_send_email_alert(
+ useremail, userfirstname, userlastname, stats, **kwargs):
+
+ if 'debug' in kwargs:
+ debug = kwargs['debug']
+ else:
+ debug = False
+
+ subject = "Your rowing performance on rowsandall.com ({startdate} to {enddate})".format(
+ startdate = stats['startdate'],
+ enddate = stats['enddate'],
+ )
+
+ from_email = 'Rowsandall
+ Alerts are useful to give you a regular update on how you are doing. For example, if you are
+ worried about rowing too short, you can set an alert on drive length, and the site will automatically
+ tell you how well you are doing.
+
+ To set an alert on a minimum drive length, you would select "Drive Length (degree)" as the metric in the
+ form below, then set the condition to ">" (greater than), and value 1 to the minimum drive length
+ that you find acceptable. The value 2 is only relevant for alerts where you want to have a metric
+ between two values. Set the workout type to "Standard Racing Shell", or whatever boat class you
+ want this metric to run for, select the period over which you want to monitor and get regular
+ reports (7 days).
+
+ Optionally, you can add filters. With filters, the alert considers only those strokes that
+ fulfill all filters. For example, you could set a filter on power between 200 and 300 Watt,
+ to only look at drive length in that power zone.
+Alert Create
+
+
This will permanently delete the alert
+ ++
+ ++ Alerts are useful to give you a regular update on how you are doing. For example, if you are + worried about rowing too short, you can set an alert on drive length, and the site will automatically + tell you how well you are doing. +
+ ++ To set an alert on a minimum drive length, you would select "Drive Length (degree)" as the metric in the + form below, then set the condition to ">" (greater than), and value 1 to the minimum drive length + that you find acceptable. The value 2 is only relevant for alerts where you want to have a metric + between two values. Set the workout type to "Standard Racing Shell", or whatever boat class you + want this metric to run for, select the period over which you want to monitor and get regular + reports (7 days). +
+ ++ Optionally, you can add filters. With filters, the alert considers only those strokes that + fulfill all filters. For example, you could set a filter on power between 200 and 300 Watt, + to only look at drive length in that power zone. +
+ + + + + + + + + +{% endblock %} + +{% block sidebar %} +{% include 'menu_analytics.html' %} +{% endblock %} diff --git a/rowers/templates/alert_stats.html b/rowers/templates/alert_stats.html new file mode 100644 index 00000000..713716f9 --- /dev/null +++ b/rowers/templates/alert_stats.html @@ -0,0 +1,35 @@ +{% extends "newbase.html" %} +{% load staticfiles %} + +{% block title %}Metric Alert{% endblock %} + +{% block main %} + ++ Previous + {% if nperiod > 0 %} + Next + {% endif %} +
+ +{{ alert }}
+This is a page under construction. Currently with minimal information
+{{ value }}
+Dear {{ first_name }},
+ ++ Here is the report for your alert on rowsandall.com. +
+ +{% for key, value in report.items() %} ++ {{ key }}: {{ value }} +
+{% endfor %} + ++ Best Regards, the Rowsandall Team +
+{% endblock %} + diff --git a/rowers/templates/alerts.html b/rowers/templates/alerts.html new file mode 100644 index 00000000..0ea4db1c --- /dev/null +++ b/rowers/templates/alerts.html @@ -0,0 +1,73 @@ +{% extends "newbase.html" %} +{% load staticfiles %} +{% load rowerfilters %} + +{% block title %}Rowsandall - Analysis {% endblock %} + +{% block main %} + +Set up automatic alerting for your workouts
+ + +| Name | +metric | +Workout type | +Next Run | +|||
|---|---|---|---|---|---|---|
| {{ alert.name }} | +{{ alert.measured.metric }} | +{{ alert.workouttype }} | +{{ alert.next_run }} | ++ + + + | ++ + + + | ++ + + + | +
You have not set any alerts for {{ rower.user.first_name }}
++ Create new alert +
+Rower: {{ rower.user.first_name }}
-Be adventurous and try our new Analysis page ++ Try out Alerts +
{% endblock %} diff --git a/rowers/tests/testdata/testdata.csv.gz b/rowers/tests/testdata/testdata.csv.gz index 9676956f..5625c2c1 100644 Binary files a/rowers/tests/testdata/testdata.csv.gz and b/rowers/tests/testdata/testdata.csv.gz differ diff --git a/rowers/urls.py b/rowers/urls.py index 02d7b112..6e0c64ec 100644 --- a/rowers/urls.py +++ b/rowers/urls.py @@ -417,6 +417,15 @@ urlpatterns = [ name='multi_compare_view'), re_path(r'^multi-compare/workout/(?P