Skip to content

Commit

Permalink
Merge pull request #85 from vitorfs/dev
Browse files Browse the repository at this point in the history
Release v2.1
  • Loading branch information
vitorfs authored Sep 12, 2021
2 parents 7d43597 + 1282550 commit 104375b
Show file tree
Hide file tree
Showing 48 changed files with 1,428 additions and 835 deletions.
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
Parsifal 2.1 (2021-09-12)
=========================

Features
--------

- Add new invite co-author feature (#40)


Parsifal 2.0.5 (2021-09-10)
===========================

Expand Down
2 changes: 1 addition & 1 deletion parsifal/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from parsifal.utils.version import get_version

VERSION = (2, 0, 5, "final", 0)
VERSION = (2, 1, 0, "final", 0)

__version__ = get_version(VERSION)
25 changes: 25 additions & 0 deletions parsifal/apps/authentication/forms.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from django import forms
from django.contrib.auth.forms import UserCreationForm, UsernameField
from django.utils.translation import gettext

Expand All @@ -7,6 +8,8 @@
validate_case_insensitive_username,
validate_forbidden_usernames,
)
from parsifal.apps.invites.constants import InviteStatus
from parsifal.apps.invites.models import Invite
from parsifal.utils.recaptcha import recaptcha_is_valid


Expand All @@ -19,6 +22,12 @@ def __init__(self, *args, **kwargs):


class SignUpForm(UserCreationForm):
invite = forms.ModelChoiceField(
queryset=Invite.objects.filter(invitee=None, status=InviteStatus.PENDING),
required=False,
widget=forms.HiddenInput(),
)

class Meta(UserCreationForm.Meta):
fields = ("username", "email")
field_classes = {"username": ASCIIUsernameField}
Expand All @@ -35,3 +44,19 @@ def clean(self):
if self.request and not recaptcha_is_valid(self.request):
self.add_error(None, gettext("The Google reCAPTCHA did not validate. Please try again."))
return cleaned_data

def accept_invite(self, user):
invite = self.cleaned_data.get("invite")
if invite:
invite.accept(user)
# check if there are currently any invitation with this user email
# and associate with their account
Invite.objects.filter(invitee=None, status=InviteStatus.PENDING, invitee_email__iexact=user.email).update(
invitee=user
)

def save(self, commit=True):
user = super().save(commit=commit)
if commit:
self.accept_invite(user)
return user
3 changes: 3 additions & 0 deletions parsifal/apps/authentication/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ class Meta:
verbose_name_plural = _("profiles")
db_table = "auth_profile"

def __str__(self):
return self.get_screen_name()

def get_url(self):
url = self.url
if "http://" not in self.url and "https://" not in self.url and len(self.url) > 0:
Expand Down
18 changes: 17 additions & 1 deletion parsifal/apps/authentication/views.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,36 @@
from django.contrib import messages
from django.contrib.auth import login
from django.core.exceptions import ValidationError
from django.shortcuts import redirect
from django.utils.decorators import method_decorator
from django.utils.functional import cached_property
from django.utils.translation import gettext as _
from django.views.decorators.cache import never_cache
from django.views.decorators.csrf import csrf_protect
from django.views.decorators.debug import sensitive_post_parameters
from django.views.generic import FormView

from parsifal.apps.authentication.forms import SignUpForm
from parsifal.apps.invites.constants import InviteStatus
from parsifal.apps.invites.models import Invite


@method_decorator([sensitive_post_parameters(), csrf_protect, never_cache], name="dispatch")
class SignUpView(FormView):
form_class = SignUpForm
template_name = "registration/signup.html"

@cached_property
def invite(self):
try:
code = self.request.GET.get("invite", self.request.POST.get("invite"))
return Invite.objects.filter(invitee=None, status=InviteStatus.PENDING).get(code=code)
except (Invite.DoesNotExist, ValidationError):
return None

def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs.update(request=self.request)
kwargs.update(request=self.request, initial={"invite": self.invite})
return kwargs

