class Reminder(models.Model): """Describes a reminder (c.f. ReminderInstance, ScriptReminder)""" name = models.CharField(max_length=50) kind = models.CharField(max_length=100, choices=((i, i) for i in ["sms", "email"]), default="email") from_address = models.EmailField(blank=True, help_text=safe_help(""" The email address you want the reminder to appear as if it has come from. Think carefully before making this something other than your own email, and be sure the smpt server you are using will accept this as a from address (if you're not sure, check). If blank this will be the study email. For SMS reminders the callerid will be the SMS return number specified for the Study.""")) subject = models.CharField(max_length=1024, blank=True, verbose_name="Reminder email subject", help_text="""For reminder emails only""") message = models.TextField(blank=True, help_text="""The body of the message. See the documentation for message_body fields on the Script model for more details. You can include some variable with {{}} syntax, for example {{url}} will include a link back to the observation.""", verbose_name="Reminder message") def admin_edit_url(self): return admin_edit_url(self) class Meta: app_label = 'signalbox' def __unicode__(self): return self.name
class Question(models.Model): """Question objects; e.g. mutliple choice, text, etc.""" def check_if_protected(self): if not self.modifiable(): raise DataProtectionException("""This question already has answers attached to it and can't be modified.""") def modifiable(self): nonpreviewanswercount = self.answer_set.exclude( reply__entry_method="preview").count() # if we have non-preview answers, don't allow the question to be edited return not nonpreviewanswercount def delete(self, *args, **kwargs): # we delete preview answers to this questions to avoid ProtectedErrors # when deleting questions through the admin or in the yaml interface self.answer_set.filter(reply__entry_method="preview").delete() self.check_if_protected() super(Question, self).delete(*args, **kwargs) def save(self, *args, **kwargs): super(Question, self).save(*args, **kwargs) def index(self): return self.page.asker.questions().index(self) def dict_for_dataframe(self): out = {"variable_name": self.variable_name, "text": self.text} if self.choiceset: dl = list(map(dict, [v for k, v in self.choiceset.yaml.items()])) out.update({ "labels": [i['label'] for i in dl], "scores": [i['score'] for i in dl] }) return out def dict_for_yaml(self): d = { "text": self.text, "q_type": self.q_type, "choiceset": supergetattr(self, "choiceset.name", None), "required": self.required, } return {self.variable_name: {k: v for k, v in list(d.items()) if v}} page = models.ForeignKey('ask.AskPage', null=True, blank=True) scoresheet = models.ForeignKey('signalbox.ScoreSheet', blank=True, null=True) objects = QuestionManager() def natural_key(self): return (self.variable_name, ) natural_key.dependencies = ['ask.choiceset', 'ask.AskPage'] order = models.IntegerField( default=-1, verbose_name="Page order", help_text="""The order in which items will apear in the page.""") allow_not_applicable = models.BooleanField(default=False) required = models.BooleanField(default=False) @contract def show_conditional(self, mapping_of_answers): """ Use pyparsing to match an 'if' keyval on questions. :type mapping_of_answers: dict :rtype: bool """ try: condition = self.extra_attrs.get('if', None) except AttributeError: condition = None show = parse_conditional(condition, mapping_of_answers) return show text = models.TextField(blank=True, null=True, help_text=safe_help( settings.QUESTION_TEXT_HELP_TEXT)) variable_name = models.SlugField( default="", max_length=32, unique=True, validators=[ valid.first_char_is_alpha, valid.illegal_characters, valid.is_lower ], help_text="""Variable names can use characters a-Z, 0-9 and underscore (_), and must be unique within the system.""") choiceset = models.ForeignKey('ask.ChoiceSet', null=True, blank=True) help_text = models.TextField(blank=True, null=True) javascript = models.TextField(blank=True, null=True) @contract def show_as_image_data_url(self): """Say whether the answer is a stored data url for an image. :rtype: bool """ if self.q_type == "webcam": return True return False @contract def display_text(self, reply=None, request=None): """ :type reply: is_reply|None :type request: c|None :return: The html formatted string displaying the question :rtype: string """ templ_header = r"""{% load humanize %}{% load mathfilters %}""" # include these templatetags # in render context templ = Template(templ_header + self.text) context = { 'reply': reply, 'condition': supergetattr(reply, "observation.dyad.condition"), 'user': supergetattr(reply, 'observation.dyad.user', default=None), 'page': self.page, 'scores': {}, 'answers': defaultdict(None), 'answers_label': {} } fc = self.field_class() if reply and reply.asker and fc.compute_scores: context['scores'] = self.page.summary_scores(reply) # put actual values into main namespace too context.update({ k: v.get('score', None) for k, v in list(context['scores'].items()) }) if fc.allow_showing_answers and reply: context['answers'] = { i.variable_name(): int_or_string(i.answer) for i in reply.answer_set.all() } for i in self.questionasset_set.all(): context[i.slug] = str(i) from django.template.base import VariableDoesNotExist try: return markdown.markdown(templ.render(Context(context))) except VariableDoesNotExist: return markdown.markdown(self.text) q_type = models.CharField(choices=[(i, i) for i in FIELD_NAMES], blank=False, max_length=100, default="instruction") def field_class(self): """Return the relevant form field Class""" return getattr(fields, fields.class_name(self.q_type)) def preview_html(self): """Return a form with a single question which can be used to preview questions -> PageForm""" from ask.forms import PageForm # yuck but circular imports request = http.HttpRequest() form = PageForm(None, None, page=None, questions=[self], reply=None, request=request) return form def label_variable(self): return self.field_class().label_variable(self) def set_format(self): return self.field_class().set_format(self) def label_choices(self): return self.field_class().label_choices(self) extra_attrs = YAMLField( blank=True, help_text="""A YAML representation of a python dictionary of attributes which, when deserialised, is passed to the form widget when the questionnaire is rendered. See django-floppyforms docs for options.""") def extra_attrs_as_yaml(self): return yaml.dump(self.extra_attrs, default_flow_style=False) def voice_function(self): """Returns the function to render instructions for external telephony API.""" return self.field_class().voice_function def choices_as_json(self): """Question -> String""" return self.choiceset and self.choiceset.values_as_json() or "" @contract def choices(self): """ :rtype: None|list(tuple) """ return (self.choiceset and self.choiceset.choice_tuples()) or None def response_possible(self): """:: Question -> Bool Indicates whether a user response is possible for this Question""" return self.field_class().response_possible @contract def check_telephone_keypad_answer(self, raw_response_as_string): """Check a user response to see if it is allowed by the question. :type raw_response_as_string: string :rtype: bool """ if not self.response_possible(): # if no response expected we don't care what they pressed return True if self.choiceset: try: return bool( int(raw_response_as_string) in self.choiceset.allowed_responses()) except ValueError: # can't case string to an int, so not allowed return False # If we have no choiceset specified then anything is allowed return True @contract def previous_answer(self, reply): """ Return the an earlier answer if one exists for this reply. :rtype: string|None """ from signalbox.models import Answer # yuck, but otherwise circular import hell if not self.response_possible() or not reply: return None try: return reply.answer_set.get(question=self, page=self.page).answer except Answer.DoesNotExist: return "" def __unicode__(self): return truncatelabel(self.variable_name, 26) MARKDOWN_FORMAT = """~~~{{{variable_name} {classes} {keyvals}}}\n{text}\n{details}\n~~~""" def as_markdown(self): iden = "#" + self.variable_name if self.extra_attrs: classes = self.extra_attrs.pop('classes', {}) classesstring = " ".join( [".{}".format(k) for k, v in list(classes.items()) if v]) else: classesstring = ".{}".format(self.q_type) keyvals = self.extra_attrs or {} keyvalsstring = " ".join( ["""{}="{}\"""".format(k, v) for k, v in list(keyvals.items())]) detailsstring = "" if self.choiceset: detailsstring = ">>>\n" + self.choiceset.as_markdown() elif self.scoresheet: detailsstring = ">>>\n" + self.scoresheet.as_markdown() return self.MARKDOWN_FORMAT.format( **{ 'variable_name': iden, 'classes': classesstring, 'keyvals': keyvalsstring, 'text': self.text, 'details': detailsstring }) def clean(self, *args, **kwargs): self.variable_name = self.variable_name.replace("-", "_") super(Question, self).clean(*args, **kwargs) def clean_fields(self, *args, **kwargs): """Do some extra validation related to form fields used for the question.""" super(Question, self).clean_fields(*args, **kwargs) if not self.q_type: return False fieldclass = self.field_class() errors = {} if self.order is -1: self.order = 1 + self.page.question_set.all().count() if self.q_type in "slider" and not self.widget_kwargs: errors['widget_kwargs'] = [ """No default settings for slider added (need min, max, value). E.g. {"value":50,"min":0,"max":100}""" ] if "range-slider" in self.q_type and not self.widget_kwargs: errors['widget_kwargs'] = [ """Default settings for slider added (need values [a,b], min, max). E.g. {"values":[40,60],"min":0,"max":100}. Optional extra attributes: {"units":"%" } """ ] if (not fieldclass.has_choices) and self.choiceset: errors['choiceset'] = [ "You don't need a choiceset for this type of question." ] if fieldclass.has_choices and (not self.choiceset): errors['choiceset'] = [ "You need a choiceset for this type of question." ] if self.required and (not fieldclass.response_possible): errors['required'] = [ "This type of question (%s) doesn't allow a response." % (self.q_type, ) ] if errors: raise ValidationError(errors) def admin_edit_url(self): return admin_edit_url(self) class Meta: app_label = 'ask' ordering = ['order']
def preview_of_datetimes(self): code = lambda x: "<code>" + str(x) + "</code>" return safe_help("<br>".join(map(code, list(self.datetimes()))))
NATURAL_DATE_SYNTAX_HELP = safe_help( u""" Each line in this field will be read as a date on which to make an observation when the script is executed. By default each date is created relative to the current date and time at midnight. For example, if a user signed up when this page loaded, these lines: <code>now</code>, <code>next week</code>, and <code>in 3 days at 5pm</code> would create the following dates: <div id="exampletimes">.</div> <input type=hidden id="examplesyntax" value="now \n next week \n in 3 days at 5pm"> <script type="text/javascript"> $.post("/admin/signalbox/preview/timings/", {'syntax': $('#examplesyntax').val() }, function(data) { $('#exampletimes').html(data);} ); </script> <div class="alert"> <i class="icon-warning-sign"></i> Note that specifying options here will override other advanced timing options specified in other panels.</div> <a class="btn timingsdetail" href="#" onclick="$('.timingsdetail').toggle();return false;">Show more examples</a> <div class="hide timingsdetail"> Other examples include: <pre> 3 days from now a day ago in 2 weeks in 3 days at 5pm now 10 minutes ago 10 minutes from now in 10 minutes in a minute in 30 seconds tomorrow at noon tomorrow at 6am today at 12:15 AM 2 days from today at 2pm a week from today a week from now 3 weeks ago next Sunday at noon Sunday noon next Sunday at 2pm </pre> HOWEVER - you must check the times have been interpreted properly before using the script. </div> """ )
class Script(models.Model): """Defines :class:`Observation`s to be made and a schedule to make them. Scripts are attached to :class:`StudyCondition`s and used when :class:`Observation's are being added for a :class:`Membership` to determine a schedule of measurements. Scripts specify repeating rules using the python `dateutil` library. dateutil syntax can either be used directly (in the `raw_date_syntax` field) or by completing the `repeat_by...` fields, which are combined to generate a repeating rule. Care (and some mental effort) needs to be taken when generating repeating rules that the schedule is as expected --- it's actually a complicated problem, and dateutil is very general and abstract in what it allows, which means most things are possible, but at the cost of complexity. In particular, fields like `repeat_bymonths` can interact with other options to create slightly counterintuitive results. When editing a Script in the django admin, a preview of dates and times to be generated is available. However you should consider the possibility that times between observations, or the latency for the first observation, may differ based on when the user is randomised. A simple example: if an observation is to be repeated by hour at 9am, a user signing up at 8am will recieve an observation right away, whereas one signing up at 10am will recieve their first only the next day. ..note:: It may often be a good idea to split observations across multiple scripts to avoid having to write very complicated repeating rules. """ name = models.CharField( max_length=200, unique=False, help_text="""This is the name participants will see""") reference = models.CharField( max_length=200, unique=True, null=True, help_text="""An internal reference. Not shown to participants.""") admin_display_name = lambda self: self.reference or self.name admin_display_name.short_description = "Reference/Name" admin_display_name.admin_order_field = 'reference' is_clinical_data = models.BooleanField(default=False, help_text=""" If checked, indicates only clinicians and superusers should have access to respones made to this script.""") breaks_blind = models.BooleanField( default=True, help_text="""If checked, indicates that observations created by this Script have the potential to break the blind. If so, we will exclude them from views which Assessors may access.""") show_in_tasklist = models.BooleanField( verbose_name="Show in user's list of tasks to complete", default=True, help_text="""Should :class:`Observation`s generated by this script appear in a user's list of tasks to complete.""") allow_display_of_results = models.BooleanField( default=False, help_text="""If checked, then replies to these observations may be visible to some users in the Admin area (e.g. for screening questionnaires). If unchecked then only superusers will be able to preview replies to observations made using this script.""") show_replies_on_dashboard = models.BooleanField( default=True, help_text="""If true, replies to this script are visible to the user on their personal dashboard.""") script_type = models.ForeignKey( 'signalbox.ScriptType', help_text="""IMPORTANT: This type attribute determines the interpretation of some fields below.""", blank=False, null=False) asker = models.ForeignKey( 'ask.Asker', blank=True, null=True, help_text="""Survey which the participant will complete for the Observations created by this script""", verbose_name="Attached questionnaire") external_asker_url = models.URLField( blank=True, null=True, verbose_name="Externally hosted questionnaire url", help_text="""The full url of an external questionnaire. Note that you can include {{variable}} syntax to identify the Reply or Observation from which the user has been redirected. For example including http://monkey.com/survey1?c={{reply.observation.dyad.user.username}} would pass the username of the study participant to the external system. In contrast including {{reply.observation.id}} would simply pass the anonymous Observation id number. Where a questionnaire will be completed more than once during the study, it iss recommended to include the {{reply.id}} or {{reply.token}} to allow for reconciling external data with internal data at a later date.""") def parse_external_asker_url(self, reply): return render_string_with_context(self.external_asker_url, {'reply': reply}) label = models.CharField( max_length=255, blank=True, default="{{script.name}}", help_text=safe_help( """This field allows individual observations to have a meaningful label when listed for participants. Either enter a simple text string, for example "Main questionnaire", or have the label created dynamically from information about this script object. For example, you can enter `{{i}}` to add the index in of a particular observation in a sequence of generated observations. If you enter `{{n}}` then this will be the position ('first', 'second' etc.). Advanced use: If you want to reference attributes of the Script or Membership objects these are passed in as extra context, so {{script.name}} includes the script name, and `{{membership.user.last_name}}` would include the user's surname. Mistakes when entering these variable names will not result in an error, but won't produce any output either. """)) script_subject = models.CharField( max_length=1024, blank=True, verbose_name="Email subject", help_text="""Subject line of an email if required. Can use django template syntax to include variables: {{var}}. Currently variables available are {{url}} (the link to the questionnaire), {{user}} and {{userprofile}} and {{observation}}.""") script_body = models.TextField( blank=True, help_text="""Used for Email or SMS body. Can use django template syntax to include variables: {{var}}. Currently variables available are {{url}} (the link to the questionnaire), {{user}} and {{userprofile}} and {{observation}}. Note that these are references to the django objects themselves, so you can use the dot notation to access other attributed. {{user.email}} or {{user.last_name}} for example, would print the user's email address or last name.""", verbose_name="Message") user_instructions = models.TextField( blank=True, null=True, help_text="""Instructions shown to user on their homepage and perhaps elsewhere as the link to the survey. For example, "Please fill in the questionnaire above. You will need to allow N minutes to do this in full." """) max_number_observations = models.IntegerField( default=1, help_text="""The # of observations this scipt will generate. Default is 1""", verbose_name="""create N observations""") RRULES = [ (i, i) for i in "YEARLY MONTHLY WEEKLY DAILY HOURLY MINUTELY SECONDLY".split( " ") ] repeat = models.CharField(max_length=80, choices=RRULES, default='DAILY') repeat_from = models.DateTimeField( blank=True, null=True, verbose_name="Fixed start date", help_text="""Leave blank for the observations to start relative to the datetime they are created (i.e. when the participant is randomised)""" ) repeat_interval = models.IntegerField( blank=True, null=True, help_text="""The interval between each freq iteration. For example, when repeating WEEKLY, an interval of 2 means once per fortnight, but with HOURLY, it means once every two hours.""", verbose_name="repeat interval") repeat_byhours = models.CharField( validators=[validate_comma_separated_integer_list, v.valid_hours_list], blank=True, null=True, max_length=20, help_text="""A number or list of integers, indicating the hours of the day at which observations are made. For example, '13,19' would make observations happen at 1pm and 7pm.""", verbose_name="repeat at these hours") def repeat_byhours_list(self): return [ int(i) for i in csv_to_list(self.repeat_byhours) if int(i) < 24 ] repeat_byminutes = models.CharField( validators=[validate_comma_separated_integer_list, v.in_minute_range], blank=True, null=True, max_length=20, help_text="""A list of numbers indicating at what minutes past the hour the observations should be created. E.g. 0,30 will create observations on the hour and half hour""", verbose_name="repeat at these minutes past the hour") def repeat_byminutes_list(self): return [int(i) for i in csv_to_list(self.repeat_byminutes)] repeat_bydays = models.CharField( blank=True, null=True, max_length=100, help_text="""One or more of MO, TU, WE, TH, FR, SA, SU separated by a comma, to indicate which days observations will be created on.""", verbose_name="repeat on these days of the week") def repeat_bydays_list(self): return [ WEEKDAY_MAP[i.strip()[0:2]] for i in csv_to_list(self.repeat_bydays) ] repeat_bymonths = models.CharField( validators=[validate_comma_separated_integer_list], blank=True, null=True, max_length=20, help_text="""A comma separated list of months as numbers (1-12), indicating the months in which obervations can be made. E.g. '1,6' would mean observations are only made in Jan and June.""", verbose_name="repeat in these months") def repeat_bymonths_list(self): return [int(i) for i in csv_to_list(self.repeat_bymonths)] repeat_bymonthdays = models.CharField( validators=[validate_comma_separated_integer_list], blank=True, null=True, max_length=20, help_text="""An integer or comma separated list of integers; represents days within a month on observations are created. For example, '1, 24' would create observations on the first and 24th of each month.""", verbose_name="on these days in the month") def repeat_bymonthdays_list(self): return [int(i) for i in csv_to_list(self.repeat_bymonthdays)] delay_by_minutes = models.IntegerField(default=0, help_text="""Start the observations this many minutes from the time the Observations are added.""" ) delay_by_hours = models.IntegerField(default=0, help_text="""Start the observations this many hours from the time the Observations are added.""" ) delay_by_days = models.IntegerField(default=0, help_text="""Start the observations this many days from the time the Observations are added.""" ) delay_by_weeks = models.IntegerField(default=0, null=True, help_text="""Start the observations this many weeks from the time the Observations are added.""" ) delay_in_whole_days_only = models.BooleanField( default=True, help_text="""If true, observations are delayed to the nearest number of whole days, and repeat rules will start from 00:00 on the morning of that day.""") def delay(self): """Calculate the interval until Observations start. Returns a dateime.TimeDelta object""" return timedelta(weeks=self.delay_by_weeks, days=self.delay_by_days, hours=self.delay_by_hours, minutes=self.delay_by_minutes) natural_date_syntax = models.TextField( blank=True, null=True, validators=[v.valid_natural_datetime], help_text=NATURAL_DATE_SYNTAX_HELP) def eval_natural_date_synax(self): timesanderrors = [ parse_natural_date(t) for t in self.natural_date_syntax.splitlines() ] times = [i for i, e in timesanderrors if not e] return times completion_window = models.IntegerField( blank=True, null=True, help_text="""Window in minutes during which the observation can be completed. If left blank, the observation will not expire.""") jitter = models.IntegerField( blank=True, null=True, help_text="""Number of minutes (plus or minus) to randomise observation timing by.""") reminders = models.ManyToManyField('signalbox.Reminder', through='signalbox.ScriptReminder') def calculate_start_datetime(self, start_date=None): """Return the date the observations should start on.""" start_date = start_date or datetime.now() # we pass in date_randomised sometimes, which is a date not a datetime # so here we convert it if needed if type(start_date) == dtmod.date: start_date = datetime.combine(start_date, time()) startfrom = start_date + self.delay() if self.delay_in_whole_days_only: return startfrom.replace(hour=0, minute=0, second=0, microsecond=0) return startfrom def datetimes_with_natural_syntax(self): datesyntax = self.natural_date_syntax or "ADVANCED" return [{ 'datetime': i, 'syntax': j } for i, j, k in list( it.izip_longest(self.datetimes(), datesyntax.split("\n"), ""))] def preview_of_datetimes(self): code = lambda x: "<code>" + str(x) + "</code>" return safe_help("<br>".join(map(code, list(self.datetimes())))) preview_of_datetimes.allow_tags = True def datetimes(self, start_date=None): if self.natural_date_syntax: return self.eval_natural_date_synax() else: kwargs = { 'count': self.max_number_observations, 'interval': self.repeat_interval, 'byhour': self.repeat_byhours_list(), 'byminute': self.repeat_byminutes_list(), 'byweekday': self.repeat_bydays_list(), 'bymonth': self.repeat_bymonths_list(), 'bymonthday': self.repeat_bymonthdays_list(), 'dtstart': self.calculate_start_datetime(start_date=start_date) } # filter out properties with null values kwargs = dict([(k, v) for k, v in list(kwargs.items()) if v]) # return an rrule iterator containing the dates return rrule(FREQ_MAP[self.repeat], **kwargs) def make_observations(self, membership): from signalbox.models import Observation times = self.datetimes(membership.date_randomised) times_indexes = list(zip(times, list(range(1, len(list(times)) + 1)))) observations = [] for time, index in times_indexes: observations.append( Observation(due_original=time, n_in_sequence=index, label=render_string_with_context( self.label, { 'i': index, 'n': ordinal(index), 'script': self, 'membership': membership }), dyad=membership, created_by_script=self)) [i.save() for i in observations] return observations class Meta: ordering = ['reference', 'name'] app_label = 'signalbox' def clean(self): if self.asker and self.external_asker_url: raise ValidationError("""You can't use both an internal Questionnaire and one hosted on an external site.""" ) if self.asker and supergetattr( self, "script_type.require_study_ivr_number", False): last_q = self.asker.questions()[-1] if last_q.q_type != "hangup": raise ValidationError( "The last question of a telephone call needs to be a 'hangup' type (currently {})" .format(last_q.q_type)) try: if len(self.script_body ) > 160 and self.script_type.name == "TwilioSMS": raise ValidationError( "Messages must be < 160 characters long.") except AttributeError: raise def __unicode__(self): return '(%s) %s' % (self.reference, self.name)
NATURAL_DATE_SYNTAX_HELP = safe_help(""" Each line in this field will be read as a date on which to make an observation when the script is executed. By default each date is created relative to the current date and time at midnight. For example, if a user signed up when this page loaded, these lines: <code>now</code>, <code>next week</code>, and <code>in 3 days at 5pm</code> would create the following dates: <div id="exampletimes">.</div> <input type=hidden id="examplesyntax" value="now \n next week \n in 3 days at 5pm"> <script type="text/javascript"> $.post("/admin/signalbox/preview/timings/", {'syntax': $('#examplesyntax').val() }, function(data) { $('#exampletimes').html(data);} ); </script> <div class="alert"> <i class="icon-warning-sign"></i> Note that specifying options here will override other advanced timing options specified in other panels.</div> <a class="btn timingsdetail" href="#" onclick="$('.timingsdetail').toggle();return false;">Show more examples</a> <div class="hide timingsdetail"> Other examples include: <pre> 3 days from now a day ago in 2 weeks in 3 days at 5pm now 10 minutes ago 10 minutes from now in 10 minutes in a minute in 30 seconds tomorrow at noon tomorrow at 6am today at 12:15 AM 2 days from today at 2pm a week from today a week from now 3 weeks ago next Sunday at noon Sunday noon next Sunday at 2pm </pre> HOWEVER - you must check the times have been interpreted properly before using the script. </div> """)