This commit is contained in:
Ross
2021-12-11 22:21:12 +00:00
parent 98ff9f2d39
commit 3d9c019bdc
9 changed files with 221 additions and 110 deletions
@@ -0,0 +1,19 @@
# Generated by Django 3.2.8 on 2021-12-11 22:12
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('generic', '0015_ciduser_ciduserexam'),
('anatomy', '0053_anatomyquestion_structure'),
]
operations = [
migrations.AddField(
model_name='exam',
name='valid_users',
field=models.ManyToManyField(blank=True, related_name='anatomy_exams', to='generic.CidUser'),
),
]
+3 -1
View File
@@ -15,7 +15,7 @@ from sortedm2m.fields import SortedManyToManyField
import string import string
from generic.models import Examination, ExamBase, QuestionNote from generic.models import CidUser, Examination, ExamBase, QuestionNote
from collections import defaultdict from collections import defaultdict
from helpers.images import image_as_base64 from helpers.images import image_as_base64
@@ -354,6 +354,8 @@ class Exam(ExamBase):
related_name="anatomy_exam_author", related_name="anatomy_exam_author",
) )
valid_users = models.ManyToManyField(CidUser, blank=True, related_name="anatomy_exams")
def get_exam_json(self, based=True): def get_exam_json(self, based=True):
questions = self.exam_questions.all() questions = self.exam_questions.all()
+1
View File
@@ -26,4 +26,5 @@ urlpatterns = [
), ),
name="examination-autocomplete", name="examination-autocomplete",
), ),
path("cids/manage/", views.create_examination, name="create_examination"),
] ]
+19
View File
@@ -0,0 +1,19 @@
# Generated by Django 3.2.8 on 2021-12-11 22:12
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('generic', '0015_ciduser_ciduserexam'),
('longs', '0054_alter_exam_open_access'),
]
operations = [
migrations.AddField(
model_name='exam',
name='valid_users',
field=models.ManyToManyField(blank=True, related_name='longs_exams', to='generic.CidUser'),
),
]
+3 -1
View File
@@ -28,7 +28,7 @@ from helpers.images import image_as_base64, pretty_print_dicom
from anatomy.models import Modality from anatomy.models import Modality
from generic.models import Examination, Condition, Sign, ExamBase, Plane, Contrast, QuestionNote from generic.models import CidUser, Examination, Condition, Sign, ExamBase, Plane, Contrast, QuestionNote
# from generic.models import Examination, Site, Condition, Sign # from generic.models import Examination, Site, Condition, Sign
@@ -597,6 +597,8 @@ class Exam(ExamBase):
double_mark = models.BooleanField(default=True, help_text="Defines if an exam is expected to be double marked") double_mark = models.BooleanField(default=True, help_text="Defines if an exam is expected to be double marked")
valid_users = models.ManyToManyField(CidUser, blank=True, related_name="longs_exams")
def get_exam_question_json(self, question_id): def get_exam_question_json(self, question_id):
q = get_object_or_404(Long, pk=question_id) q = get_object_or_404(Long, pk=question_id)
@@ -0,0 +1,19 @@
# Generated by Django 3.2.8 on 2021-12-11 22:12
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('generic', '0015_ciduser_ciduserexam'),
('rapids', '0038_alter_rapidimage_image_md5_hash'),
]
operations = [
migrations.AddField(
model_name='exam',
name='valid_users',
field=models.ManyToManyField(blank=True, related_name='rapids_exams', to='generic.CidUser'),
),
]
+136 -107
View File
@@ -24,7 +24,7 @@ from helpers.images import image_as_base64
from django.contrib.contenttypes.fields import GenericRelation from django.contrib.contenttypes.fields import GenericRelation
from generic.models import Site, Condition, Sign, ExamBase, QuestionNote from generic.models import CidUser, Site, Condition, Sign, ExamBase, QuestionNote
import reversion import reversion
@@ -37,31 +37,33 @@ import pydicom.errors
image_storage = FileSystemStorage( image_storage = FileSystemStorage(
# Physical file location ROOT # Physical file location ROOT
location=u"{0}".format(settings.MEDIA_ROOT), location="{0}".format(settings.MEDIA_ROOT),
# Url for file # Url for file
base_url=u"{0}".format(settings.MEDIA_URL), base_url="{0}".format(settings.MEDIA_URL),
) )
def image_directory_path(instance, filename): def image_directory_path(instance, filename):
#return u"{0}".format(filename) # return u"{0}".format(filename)
return u"rapids/picture/{0}".format(filename) return "rapids/picture/{0}".format(filename)
def get_answer_compare(s): def get_answer_compare(s):
s = s.strip().lower() s = s.strip().lower()
s = s.translate(str.maketrans('', '', string.punctuation.replace("#", ""))) s = s.translate(str.maketrans("", "", string.punctuation.replace("#", "")))
return s return s
class Abnormality(models.Model): class Abnormality(models.Model):
name = models.CharField(max_length=200, name = models.CharField(
unique=True, max_length=200, unique=True, help_text="Primary abnormality on the film(s)"
help_text="Primary abnormality on the film(s)") )
def __str__(self): def __str__(self):
return self.name return self.name
class Meta: class Meta:
ordering = ('name', ) ordering = ("name",)
class Region(models.Model): class Region(models.Model):
@@ -69,13 +71,14 @@ class Region(models.Model):
max_length=200, max_length=200,
unique=True, unique=True,
help_text="Region of the abnormality (not including lateralitiy)", help_text="Region of the abnormality (not including lateralitiy)",
blank=True) blank=True,
)
def __str__(self): def __str__(self):
return self.name return self.name
class Meta: class Meta:
ordering = ('name', ) ordering = ("name",)
class Examination(models.Model): class Examination(models.Model):
@@ -85,7 +88,8 @@ class Examination(models.Model):
return self.examination return self.examination
class Meta: class Meta:
ordering = ('examination', ) ordering = ("examination",)
class Answer(models.Model): class Answer(models.Model):
question = models.ForeignKey( question = models.ForeignKey(
@@ -119,8 +123,8 @@ class Answer(models.Model):
class Rapid(models.Model): class Rapid(models.Model):
#author = models.ForeignKey('auth.User', on_delete=models.CASCADE) # author = models.ForeignKey('auth.User', on_delete=models.CASCADE)
#image = models.ImageField() # image = models.ImageField()
question = models.TextField(null=True, blank=True) question = models.TextField(null=True, blank=True)
feedback = models.TextField(null=True, blank=True) feedback = models.TextField(null=True, blank=True)
@@ -146,25 +150,28 @@ class Rapid(models.Model):
region = models.ManyToManyField( region = models.ManyToManyField(
Region, Region,
blank=True, blank=True,
help_text="Region of the abnormality (laterality independent)") help_text="Region of the abnormality (laterality independent)",
)
examination = models.ManyToManyField( examination = models.ManyToManyField(
Examination, help_text="Name of the (primary) examination") Examination, help_text="Name of the (primary) examination"
)
laterality = models.CharField( laterality = models.CharField(
max_length=20, max_length=20,
choices=LATERALITY_CHOICES, choices=LATERALITY_CHOICES,
default=NONE, default=NONE,
help_text="Applies to the answer, not the examination") help_text="Applies to the answer, not the examination",
#condition = tagulous.models.TagField( )
# condition = tagulous.models.TagField(
# to=Condition, # to=Condition,
# blank=True, # blank=True,
# help_text= # help_text=
# "Associated condition. Will allow searching / filtering and tips / hints to be displayed. Conditions with spaces must be enclosed in quotes \"...\"" # "Associated condition. Will allow searching / filtering and tips / hints to be displayed. Conditions with spaces must be enclosed in quotes \"...\""
#) # )
#sign = tagulous.models.TagField( # sign = tagulous.models.TagField(
# to=Sign, blank=True, help_text='Radiological signs in the question') # to=Sign, blank=True, help_text='Radiological signs in the question')
DEFAULT_SITE_ID = 1 DEFAULT_SITE_ID = 1
#site = models.ManyToManyField( # site = models.ManyToManyField(
# Site, # Site,
# related_name="site_rapid", # related_name="site_rapid",
# blank=True, # blank=True,
@@ -174,14 +181,16 @@ class Rapid(models.Model):
verified = models.BooleanField(default=False) verified = models.BooleanField(default=False)
created_date = models.DateTimeField(default=timezone.now) created_date = models.DateTimeField(default=timezone.now)
published_date = models.DateTimeField(blank=True, null=True) published_date = models.DateTimeField(blank=True, null=True)
author = models.ManyToManyField(settings.AUTH_USER_MODEL, author = models.ManyToManyField(
blank=True, settings.AUTH_USER_MODEL,
help_text='Author of question', blank=True,
related_name="rapid_authored_questions") help_text="Author of question",
related_name="rapid_authored_questions",
)
scrapped = models.BooleanField( scrapped = models.BooleanField(
default=False, default=False, help_text="Question has been scrapped and will not be shown"
help_text='Question has been scrapped and will not be shown') )
open_access = models.BooleanField( open_access = models.BooleanField(
help_text="If a question should be freely available to browse", default=True help_text="If a question should be freely available to browse", default=True
@@ -189,9 +198,8 @@ class Rapid(models.Model):
notes = GenericRelation(QuestionNote) notes = GenericRelation(QuestionNote)
def get_absolute_url(self): def get_absolute_url(self):
return reverse('rapids:question_detail', kwargs={'pk': self.pk}) return reverse("rapids:question_detail", kwargs={"pk": self.pk})
def get_authors(self): def get_authors(self):
"""Returns a comma seperated text list of authors""" """Returns a comma seperated text list of authors"""
@@ -216,8 +224,10 @@ class Rapid(models.Model):
def __str__(self): def __str__(self):
n = "Normal" n = "Normal"
if not self.normal: if not self.normal:
#n = self.answers.first() # n = self.answers.first()
n = "{}/{} : {}".format(self.pk, self.abnormality.first(), self.region.first()) n = "{}/{} : {}".format(
self.pk, self.abnormality.first(), self.region.first()
)
exams = self.get_examinations() exams = self.get_examinations()
lat = "" lat = ""
if self.laterality != "NONE": if self.laterality != "NONE":
@@ -227,8 +237,17 @@ class Rapid(models.Model):
def get_primary_answer(self): def get_primary_answer(self):
if self.normal: if self.normal:
return "Normal" return "Normal"
elif self.answers.filter(proposed=False, status=Answer.MarkOptions.CORRECT).count() > 0: elif (
return self.answers.filter(proposed=False, status=Answer.MarkOptions.CORRECT).first().answer self.answers.filter(
proposed=False, status=Answer.MarkOptions.CORRECT
).count()
> 0
):
return (
self.answers.filter(proposed=False, status=Answer.MarkOptions.CORRECT)
.first()
.answer
)
else: else:
return "None yet..." return "None yet..."
@@ -261,11 +280,15 @@ class Rapid(models.Model):
marked_answers = self.get_marked_answers() marked_answers = self.get_marked_answers()
unmarked_answers = set( unmarked_answers = set(
[i for i in user_answer_queryset if i.normal == False and i.answer_compare not in marked_answers] [
i
for i in user_answer_queryset
if i.normal == False and i.answer_compare not in marked_answers
]
) )
return [i.answer_compare for i in unmarked_answers] return [i.answer_compare for i in unmarked_answers]
# Marker code below is not used # Marker code below is not used
if marker is None: if marker is None:
return [i.answer_compare for i in unmarked_answers] return [i.answer_compare for i in unmarked_answers]
@@ -275,7 +298,7 @@ class Rapid(models.Model):
for answer in unmarked_answers: for answer in unmarked_answers:
if answer.mark.filter(marker=marker).count() < 1: if answer.mark.filter(marker=marker).count() < 1:
marker_unmarked.append(answer.answer_compare) marker_unmarked.append(answer.answer_compare)
return marker_unmarked return marker_unmarked
def get_unmarked_user_answer_count(self, exam_pk=None, marker=None): def get_unmarked_user_answer_count(self, exam_pk=None, marker=None):
@@ -293,19 +316,12 @@ class Rapid(models.Model):
if not include_normal: if not include_normal:
queryset = queryset.filter(normal=False) queryset = queryset.filter(normal=False)
user_answers = set( user_answers = set([i.answer_compare for i in queryset])
[i.answer_compare for i in queryset]
)
return user_answers return user_answers
def get_compare_answers(self): def get_compare_answers(self):
return set( return set([i.answer_compare for i in self.answers.filter()])
[
i.answer_compare
for i in self.answers.filter()
]
)
def get_marked_answers(self): def get_marked_answers(self):
return set( return set(
@@ -352,11 +368,14 @@ class Rapid(models.Model):
def get_image_url_array(self): def get_image_url_array(self):
return json.dumps([f"{REMOTE_URL}{i.url}" for i in self.get_images()]) return json.dumps([f"{REMOTE_URL}{i.url}" for i in self.get_images()])
#def GetNonFeedbackQuestionImages(self):
#return self.get_images() # def GetNonFeedbackQuestionImages(self):
# return self.get_images()
def get_image_annotations(self): def get_image_annotations(self):
return json.dumps([i.image_annotations for i in self.images.all() if not i.feedback_image]) return json.dumps(
[i.image_annotations for i in self.images.all() if not i.feedback_image]
)
def get_laterality_string(self): def get_laterality_string(self):
if self.laterality == self.NONE: if self.laterality == self.NONE:
@@ -382,10 +401,12 @@ class Rapid(models.Model):
compare_answers = self.get_compare_answers() compare_answers = self.get_compare_answers()
new_answers = [a for a in answers if get_answer_compare(a) not in compare_answers] new_answers = [
a for a in answers if get_answer_compare(a) not in compare_answers
]
return new_answers return new_answers
def get_question_json(self, based=True): def get_question_json(self, based=True):
"""Returns json""" """Returns json"""
@@ -399,7 +420,9 @@ class Rapid(models.Model):
if based: if based:
feedback_images.append(image_as_base64(i.image)) feedback_images.append(image_as_base64(i.image))
else: else:
feedback_images.append("{}/{}".format(settings.REMOTE_URL, i.image.url)) feedback_images.append(
"{}/{}".format(settings.REMOTE_URL, i.image.url)
)
feedback_images.append("{}/{}".format(settings.REMOTE_URL, i.image.url)) feedback_images.append("{}/{}".format(settings.REMOTE_URL, i.image.url))
else: else:
if based: if based:
@@ -407,11 +430,9 @@ class Rapid(models.Model):
else: else:
images.append("{}/{}".format(settings.REMOTE_URL, i.image.url)) images.append("{}/{}".format(settings.REMOTE_URL, i.image.url))
json = { json = {
"images": images, "images": images,
#"feedback_image": [], # "feedback_image": [],
"annotations": annotations, "annotations": annotations,
"type": "rapid", "type": "rapid",
} }
@@ -423,16 +444,15 @@ class Rapid(models.Model):
return json return json
#reversion.register(Rapid, follow=["images"]) # reversion.register(Rapid, follow=["images"])
#@reversion.register # @reversion.register
class RapidImage(models.Model): class RapidImage(models.Model):
rapid = models.ForeignKey(Rapid, rapid = models.ForeignKey(Rapid, related_name="images", on_delete=models.CASCADE)
related_name="images",
on_delete=models.CASCADE)
image = models.FileField(upload_to=image_directory_path) image = models.FileField(upload_to=image_directory_path)
image_annotations = models.TextField( image_annotations = models.TextField(
blank=True, null=True, blank=True,
null=True,
help_text="Stores a JSON representation of annotations to be applied by cornerstonetools", help_text="Stores a JSON representation of annotations to be applied by cornerstonetools",
) )
@@ -445,12 +465,14 @@ class RapidImage(models.Model):
def image_tag(self): def image_tag(self):
if self.image: if self.image:
return mark_safe( return mark_safe(
'<img src="{}" class="admin-rapid-image" /><span class="admin-rapid-image-info">Click and hold to zoom<span>' '<img src="{}" class="admin-rapid-image" /><span class="admin-rapid-image-info">Click and hold to zoom<span>'.format(
.format(self.image.url)) self.image.url
)
)
else: else:
return "" return ""
image_tag.short_description = 'Image' image_tag.short_description = "Image"
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
"""Override save method to add image hash""" """Override save method to add image hash"""
@@ -461,25 +483,24 @@ class RapidImage(models.Model):
# and generate a hash from the pixel data # and generate a hash from the pixel data
# TODO: improve? # TODO: improve?
dataset = pydicom.dcmread(self.image) dataset = pydicom.dcmread(self.image)
#flatten = dataset.pixel_array.astype(str).flatten() # flatten = dataset.pixel_array.astype(str).flatten()
#print("flatteded") # print("flatteded")
#pre_join = ",".join(flatten) # pre_join = ",".join(flatten)
#print(pre_join) # print(pre_join)
#hash = hashlib.md5(pre_join.encode()).hexdigest() # hash = hashlib.md5(pre_join.encode()).hexdigest()
#---- # ----
md5 = hashlib.md5() md5 = hashlib.md5()
first = True first = True
for i in dataset.pixel_array.astype(str).flatten(): for i in dataset.pixel_array.astype(str).flatten():
if first: if first:
first=False first = False
md5.update(f"{i}".encode()) md5.update(f"{i}".encode())
else: else:
md5.update(f",{i}".encode()) md5.update(f",{i}".encode())
hash = md5.hexdigest() hash = md5.hexdigest()
#---- # ----
self.is_dicom = True self.is_dicom = True
except pydicom.errors.InvalidDicomError: except pydicom.errors.InvalidDicomError:
self.image.file.open() self.image.file.open()
@@ -490,13 +511,12 @@ class RapidImage(models.Model):
class RapidCreationDefault(models.Model): class RapidCreationDefault(models.Model):
#author = models.OneToOneField(User, # author = models.OneToOneField(User,
author = models.OneToOneField( author = models.OneToOneField(
settings.AUTH_USER_MODEL, settings.AUTH_USER_MODEL, related_name="rapid_default", on_delete=models.CASCADE
related_name="rapid_default", )
on_delete=models.CASCADE)
#site = models.ManyToManyField( # site = models.ManyToManyField(
# Site, # Site,
# related_name="site_rapid_creation_default", # related_name="site_rapid_creation_default",
# blank=True, # blank=True,
@@ -504,24 +524,30 @@ class RapidCreationDefault(models.Model):
# help_text="Default site to use when creating a new rapid") # help_text="Default site to use when creating a new rapid")
def get_absolute_url(self): def get_absolute_url(self):
return reverse('rapids:rapid_create') return reverse("rapids:rapid_create")
#@reversion.register
# @reversion.register
class Exam(ExamBase): class Exam(ExamBase):
app_name = "rapids" app_name = "rapids"
exam_questions = SortedManyToManyField( exam_questions = SortedManyToManyField(Rapid, related_name="exams", blank="true")
Rapid, related_name="exams", blank="true"
)
time_limit = models.IntegerField( time_limit = models.IntegerField(
help_text="Exam time limit (in seconds). Default is 2100 secondse (35 minutes)", default=2100 help_text="Exam time limit (in seconds). Default is 2100 secondse (35 minutes)",
default=2100,
) )
author = models.ManyToManyField(settings.AUTH_USER_MODEL, author = models.ManyToManyField(
blank=True, settings.AUTH_USER_MODEL,
help_text='Author of exam', blank=True,
related_name="rapid_exam_author") help_text="Author of exam",
related_name="rapid_exam_author",
)
valid_users = models.ManyToManyField(
CidUser, blank=True, related_name="rapids_exams"
)
def get_normal_abnormal_breakdown(self): def get_normal_abnormal_breakdown(self):
# Inefficient but more extendible # Inefficient but more extendible
@@ -534,9 +560,8 @@ class Exam(ExamBase):
normal.append(q) normal.append(q)
else: else:
abnormal.append(q) abnormal.append(q)
return len(normal)
return len(normal)
def get_exam_json(self, based=True): def get_exam_json(self, based=True):
questions = self.exam_questions.all() questions = self.exam_questions.all()
@@ -556,32 +581,32 @@ class Exam(ExamBase):
if based: if based:
feedback_images.append(image_as_base64(i.image)) feedback_images.append(image_as_base64(i.image))
else: else:
feedback_images.append("{}/{}".format(settings.REMOTE_URL, i.image.url)) feedback_images.append(
"{}/{}".format(settings.REMOTE_URL, i.image.url)
)
else: else:
if based: if based:
images.append(image_as_base64(i.image)) images.append(image_as_base64(i.image))
else: else:
images.append("{}/{}".format(settings.REMOTE_URL, i.image.url)) images.append("{}/{}".format(settings.REMOTE_URL, i.image.url))
exam_questions[q.id] = { exam_questions[q.id] = {
"images": images, "images": images,
#"feedback_image": [], # "feedback_image": [],
#"annotations": [str(q.image_annotations)], # "annotations": [str(q.image_annotations)],
"type": "rapid", "type": "rapid",
} }
if not self.exam_mode: if not self.exam_mode:
exam_questions[q.id]["normal"] = q.normal exam_questions[q.id]["normal"] = q.normal
exam_questions[q.id]["feedback_images"] = feedback_images exam_questions[q.id]["feedback_images"] = feedback_images
exam_questions[q.id]["answers"] = list(q.get_correct_unstripped_answers()) exam_questions[q.id]["answers"] = list(
q.get_correct_unstripped_answers()
)
# if feedback_images:
#if feedback_images:
# exam_questions[q.id]["feedback_image"] = feedback_images # exam_questions[q.id]["feedback_image"] = feedback_images
exam_json = { exam_json = {
"eid": "rapid/{}".format(self.id), "eid": "rapid/{}".format(self.id),
"cached": False, "cached": False,
@@ -598,7 +623,7 @@ class Exam(ExamBase):
return exam_json return exam_json
def get_absolute_url(self): def get_absolute_url(self):
return reverse('rapids:exam_overview', kwargs={'pk': self.pk}) return reverse("rapids:exam_overview", kwargs={"pk": self.pk})
@reversion.register @reversion.register
@@ -615,7 +640,11 @@ class CidUserAnswer(models.Model):
answer_compare = models.TextField(max_length=500, blank=True) answer_compare = models.TextField(max_length=500, blank=True)
cid = models.BigIntegerField(blank=True, null=True, help_text="Candidate ID (limitied by BigIntegerField size)") cid = models.BigIntegerField(
blank=True,
null=True,
help_text="Candidate ID (limitied by BigIntegerField size)",
)
# Each user answer is associated with a particular exam # Each user answer is associated with a particular exam
exam = models.ForeignKey( exam = models.ForeignKey(
@@ -643,7 +672,7 @@ class CidUserAnswer(models.Model):
self.answer = self.answer.strip() self.answer = self.answer.strip()
s = self.answer.lower() s = self.answer.lower()
s = s.translate(str.maketrans('', '', string.punctuation.replace("#", ""))) s = s.translate(str.maketrans("", "", string.punctuation.replace("#", "")))
self.answer_compare = s self.answer_compare = s
@@ -671,9 +700,9 @@ class CidUserAnswer(models.Model):
ans = self.answer_compare ans = self.answer_compare
# TODO: this should be cleaned up as we don't want duplicates... # TODO: this should be cleaned up as we don't want duplicates...
#try: # try:
# marked_ans = q.answers.get(answer_compare__iexact=ans) # marked_ans = q.answers.get(answer_compare__iexact=ans)
#except Answer.DoesNotExist: # except Answer.DoesNotExist:
# marked_ans = None # marked_ans = None
marked_ans = q.answers.filter(answer_compare__iexact=ans).first() marked_ans = q.answers.filter(answer_compare__iexact=ans).first()
@@ -708,4 +737,4 @@ class CidUserAnswer(models.Model):
mark = 0 mark = 0
else: else:
mark = "unmarked" mark = "unmarked"
return mark return mark
+19
View File
@@ -0,0 +1,19 @@
# Generated by Django 3.2.8 on 2021-12-11 22:12
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('generic', '0015_ciduser_ciduserexam'),
('sbas', '0006_alter_exam_open_access'),
]
operations = [
migrations.AddField(
model_name='exam',
name='valid_users',
field=models.ManyToManyField(blank=True, related_name='sba_exams', to='generic.CidUser'),
),
]
+2 -1
View File
@@ -11,7 +11,7 @@ from django.utils.translation import ugettext_lazy as _
from sortedm2m.fields import SortedManyToManyField from sortedm2m.fields import SortedManyToManyField
from generic.models import ExamBase, QuestionNote from generic.models import CidUser, ExamBase, QuestionNote
import reversion import reversion
@@ -159,6 +159,7 @@ class Exam(ExamBase):
help_text='Author of exam', help_text='Author of exam',
related_name="sba_exam_author") related_name="sba_exam_author")
valid_users = models.ManyToManyField(CidUser, blank=True, related_name="sba_exams")
@reversion.register @reversion.register