def form_valid(self, form):
Expand All @@ -36,3 +48,7 @@ def form_invalid(self, form):
),
)
return super().form_invalid(form)

def get_context_data(self, **kwargs):
kwargs.update(invite=self.invite)
return super().get_context_data(**kwargs)
1 change: 1 addition & 0 deletions parsifal/apps/invites/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
default_app_config = "parsifal.apps.invites.apps.InvitesConfig"
31 changes: 31 additions & 0 deletions parsifal/apps/invites/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
from django.contrib import admin
from django.utils.translation import gettext_lazy as _

from parsifal.apps.invites.models import Invite


@admin.register(Invite)
class InviteAdmin(admin.ModelAdmin):
date_hierarchy = "date_sent"
list_display = ("get_invitee_email", "invited_by", "review", "status", "date_sent", "date_answered")
list_select_related = ("invitee", "invited_by__profile", "review")
list_filter = ("status",)
raw_id_fields = ("invitee", "invited_by", "review")
search_fields = (
"invitee_email",
"invitee__email",
"invitee__username",
"invited_by__email",
"invited_by__username",
)
readonly_fields = ("status", "date_sent", "code")
fieldsets = (
(None, {"fields": ("review", "invited_by", "status", "code")}),
(_("Invitee"), {"fields": ("invitee", "invitee_email")}),
(_("Important dates"), {"fields": ("date_sent", "date_answered")}),
)

def get_invitee_email(self, obj):
return obj.get_invitee_email()

get_invitee_email.short_description = _("Invitee")
6 changes: 6 additions & 0 deletions parsifal/apps/invites/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from django.apps import AppConfig


class InvitesConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "parsifal.apps.invites"
13 changes: 13 additions & 0 deletions parsifal/apps/invites/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from django.utils.translation import gettext_lazy as _


class InviteStatus:
PENDING = "pending"
ACCEPTED = "accepted"
REJECTED = "rejected"

CHOICES = (
(PENDING, _("Pending")),
(ACCEPTED, _("Accepted")),
(REJECTED, _("Rejected")),
)
116 changes: 116 additions & 0 deletions parsifal/apps/invites/forms.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
from django import forms
from django.contrib.auth.models import User
from django.contrib.sites.shortcuts import get_current_site
from django.core.mail import EmailMultiAlternatives
from django.db.models.functions import Lower
from django.template.loader import render_to_string
from django.utils.translation import gettext as _

from parsifal.apps.invites.constants import InviteStatus
from parsifal.apps.invites.models import Invite


class SendInviteForm(forms.ModelForm):
class Meta:
model = Invite
fields = ("invitee", "invitee_email")

def __init__(self, *args, request, review, **kwargs):
self.request = request
self.review = review
super().__init__(*args, **kwargs)
user_ids = {user.pk for user in self.request.user.profile.get_following()}
self.fields["invitee"].queryset = (
User.objects.filter(pk__in=user_ids)
.exclude(pk__in=self.review.co_authors.all())
.annotate(lower_username=Lower("username"))
.order_by("lower_username")
)
self.fields["invitee"].label = _("Contacts")
self.fields["invitee"].help_text = _("List of people that you are currently following on Parsifal.")

self.fields["invitee_email"].label = _("Email address of the person you want to invite")
self.fields["invitee_email"].help_text = _(
"If the person you want to invite is not on Parsifal, you can inform their email address and we will send "
"an invitation link to their inbox."
)
self.fields["invitee_email"].required = False

def clean(self):
cleaned_data = super().clean()
if cleaned_data.get("invitee") and cleaned_data.get("invitee_email"):
self.add_error(
None, _("You must inform either a contact or an email address, but not both at the same time.")
)
if not cleaned_data.get("invitee") and not cleaned_data.get("invitee_email"):
self.add_error(None, _("You must inform either a contact or an email address."))
return cleaned_data

