Files
@ 64b4d93470bd
Branch filter:
Location: symposion_app/vendor/symposion/reviews/models.py - annotation
64b4d93470bd
15.4 KiB
text/x-python
Add django-user-accounts app for use in place of SSO
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 | 3d68af979659 3d68af979659 3d68af979659 3d68af979659 d305cd8c13d7 d305cd8c13d7 3d68af979659 11f697d13757 6e133970d958 57acd04852f8 3d68af979659 3d68af979659 252697b842c0 3207621058b8 3d68af979659 a36ff64a8205 a36ff64a8205 3d68af979659 37c976c2f798 3d68af979659 252697b842c0 252697b842c0 3d68af979659 3d68af979659 be4404c60283 f1f29c6f61de 3d68af979659 03a231093fde 03a231093fde 36ab6d599ffc 3d68af979659 f1f29c6f61de f1f29c6f61de f1f29c6f61de f1f29c6f61de be4404c60283 3d68af979659 298b162be697 298b162be697 3d68af979659 3d68af979659 3d68af979659 3d68af979659 3d68af979659 3d68af979659 3d68af979659 e180c7f00b6d e180c7f00b6d 36ab6d599ffc 3d68af979659 3207621058b8 3207621058b8 3207621058b8 3d68af979659 36ab6d599ffc 252697b842c0 252697b842c0 252697b842c0 252697b842c0 252697b842c0 252697b842c0 252697b842c0 252697b842c0 252697b842c0 252697b842c0 36ab6d599ffc 3207621058b8 36ab6d599ffc 3207621058b8 3207621058b8 36ab6d599ffc 3d68af979659 3d68af979659 3d68af979659 3d68af979659 3d68af979659 3d68af979659 3d68af979659 3d68af979659 e180c7f00b6d e180c7f00b6d e180c7f00b6d e180c7f00b6d 3d68af979659 3d68af979659 3d68af979659 3d68af979659 3d68af979659 3d68af979659 3d68af979659 e180c7f00b6d 3d68af979659 e180c7f00b6d e180c7f00b6d e180c7f00b6d 3d68af979659 3d68af979659 3d68af979659 3d68af979659 3d68af979659 3d68af979659 3d68af979659 3d68af979659 252697b842c0 252697b842c0 252697b842c0 252697b842c0 252697b842c0 252697b842c0 252697b842c0 252697b842c0 252697b842c0 252697b842c0 252697b842c0 36ab6d599ffc 11f697d13757 11f697d13757 3207621058b8 3d68af979659 11f697d13757 11f697d13757 11f697d13757 11f697d13757 3d68af979659 3d68af979659 3207621058b8 3207621058b8 3d68af979659 3d68af979659 3d68af979659 3d68af979659 36ab6d599ffc 252697b842c0 252697b842c0 252697b842c0 252697b842c0 252697b842c0 252697b842c0 252697b842c0 252697b842c0 252697b842c0 252697b842c0 252697b842c0 36ab6d599ffc 3d68af979659 3d68af979659 3207621058b8 d305cd8c13d7 d305cd8c13d7 d305cd8c13d7 d305cd8c13d7 11f697d13757 3207621058b8 36ab6d599ffc d305cd8c13d7 d305cd8c13d7 d305cd8c13d7 d305cd8c13d7 d305cd8c13d7 d305cd8c13d7 d305cd8c13d7 d305cd8c13d7 3d68af979659 11f697d13757 3d68af979659 3d68af979659 36ab6d599ffc 36ab6d599ffc 36ab6d599ffc 36ab6d599ffc 36ab6d599ffc 3d68af979659 3d68af979659 3d68af979659 3d68af979659 3d68af979659 3d68af979659 3d68af979659 3d68af979659 36ab6d599ffc 3d68af979659 3d68af979659 3d68af979659 3d68af979659 3d68af979659 3d68af979659 3d68af979659 2c46e56b353c 2c46e56b353c 3d68af979659 2c46e56b353c 3d68af979659 3d68af979659 3d68af979659 3d68af979659 3d68af979659 2c46e56b353c 2c46e56b353c 2c46e56b353c 2c46e56b353c 2c46e56b353c 36ab6d599ffc 36ab6d599ffc 3d68af979659 3d68af979659 3d68af979659 3d68af979659 3d68af979659 3d68af979659 3d68af979659 2c46e56b353c 2c46e56b353c 866217bf35d7 3d68af979659 3d68af979659 3d68af979659 36ab6d599ffc 3d68af979659 3d68af979659 be4404c60283 acc1b1490e33 3d68af979659 3d68af979659 acc1b1490e33 3d68af979659 36ab6d599ffc 3d68af979659 3d68af979659 3d68af979659 3d68af979659 3207621058b8 3207621058b8 3207621058b8 3207621058b8 3d68af979659 3d68af979659 3d68af979659 36ab6d599ffc 252697b842c0 252697b842c0 252697b842c0 252697b842c0 252697b842c0 252697b842c0 252697b842c0 252697b842c0 252697b842c0 252697b842c0 252697b842c0 36ab6d599ffc 3d68af979659 3d68af979659 3207621058b8 3207621058b8 36ab6d599ffc 3d68af979659 3d68af979659 3207621058b8 3207621058b8 36ab6d599ffc 3d68af979659 3d68af979659 be4404c60283 acc1b1490e33 3d68af979659 3d68af979659 acc1b1490e33 3d68af979659 3d68af979659 3d68af979659 3d68af979659 252697b842c0 252697b842c0 252697b842c0 252697b842c0 252697b842c0 252697b842c0 3207621058b8 3207621058b8 7f3ed91daed6 3207621058b8 be4404c60283 f1f29c6f61de 3207621058b8 3207621058b8 f1f29c6f61de 3d68af979659 3d68af979659 3d68af979659 3d68af979659 3207621058b8 04228555827f 3207621058b8 3207621058b8 3207621058b8 3207621058b8 3207621058b8 36ab6d599ffc 3d68af979659 3d68af979659 3d68af979659 3d68af979659 57acd04852f8 57acd04852f8 866217bf35d7 866217bf35d7 866217bf35d7 866217bf35d7 866217bf35d7 866217bf35d7 57acd04852f8 57acd04852f8 57acd04852f8 57acd04852f8 57acd04852f8 57acd04852f8 57acd04852f8 57acd04852f8 57acd04852f8 57acd04852f8 57acd04852f8 57acd04852f8 57acd04852f8 57acd04852f8 57acd04852f8 57acd04852f8 57acd04852f8 57acd04852f8 57acd04852f8 57acd04852f8 57acd04852f8 7f3ed91daed6 866217bf35d7 3d68af979659 3d68af979659 3207621058b8 3207621058b8 3207621058b8 3207621058b8 3d68af979659 3d68af979659 252697b842c0 252697b842c0 252697b842c0 252697b842c0 252697b842c0 252697b842c0 252697b842c0 252697b842c0 252697b842c0 252697b842c0 252697b842c0 11f697d13757 11f697d13757 36ab6d599ffc 3d68af979659 3207621058b8 3207621058b8 3207621058b8 3207621058b8 3207621058b8 3207621058b8 3d68af979659 11f697d13757 031ee3807e84 11f697d13757 11f697d13757 3d68af979659 3dd746836545 36ab6d599ffc 3207621058b8 3207621058b8 3207621058b8 3207621058b8 3207621058b8 3207621058b8 3207621058b8 3207621058b8 3dd746836545 3dd746836545 3dd746836545 36ab6d599ffc 252697b842c0 252697b842c0 252697b842c0 252697b842c0 252697b842c0 252697b842c0 252697b842c0 252697b842c0 252697b842c0 252697b842c0 252697b842c0 252697b842c0 252697b842c0 3207621058b8 3207621058b8 3207621058b8 433a99a4020c 3207621058b8 36ab6d599ffc 3dd2f14f7293 3dd2f14f7293 3dd2f14f7293 3dd2f14f7293 c7608fb0d5fa c7608fb0d5fa c7608fb0d5fa 733c4adc530b 733c4adc530b 3dd2f14f7293 3dd746836545 3dd746836545 3d68af979659 37c976c2f798 37c976c2f798 37c976c2f798 699b32b938d2 699b32b938d2 699b32b938d2 699b32b938d2 699b32b938d2 699b32b938d2 37c976c2f798 37c976c2f798 36ab6d599ffc 36ab6d599ffc 36ab6d599ffc 36ab6d599ffc 36ab6d599ffc 37c976c2f798 37c976c2f798 699b32b938d2 699b32b938d2 699b32b938d2 36ab6d599ffc 37c976c2f798 3d68af979659 3d68af979659 d4b53263950c d4b53263950c d4b53263950c d4b53263950c d4b53263950c 3d68af979659 d02e7f83c6ed 3d68af979659 04228555827f 3d68af979659 d4b53263950c d4b53263950c 298b162be697 298b162be697 3d68af979659 | # -*- coding: utf-8 -*-
from datetime import datetime
from decimal import Decimal
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import Q, F
from django.db.models import Case, When, Value
from django.db.models import Count
from django.db.models.signals import post_save
from django.contrib.auth import get_user_model
from django.utils.translation import ugettext_lazy as _
from symposion import constants
from symposion.text_parser import parse
from symposion.proposals.models import ProposalBase
from symposion.schedule.models import Presentation
User = get_user_model()
class Votes(object):
ABSTAIN = "0"
PLUS_TWO = "+2"
PLUS_ONE = "+1"
MINUS_ONE = "-1"
MINUS_TWO = "-2"
CHOICES = [
(PLUS_TWO, _("+2 — Good proposal and I will argue for it to be accepted.")),
(PLUS_ONE, _("+1 — OK proposal, but I will not argue for it to be accepted.")),
(MINUS_ONE, _("−1 — Weak proposal, but I will not argue strongly against acceptance.")),
(MINUS_TWO, _("−2 — Serious issues and I will argue to reject this proposal.")),
(ABSTAIN, _("Abstain - I do not want to review this proposal and I do not want to see it again.")),
]
VOTES = Votes()
class ReviewAssignment(models.Model):
AUTO_ASSIGNED_INITIAL = 0
OPT_IN = 1
AUTO_ASSIGNED_LATER = 2
NUM_REVIEWERS = 3
ORIGIN_CHOICES = [
(AUTO_ASSIGNED_INITIAL, _("auto-assigned, initial")),
(OPT_IN, _("opted-in")),
(AUTO_ASSIGNED_LATER, _("auto-assigned, later")),
]
proposal = models.ForeignKey(
ProposalBase,
verbose_name=_("Proposal"),
on_delete=models.CASCADE,
)
user = models.ForeignKey(
User,
verbose_name=_("User"),
on_delete=models.CASCADE,
)
origin = models.IntegerField(choices=ORIGIN_CHOICES, verbose_name=_("Origin"))
assigned_at = models.DateTimeField(default=datetime.now, verbose_name=_("Assigned at"))
opted_out = models.BooleanField(default=False, verbose_name=_("Opted out"))
@classmethod
def create_assignments(cls, proposal, origin=AUTO_ASSIGNED_INITIAL):
speakers = [proposal.speaker] + list(proposal.additional_speakers.all())
reviewers = User.objects.exclude(
pk__in=[
speaker.user_id
for speaker in speakers
if speaker.user_id is not None
] + [
assignment.user_id
for assignment in ReviewAssignment.objects.filter(
proposal_id=proposal.id)]
).filter(
groups__name="reviewers",
).filter(
Q(reviewassignment__opted_out=False) | Q(reviewassignment=None)
).annotate(
num_assignments=models.Count("reviewassignment")
).order_by(
"num_assignments", "?",
)
num_assigned_reviewers = ReviewAssignment.objects.filter(
proposal_id=proposal.id, opted_out=0).count()
for reviewer in reviewers[:max(0, cls.NUM_REVIEWERS - num_assigned_reviewers)]:
cls._default_manager.create(
proposal=proposal,
user=reviewer,
origin=origin,
)
class ProposalMessage(models.Model):
proposal = models.ForeignKey(
ProposalBase,
related_name="messages",
verbose_name=_("Proposal"),
on_delete=models.CASCADE,
)
user = models.ForeignKey(
User,
verbose_name=_("User"),
on_delete=models.CASCADE,
)
message = models.TextField(verbose_name=_("Message"))
message_html = models.TextField(blank=True)
submitted_at = models.DateTimeField(default=datetime.now, editable=False, verbose_name=_("Submitted at"))
def save(self, *args, **kwargs):
self.message_html = parse(self.message)
return super(ProposalMessage, self).save(*args, **kwargs)
class Meta:
ordering = ["submitted_at"]
verbose_name = _("proposal message")
verbose_name_plural = _("proposal messages")
class Review(models.Model):
VOTES = VOTES
proposal = models.ForeignKey(
ProposalBase,
related_name="reviews",
verbose_name=_("Proposal"),
on_delete=models.CASCADE,
)
user = models.ForeignKey(
User,
verbose_name=_("User"),
on_delete=models.CASCADE,
)
# No way to encode "-0" vs. "+0" into an IntegerField, and I don't feel
# like some complicated encoding system.
vote = models.CharField(max_length=2, blank=True, choices=VOTES.CHOICES, verbose_name=_("Vote"))
comment = models.TextField(
blank=True,
verbose_name=_("Comment")
)
comment_html = models.TextField(blank=True)
submitted_at = models.DateTimeField(default=datetime.now, editable=False, verbose_name=_("Submitted at"))
def clean(self):
err = {}
if self.vote != VOTES.ABSTAIN and not self.comment.strip():
err["comment"] = ValidationError(_("You must provide a comment"))
if err:
raise ValidationError(err)
def save(self, **kwargs):
self.comment_html = parse(self.comment)
if self.vote:
vote, created = LatestVote.objects.get_or_create(
proposal=self.proposal,
user=self.user,
defaults=dict(
vote=self.vote,
submitted_at=self.submitted_at,
)
)
if not created:
LatestVote.objects.filter(pk=vote.pk).update(vote=self.vote)
self.proposal.result.update_vote(self.vote, previous=vote.vote)
else:
self.proposal.result.update_vote(self.vote)
super(Review, self).save(**kwargs)
def delete(self):
model = self.__class__
user_reviews = model._default_manager.filter(
proposal=self.proposal,
user=self.user,
)
try:
# find the latest review
latest = user_reviews.exclude(pk=self.pk).order_by("-submitted_at")[0]
except IndexError:
# did not find a latest which means this must be the only one.
# treat it as a last, but delete the latest vote.
self.proposal.result.update_vote(self.vote, removal=True)
lv = LatestVote.objects.filter(proposal=self.proposal, user=self.user)
lv.delete()
else:
# handle that we've found a latest vote
# check if self is the lastest vote
if self == latest:
# self is the latest review; revert the latest vote to the
# previous vote
previous = user_reviews.filter(submitted_at__lt=self.submitted_at)\
.order_by("-submitted_at")[0]
self.proposal.result.update_vote(self.vote, previous=previous.vote, removal=True)
lv = LatestVote.objects.filter(proposal=self.proposal, user=self.user)
lv.update(
vote=previous.vote,
submitted_at=previous.submitted_at,
)
else:
# self is not the latest review so we just need to decrement
# the comment count
self.proposal.result.comment_count = F("comment_count") - 1
self.proposal.result.save()
# in all cases we need to delete the review; let's do it!
super(Review, self).delete()
def css_class(self):
return {
self.VOTES.ABSTAIN: "abstain",
self.VOTES.PLUS_TWO: "plus-two",
self.VOTES.PLUS_ONE: "plus-one",
self.VOTES.MINUS_ONE: "minus-one",
self.VOTES.MINUS_TWO: "minus-two",
}[self.vote]
@property
def section(self):
return self.proposal.kind.section.slug
class Meta:
verbose_name = _("review")
verbose_name_plural = _("reviews")
class LatestVote(models.Model):
VOTES = VOTES
proposal = models.ForeignKey(
ProposalBase,
related_name="votes",
verbose_name=_("Proposal"),
on_delete=models.CASCADE,
)
user = models.ForeignKey(
User,
verbose_name=_("User"),
on_delete=models.CASCADE,
)
# No way to encode "-0" vs. "+0" into an IntegerField, and I don't feel
# like some complicated encoding system.
vote = models.CharField(max_length=2, choices=VOTES.CHOICES, verbose_name=_("Vote"))
submitted_at = models.DateTimeField(default=datetime.now, editable=False, verbose_name=_("Submitted at"))
class Meta:
unique_together = [("proposal", "user")]
verbose_name = _("latest vote")
verbose_name_plural = _("latest votes")
def css_class(self):
return {
self.VOTES.ABSTAIN: "abstain",
self.VOTES.PLUS_TWO: "plus-two",
self.VOTES.PLUS_ONE: "plus-one",
self.VOTES.MINUS_ONE: "minus-one",
self.VOTES.MINUS_TWO: "minus-two",
}[self.vote]
class ProposalResult(models.Model):
proposal = models.OneToOneField(
ProposalBase,
related_name="result",
verbose_name=_("Proposal"),
on_delete=models.CASCADE,
)
score = models.DecimalField(max_digits=5, decimal_places=2, default=Decimal("0.00"), verbose_name=_("Score"))
comment_count = models.PositiveIntegerField(default=0, verbose_name=_("Comment count"))
# vote_count only counts non-abstain votes.
vote_count = models.PositiveIntegerField(default=0, verbose_name=_("Vote count"))
abstain = models.PositiveIntegerField(default=0, verbose_name=_("Abstain"))
plus_two = models.PositiveIntegerField(default=0, verbose_name=_("Plus two"))
plus_one = models.PositiveIntegerField(default=0, verbose_name=_("Plus one"))
minus_one = models.PositiveIntegerField(default=0, verbose_name=_("Minus one"))
minus_two = models.PositiveIntegerField(default=0, verbose_name=_("Minus two"))
accepted = models.NullBooleanField(choices=[
(True, "accepted"),
(False, "rejected"),
(None, "undecided"),
], default=None, verbose_name=_("Accepted"))
status = models.CharField(max_length=20, choices=[
("accepted", _("accepted")),
("rejected", _("rejected")),
("undecided", _("undecided")),
("standby", _("standby")),
], default="undecided", verbose_name=_("Status"))
@classmethod
def full_calculate(cls):
for proposal in ProposalBase.objects.all():
result, created = cls._default_manager.get_or_create(proposal=proposal)
result.update_vote()
def calculate_score(self):
if self.vote_count == 0:
return 0
else:
return ((2 * self.plus_two + self.plus_one) - (2 * self.minus_two + self.minus_one)) / self.vote_count
def update_vote(self, *a, **k):
proposal = self.proposal
self.comment_count = Review.objects.filter(proposal=proposal).count()
agg = LatestVote.objects.filter(proposal=proposal).values(
"vote"
).annotate(
count=Count("vote")
)
vote_count = {}
# Set the defaults
for option in VOTES.CHOICES:
vote_count[option[0]] = 0
# Set the actual values if present
for d in agg:
vote_count[d["vote"]] = d["count"]
self.abstain = vote_count[VOTES.ABSTAIN]
self.plus_two = vote_count[VOTES.PLUS_TWO]
self.plus_one = vote_count[VOTES.PLUS_ONE]
self.minus_one = vote_count[VOTES.MINUS_ONE]
self.minus_two = vote_count[VOTES.MINUS_TWO]
self.vote_count = sum(i[1] for i in vote_count.items()) - self.abstain
self.score = self.calculate_score()
self.save()
class Meta:
verbose_name = _("proposal_result")
verbose_name_plural = _("proposal_results")
class Comment(models.Model):
proposal = models.ForeignKey(
ProposalBase,
related_name="comments",
verbose_name=_("Proposal"),
on_delete=models.CASCADE,
)
commenter = models.ForeignKey(
User,
verbose_name=_("Commenter"),
on_delete=models.CASCADE,
)
text = models.TextField(verbose_name=_("Text"))
text_html = models.TextField(blank=True)
# Or perhaps more accurately, can the user see this comment.
public = models.BooleanField(choices=[(True, _("public")), (False, _("private"))], default=False, verbose_name=_("Public"))
commented_at = models.DateTimeField(default=datetime.now, verbose_name=_("Commented at"))
class Meta:
verbose_name = _("comment")
verbose_name_plural = _("comments")
def save(self, *args, **kwargs):
self.text_html = parse(self.text)
return super(Comment, self).save(*args, **kwargs)
class NotificationTemplate(models.Model):
label = models.CharField(max_length=100, verbose_name=_("Label"))
from_address = models.EmailField(verbose_name=_("From address"))
subject = models.CharField(max_length=100, verbose_name=_("Subject"))
body = models.TextField(verbose_name=_("Body"))
class Meta:
verbose_name = _("notification template")
verbose_name_plural = _("notification templates")
class ResultNotification(models.Model):
proposal = models.ForeignKey(
ProposalBase,
related_name="notifications",
verbose_name=_("Proposal"),
on_delete=models.CASCADE,
)
template = models.ForeignKey(
NotificationTemplate,
null=True,
blank=True,
on_delete=models.SET_NULL,
verbose_name=_("Template")
)
timestamp = models.DateTimeField(default=datetime.now, verbose_name=_("Timestamp"))
to_address = models.EmailField(verbose_name=_("To address"))
from_address = models.EmailField(verbose_name=_("From address"))
subject = models.CharField(max_length=255, verbose_name=_("Subject"))
body = models.TextField(verbose_name=_("Body"))
def recipients(self):
for speaker in self.proposal.speakers():
yield speaker.email
def __unicode__(self):
return self.proposal.title + ' ' + self.timestamp.strftime('%Y-%m-%d %H:%M:%S')
@property
def email_args(self):
return (self.subject, self.body, self.from_address, self.recipients())
def promote_proposal(proposal):
if hasattr(proposal, "presentation") and proposal.presentation:
# already promoted
presentation = proposal.presentation
presentation.title = proposal.title
presentation.abstract = proposal.abstract
presentation.speaker = proposal.speaker
presentation.proposal_base = proposal
presentation.save()
presentation.additional_speakers.clear()
else:
presentation = Presentation(
title=proposal.title,
abstract=proposal.abstract,
speaker=proposal.speaker,
section=proposal.section,
proposal_base=proposal,
)
presentation.save()
for speaker in proposal.additional_speakers.all():
presentation.additional_speakers.add(speaker)
presentation.save()
return presentation
def unpromote_proposal(proposal):
if hasattr(proposal, "presentation") and proposal.presentation:
proposal.presentation.delete()
def accepted_proposal(sender, instance=None, **kwargs):
if instance is None:
return
if instance.status == "accepted":
promote_proposal(instance.proposal)
else:
unpromote_proposal(instance.proposal)
post_save.connect(accepted_proposal, sender=ProposalResult)
|