Interviews with multiple users
docassemble allows you to create multi-user interviews.
For example:
- In a legal application, a client uses the interview to answer questions about the facts of a case, and then an attorney reviews the responses, answers questions about how the law applies to the facts, and then the client comes back to the interview to receive a legal advice letter.
- If a user’s language is not English, a translator will be invited to join the interview to translate the user’s textual responses into English before the interview process is completed.
- Two parties who are negotiating with one another will fill in answers to an interview, and when both are done, the interview will suggest a resolution based on the answers of both parties.
Multi-user interviews are implemented through the user login feature and three features that were introduced in other sections:
role
modifier: designates certain questions as being answerable only by a particular user (or group of users).default role
initial block: sets therole
modifier for questions that do not have arole
explicitly set. Thecode
within this block is run asinitial
code. The responsibility of thiscode
is to set the special variablerole
depending on the circumstances, which can include the identity and privileges of the person logged in, which can be retrieved with the functionsuser_logged_in()
,user_info()
,user_privileges()
, anduser_has_privilege()
.event
variables: when the current user cannot proceed further with the interview because the interview needs input from a different user, docassemble will display a message for the current user. It finds this message by looking for a question marked withevent: role_event
. Mako template commands can be used in this question to say different things depending on who the current user is and who the new user needs to be.
The following example of a three-role interview demonstrates how multi-user interviews are created in docassemble.
The interview starts with an organizer, who clicks past an introductory screen and is asked for the e-mail addresses of two participants who will bid on a contract. The organizer is then asked to invite the participants to bid by clicking on a particular link and logging in. Once both bidders have entered their bids, the winner (the participant with the lowest bid) is announced. Until both participants have entered bids, users will see a page telling them to wait and to press the “Check” button to see if both bids have been made yet (except for bidders who haven’t bid yet, who will be asked for their bids).
default role: organizer
code: |
multi_user = True
role = 'organizer'
if introduction_made and participants_invited:
if user_logged_in():
if user_info().email == first_person_email:
role = 'first_person'
elif user_info().email == second_person_email:
role = 'second_person'
---
event: role_event
question: Waiting on another participant
subquestion: |
% if 'first_person' in role_needed or 'second_person' in role_needed:
We are waiting for a participant to put in a bid.
% else:
We are waiting for the organizer.
% endif
% if not user_logged_in():
If you were invited to participate, please click "Sign in."
% endif
Press **Check** to check on the status.
buttons:
- Check: refresh
---
mandatory: True
code: |
if role == 'first_person':
first_person_bid
if role == 'second_person':
second_person_bid
announce_winner
---
field: introduction_made
question: |
Welcome to a test of the multi-user system!
subquestion:
This is the start of a test of the multi-user system.
You are the organizer. To continue, press **Continue**.
---
code: |
if first_person_email == second_person_email:
organizer_messed_up
else:
recipients_ok = True
---
field: participants_invited
need: recipients_ok
question: |
Invite the bidders
subquestion: |
Ok, organizer, send e-mails to ${ first_person_email } and
${ second_person_email } inviting them to submit bids.
Tell them to go to the following URL:
[${ interview_url() }](${ interview_url() })
---
sets: announce_winner
role:
- organizer
- first_person
- second_person
question: And the winner is...
subquestion: |
% if first_person_bid < second_person_bid:
The winning bidder is First Person!
% elif first_person_bid > second_person_bid:
The winning bidder is Second Person!
% else:
Sorry, no winner. The bids matched.
% endif
---
question: |
What are the e-mail addresses of the participating bidders?
fields:
- First Person: first_person_email
datatype: email
- Second Person: second_person_email
datatype: email
---
role: first_person
question: |
How much will you bid for the contract?
fields:
- Amount: first_person_bid
datatype: currency
---
role: second_person
question: |
How much will you bid for the contract?
fields:
- Amount: second_person_bid
datatype: currency
---
sets: organizer_messed_up
question: |
That won't work.
subquestion: |
Please try again. This time enter different e-mail addresses
for the participants.
buttons:
- Restart: restart
...
Most of the code is self-explanatory, but a few of the blocks warrant explanation.
Consider the second block:
---
default role: organizer
code: |
multi_user = True
role = 'organizer'
if introduction_made and participants_invited:
if user_logged_in():
if user_info().email == first_person_email:
role = 'first_person'
elif user_info().email == second_person_email:
role = 'second_person'
---
Here, we set the default role
to organizer
. This simply means that
questions in the interview that do not have a role
specified will
require the organizer
role.
The code
is initial
code, the primary purpose of which is to set
the role
variable, which is the role of whichever user is currently
in the interview. This code runs every single time the page loads for
any user.
This code block also sets the special variable
multi_user
to True
, which tells docassemble that multiple
users will be using this interview. When multi_user
is True
,
docassemble will not encrypt the answers on the server. This
reduces security somewhat, but is necessary in order for multiple
users to participate in the same interview.
First, note that the role
for all users will remain organizer
until the organizer has seen the introductory page and the
participants have been invited.
Second, note that the flow of the interview is being controlled here.
The references to introduction_made
and participants invited
mean
that questions will be asked to define those variables if they are
undefined. This is the code that causes those questions to appear for
the organizer. After the role is set, the mandatory
code
block controls the flow of the interview.
Third, note that by requiring participants invited
to be set before
we consider whether the user’s e-mail address is equal to
first_person_email
, we ensure that the interview flow proceeds the
way we want it to. Suppose we did not check for participants
invited
before doing this. If the organizer was logged in and gave
his own e-mail address as that of a participant, he would assume the
role of a participant before learning the URL that he needs to give to
the other participant.
There are times when you just want docassemble to figure out the
interview flow implicitly, and there are times when you want to be
explicit about it. Setting up roles is one situation where you want
to be pretty explicit, so that users do not see role_event
screens
unnecessarily.
Another block that warrants explanation is the mandatory
code
block:
---
mandatory: True
code: |
if role == 'first_person':
first_person_bid
if role == 'second_person':
second_person_bid
announce_winner
---
This code block sets a goal for the interview: finding the value of
announce_winner
. Note that if we took out the two if
statements,
the interview could still get done, because announce_winner
implicitly asks for both firth_person_bid
and second_person_bid
.
The problem with that (or the inconvenience) is that announce_winner
looks for the value of first_person_bid
before it looks for the
value of second_person_bid
. This means that the second participant
would have to wait to enter his bid until the first participant had
done so. This would be arbitrary and could cause unnecessary delay.
The additional code here will ask each participant for his bid
regardless of which one is first to log in.
The URL that is created through interview_url()
contains the secret
“session key” of the interview, which was created when the organizer
started the interview. When someone goes to this URL, they will enter
the interview that is already in progress, whatever the current state
of the interview is. Note that the URL in the location bar of the web
browser will be shortened after the first page load, but the
interview-specific information in the URL will not be forgotten
(essentially, it is moved from the location bar into a cookie in the
web browser).
Interviews with an unknown number of users
In the example above, there were two participants in the interview
(other than the organizer): first_person
and second_person
. But
what if you will have more than two participants? Do you have to
create role
s up to ten_thousandth_person
? No – there are other
ways to handle multi-user interviews.
Consider the following example, which uses generic objects:
objects:
- respondents: DADict.using(gathered=True)
---
initial: True
code: |
multi_user = True
if user_logged_in():
if user_info().email not in respondents:
respondents.initializeObject(user_info().email)
user = respondents[user_info().email]
final_page
else:
must_be_logged_in_page
---
sets: final_page
question: |
<%
cat_count = 0
for email in respondents:
respondent = respondents[email]
if respondent is user or hasattr(respondent, 'number_of_cats'):
cat_count += respondent.number_of_cats
%>
% if cat_count == 0:
There are zero cats so far.
% elif cat_count == 1:
There is only one cat so far.
% else:
There are ${ cat_count } cats in all.
% endif
subquestion: |
Share this link with others!
[${ interview_url() }](${ interview_url() })
buttons:
- Check: refresh
---
generic object: DAObject
question: How many cats do you have?
fields:
- Number of cats: x.number_of_cats
datatype: integer
---
sets: must_be_logged_in_page
question: Please log in
subquestion: |
Please click "Sign in" to continue. If you do not have
an account, you can register for one.
buttons:
- Sign in: signin
...
This interview asks every user how many cats he or she has, and then tells the user the total number of cats of all of the interview respondents.
Note that this interview allows multiple users but does not make use
of the role
and default role
features. The purpose of those
features is to put one user on hold while waiting for input from
another user. There are some situations, like the above interview,
that do not require one user to wait for another user. In that case,
it is not necessary to set user roles.
The initial
code block makes sure that the user is logged in, and
sets the user
variable to a DAObject
.
A DAObject
is the most basic type of docassemble object. The
fact that the user
is a DAObject
means that the user
can have
attributes and those attributes can be gathered with generic
object questions.
The initial
code block also keeps track of all the users that have
used the interview (i.e. by either starting the interview from scratch
or clicking on the link given by interview_url
) so that the Mako
code in the final_page
question can loop over all of the users and
tally up the number of cats.
These lines in the initial
block define the user
variable and keep
track of each user:
if user_info().email not in respondents:
respondents.initializeObject(user_info().email)
user = respondents[user_info().email]
user_info()
is a special function that returns information about
the logged-in user. If the user was not logged in, the email
would
not be known. The email
is a unique identifier for each user in the
login system.
The objects
block defines respondents
as an object of type DADict
.
A DADict
acts much like an ordinary Python dictionary, except that it
has special properties that allow docassemble to set its
attributes using generic object
questions.
The second line in the excerpt above defines an entry in this
Python dictionary, respondents[user_info().email]
as a new object
of type DAObject. The initializeObject()
method effectively does
this:
respondents[user_info().email] = DAObject()
However, it does not work to actually write that; the initializeObject()
method takes care of the docassemble internals that allow
docassemble to set undefined attributes.
Note that the modules
block is necessary in this interview;
otherwise we could not refer to names like DAObject
and DADict
.
The Mako code contains this line:
if respondent is user or hasattr(respondent, 'number_of_cats'):
cat_count += respondent.number_of_cats
The hasattr
function is a built-in Python function that returns
True
if respondent
has an attribute called number_of_cats
, and
False
if not. We are saying here that we want to tally the total
number of cats for the user and all respondents who have answered the
question about the number of cats they have. If we did not exclude
respondents who had not yet indicated their number of cats, then the
user might be asked for the number of cats belonging to a different
respondent. It would be rare that this would ever happen – it would
happen if the user checked the number of cats between the time another
respondent logged in and the time the respondent entered his number of
his cats – but it is important to anticipate and control for rare
cases.
A two-user interview that does not use the roles system
You do not need need to use the role-switching system to have a multi-user interview. It helps, though, to use the user login system to keep track of who the user is. Here is an example of an interview where two users each sign a document:
objects:
firstparty: Individual
secondparty: Individual
---
initial: true
code: |
multi_user = True
if user_logged_in():
if not defined('firstparty.email'):
firstparty.email = user_info().email
if user_info().email == firstparty.email:
firstparty.endpoint
elif user_info().email == secondparty.email:
secondparty.endpoint
else:
user_kicked_out
else:
user_must_log_in
---
event: firstparty.endpoint
code: |
firstparty.name.first
secondparty.name.first
firstparty.thing
firstparty.signature
if not defined('secondparty.signature'):
waiting_on_second_party
final_screen
---
event: secondparty.endpoint
code: |
secondparty.consents
secondparty.signature
final_screen
---
question: |
What is your name?
fields:
- First Name: firstparty.name.first
- Last Name: firstparty.name.last
---
question: |
What is the name and e-mail address
of the other party to the contract?
fields:
- First Name: secondparty.name.first
- Last Name: secondparty.name.last
- E-mail: secondparty.email
datatype: email
---
question: |
If you agree to convey
${ secondparty.thing }
to ${ firstparty },
press Continue.
field: secondparty.consents
---
generic object: Individual
question: |
What is your least valuable possession?
fields:
- no label: x.thing
---
generic object: Individual
question: |
Please sign your name below.
signature: x.signature
under: |
${ x.name }
---
event: user_must_log_in
question: |
In order to use this interview,
you need to log in.
subquestion: |
If you have not yet created an
account, press register.
buttons:
Register: register
Log in: signin
prevent going back: True
---
event: user_kicked_out
question: |
You are not authorized.
subquestion: |
I'm not sure how you got here,
but you do not belong. Scram!
---
event: waiting_on_second_party
question: |
Done for now.
subquestion: |
Please ask ${ secondparty } to go to
[this link](${ interview_url() })
and register with the e-mail
address ${ secondparty.email }.
After ${ secondparty } has done this,
you can come back to this interview
(using the
[same link](${ interview_url() }))
and retrieve the signed agreement.
---
event: final_screen
question: |
Here is the signed contract.
attachment:
name: |
An agreement between
${ firstparty } and
${ secondparty }
filename: agreement
docx template file: Agreement_To_Transfer.docx