def clean_invitee(self):
invitee = self.cleaned_data.get("invitee")
if invitee:
if self.review.is_author_or_coauthor(invitee):
self.add_error("invitee", _("This person is already a co-author of this review."))
if Invite.objects.filter(
review=self.review, invitee_email__iexact=invitee.email, status=InviteStatus.PENDING
).exists():
self.add_error("invitee", _("This person already has a pending invite."))
return invitee

def clean_invitee_email(self):
invitee_email = self.cleaned_data.get("invitee_email")
if invitee_email:
invitee_email = User.objects.normalize_email(invitee_email)
if invitee_email.lower() == self.request.user.email.lower():
self.add_error("invitee_email", _("You cannot invite yourself."))
try:
user = User.objects.get(email__iexact=invitee_email)
if self.review.is_author_or_coauthor(user):
self.add_error("invitee_email", _("This person is already a co-author of this review."))
except User.DoesNotExist:
pass
if Invite.objects.filter(
review=self.review, invitee_email__iexact=invitee_email, status=InviteStatus.PENDING
).exists():
self.add_error("invitee_email", _("This person already has a pending invite."))
return invitee_email

def send_mail(self):
"""
Send a django.core.mail.EmailMultiAlternatives to `to_email`.
"""
subject = render_to_string("invites/invite_subject.txt", {"invite": self.instance})
# Email subject *must not* contain newlines
subject = "".join(subject.splitlines())

current_site = get_current_site(self.request)
site_name = current_site.name
domain = current_site.domain
invited_by_name = self.instance.invited_by.profile.get_screen_name()
from_email = f"{invited_by_name} via Parsifal <noreply@parsif.al>"
to_email = self.instance.get_invitee_email()
body = render_to_string(
"invites/invite_email.html",
{
"invite": self.instance,
"site_name": site_name,
"domain": domain,
"protocol": "https" if self.request.is_secure() else "http",
},
)

email_message = EmailMultiAlternatives(subject, body, from_email, [to_email])
email_message.send()

def save(self, commit=True):
self.instance = super().save(commit=False)
if self.instance.invitee:
self.instance.invitee_email = self.instance.invitee.email
else:
self.instance.invitee = User.objects.filter(email__iexact=self.instance.invitee_email).first()
self.instance.review = self.review
self.instance.invited_by = self.request.user
if commit:
self.instance.save()
self.send_mail()
return self.instance
37 changes: 37 additions & 0 deletions parsifal/apps/invites/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Generated by Django 3.2.7 on 2021-09-07 17:05

from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid


class Migration(migrations.Migration):

initial = True

dependencies = [
('reviews', '0036_auto_20210906_2320'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.CreateModel(
name='Invite',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('invitee_email', models.EmailField(blank=True, max_length=254, verbose_name='invitee email')),
('status', models.CharField(choices=[('pending', 'Pending'), ('accepted', 'Accepted'), ('rejected', 'Rejected')], default='pending', max_length=32, verbose_name='status')),
('code', models.UUIDField(default=uuid.uuid4, editable=False, verbose_name='code')),
('date_sent', models.DateTimeField(auto_now_add=True, verbose_name='date sent')),
('date_answered', models.DateTimeField(blank=True, null=True, verbose_name='date answered')),
('invited_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invites_sent', to=settings.AUTH_USER_MODEL, verbose_name='invited by')),
('invitee', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='invites_received', to=settings.AUTH_USER_MODEL, verbose_name='invitee')),
('review', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invites', to='reviews.review', verbose_name='review')),
],
options={
'verbose_name': 'invite',
'verbose_name_plural': 'invites',
},
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 3.2.7 on 2021-09-08 00:45

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('invites', '0001_initial'),
]

operations = [
migrations.AlterField(
model_name='invite',
name='invitee_email',
field=models.EmailField(db_index=True, max_length=254, verbose_name='invitee email'),
),
]
Empty file.
Loading

0 comments on commit 104375b

Please sign in to comment.