Skip to content
Snippets Groups Projects
Commit 2649ad88 authored by Eliot Berriot's avatar Eliot Berriot
Browse files

Merge branch '187-emails' into 'develop'

Resolve "Add email support"

Closes #187

See merge request !182
parents 988940ea 4a7105ae
No related branches found
No related tags found
No related merge requests found
Showing
with 138 additions and 111 deletions
......@@ -144,7 +144,6 @@ INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
MIDDLEWARE = (
# Make sure djangosecure.middleware.SecurityMiddleware is listed first
'django.contrib.sessions.middleware.SessionMiddleware',
'funkwhale_api.users.middleware.AnonymousSessionMiddleware',
'corsheaders.middleware.CorsMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
......@@ -173,7 +172,10 @@ FIXTURE_DIRS = (
# EMAIL CONFIGURATION
# ------------------------------------------------------------------------------
EMAIL_BACKEND = env('DJANGO_EMAIL_BACKEND', default='django.core.mail.backends.smtp.EmailBackend')
EMAIL_CONFIG = env.email_url(
'EMAIL_CONFIG', default='consolemail://')
vars().update(EMAIL_CONFIG)
# DATABASE CONFIGURATION
# ------------------------------------------------------------------------------
......@@ -293,7 +295,7 @@ AUTHENTICATION_BACKENDS = (
'django.contrib.auth.backends.ModelBackend',
'allauth.account.auth_backends.AuthenticationBackend',
)
SESSION_COOKIE_HTTPONLY = False
# Some really nice defaults
ACCOUNT_AUTHENTICATION_METHOD = 'username_email'
ACCOUNT_EMAIL_REQUIRED = True
......@@ -368,6 +370,7 @@ CORS_ORIGIN_ALLOW_ALL = True
# 'funkwhale.localhost',
# )
CORS_ALLOW_CREDENTIALS = True
REST_FRAMEWORK = {
'DEFAULT_PERMISSION_CLASSES': (
'rest_framework.permissions.IsAuthenticated',
......@@ -392,6 +395,11 @@ REST_FRAMEWORK = {
'django_filters.rest_framework.DjangoFilterBackend',
)
}
REST_AUTH_SERIALIZERS = {
'PASSWORD_RESET_SERIALIZER': 'funkwhale_api.users.serializers.PasswordResetSerializer' # noqa
}
REST_SESSION_LOGIN = False
REST_USE_JWT = True
ATOMIC_REQUESTS = False
USE_X_FORWARDED_HOST = True
......
......@@ -25,9 +25,6 @@ SECRET_KEY = env("DJANGO_SECRET_KEY", default='mc$&b=5j#6^bv7tld1gyjp2&+^-qrdy=0
# ------------------------------------------------------------------------------
EMAIL_HOST = 'localhost'
EMAIL_PORT = 1025
EMAIL_BACKEND = env('DJANGO_EMAIL_BACKEND',
default='django.core.mail.backends.console.EmailBackend')
# django-debug-toolbar
# ------------------------------------------------------------------------------
......
......@@ -21,13 +21,6 @@ class Listening(models.Model):
class Meta:
ordering = ('-creation_date',)
def save(self, **kwargs):
if not self.user and not self.session_key:
raise ValidationError('Cannot have both session_key and user empty for listening')
super().save(**kwargs)
def get_activity_url(self):
return '{}/listenings/tracks/{}'.format(
self.user.get_activity_url(), self.pk)
......@@ -36,13 +36,9 @@ class ListeningSerializer(serializers.ModelSerializer):
class Meta:
model = models.Listening
fields = ('id', 'user', 'session_key', 'track', 'creation_date')
fields = ('id', 'user', 'track', 'creation_date')
def create(self, validated_data):
if self.context.get('user'):
validated_data['user'] = self.context.get('user')
else:
validated_data['session_key'] = self.context['session_key']
validated_data['user'] = self.context['user']
return super().create(validated_data)
from rest_framework import generics, mixins, viewsets
from rest_framework import permissions
from rest_framework import status
from rest_framework.response import Response
from rest_framework.decorators import detail_route
......@@ -10,31 +11,26 @@ from funkwhale_api.music.serializers import TrackSerializerNested
from . import models
from . import serializers
class ListeningViewSet(mixins.CreateModelMixin,
mixins.RetrieveModelMixin,
viewsets.GenericViewSet):
class ListeningViewSet(
mixins.CreateModelMixin,
mixins.RetrieveModelMixin,
viewsets.GenericViewSet):
serializer_class = serializers.ListeningSerializer
queryset = models.Listening.objects.all()
permission_classes = [ConditionalAuthentication]
permission_classes = [permissions.IsAuthenticated]
def perform_create(self, serializer):
r = super().perform_create(serializer)
if self.request.user.is_authenticated:
record.send(serializer.instance)
record.send(serializer.instance)
return r
def get_queryset(self):
queryset = super().get_queryset()
if self.request.user.is_authenticated:
return queryset.filter(user=self.request.user)
else:
return queryset.filter(session_key=self.request.session.session_key)
return queryset.filter(user=self.request.user)
def get_serializer_context(self):
context = super().get_serializer_context()
if self.request.user.is_authenticated:
context['user'] = self.request.user
else:
context['session_key'] = self.request.session.session_key
context['user'] = self.request.user
return context
......@@ -55,8 +55,6 @@ class RadioSession(models.Model):
related_object = GenericForeignKey('related_object_content_type', 'related_object_id')
def save(self, **kwargs):
if not self.user and not self.session_key:
raise ValidationError('Cannot have both session_key and user empty for radio session')
self.radio.clean(self)
super().save(**kwargs)
......
......@@ -38,6 +38,7 @@ class RadioSerializer(serializers.ModelSerializer):
return super().save(**kwargs)
class RadioSessionTrackSerializerCreate(serializers.ModelSerializer):
class Meta:
model = models.RadioSessionTrack
......@@ -62,17 +63,14 @@ class RadioSessionSerializer(serializers.ModelSerializer):
'user',
'creation_date',
'custom_radio',
'session_key')
)
def validate(self, data):
registry[data['radio_type']]().validate_session(data, **self.context)
return data
def create(self, validated_data):
if self.context.get('user'):
validated_data['user'] = self.context.get('user')
else:
validated_data['session_key'] = self.context['session_key']
validated_data['user'] = self.context['user']
if validated_data.get('related_object_id'):
radio = registry[validated_data['radio_type']]()
validated_data['related_object'] = radio.get_related_object(validated_data['related_object_id'])
......
......@@ -2,6 +2,7 @@ from django.db.models import Q
from django.http import Http404
from rest_framework import generics, mixins, viewsets
from rest_framework import permissions
from rest_framework import status
from rest_framework.response import Response
from rest_framework.decorators import detail_route, list_route
......@@ -24,7 +25,7 @@ class RadioViewSet(
viewsets.GenericViewSet):
serializer_class = serializers.RadioSerializer
permission_classes = [ConditionalAuthentication]
permission_classes = [permissions.IsAuthenticated]
filter_class = filtersets.RadioFilter
def get_queryset(self):
......@@ -84,21 +85,15 @@ class RadioSessionViewSet(mixins.CreateModelMixin,
serializer_class = serializers.RadioSessionSerializer
queryset = models.RadioSession.objects.all()
permission_classes = [ConditionalAuthentication]
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
queryset = super().get_queryset()
if self.request.user.is_authenticated:
return queryset.filter(user=self.request.user)
else:
return queryset.filter(session_key=self.request.session.session_key)
return queryset.filter(user=self.request.user)
def get_serializer_context(self):
context = super().get_serializer_context()
if self.request.user.is_authenticated:
context['user'] = self.request.user
else:
context['session_key'] = self.request.session.session_key
context['user'] = self.request.user
return context
......@@ -106,17 +101,14 @@ class RadioSessionTrackViewSet(mixins.CreateModelMixin,
viewsets.GenericViewSet):
serializer_class = serializers.RadioSessionTrackSerializer
queryset = models.RadioSessionTrack.objects.all()
permission_classes = [ConditionalAuthentication]
permission_classes = [permissions.IsAuthenticated]
def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
session = serializer.validated_data['session']
try:
if request.user.is_authenticated:
assert request.user == session.user
else:
assert request.session.session_key == session.session_key
assert request.user == session.user
except AssertionError:
return Response(status=status.HTTP_403_FORBIDDEN)
track = session.radio.pick()
......
{% load account %}{% user_display user as user_display %}{% load i18n %}{% autoescape off %}{% blocktrans with site_name=current_site.name site_domain=current_site.domain %}Hello from {{ site_name }}!
You're receiving this e-mail because user {{ user_display }} at {{ site_domain }} has given yours as an e-mail address to connect their account.
To confirm this is correct, go to {{ funkwhale_url }}/auth/email/confirm?key={{ key }}
{% endblocktrans %}{% endautoescape %}
{% blocktrans with site_name=current_site.name site_domain=current_site.domain %}Thank you from {{ site_name }}!
{{ site_domain }}{% endblocktrans %}
{% load i18n %}{% autoescape off %}
{% blocktrans %}You're receiving this email because you requested a password reset for your user account at {{ site_name }}.{% endblocktrans %}
{% trans "Please go to the following page and choose a new password:" %}
{{ funkwhale_url }}/auth/password/reset/confirm?uid={{ uid }}&token={{ token }}
{% trans "Your username, in case you've forgotten:" %} {{ user.get_username }}
{% trans "Thanks for using our site!" %}
{% blocktrans %}The {{ site_name }} team{% endblocktrans %}
{% endautoescape %}
from allauth.account.adapter import DefaultAccountAdapter
from django.conf import settings
from allauth.account.adapter import DefaultAccountAdapter
from dynamic_preferences.registries import global_preferences_registry
......@@ -8,3 +9,7 @@ class FunkwhaleAccountAdapter(DefaultAccountAdapter):
def is_open_for_signup(self, request):
manager = global_preferences_registry.manager()
return manager['users__registration_enabled']
def send_mail(self, template_prefix, email, context):
context['funkwhale_url'] = settings.FUNKWHALE_URL
return super().send_mail(template_prefix, email, context)
class AnonymousSessionMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
if not request.session.session_key:
request.session.save()
response = self.get_response(request)
return response
from django.views.generic import TemplateView
from django.conf.urls import url
from rest_auth.registration.views import VerifyEmailView
from rest_auth.views import PasswordChangeView
from rest_auth.registration import views as registration_views
from rest_auth import views as rest_auth_views
from .views import RegisterView
from . import views
urlpatterns = [
url(r'^$', RegisterView.as_view(), name='rest_register'),
url(r'^verify-email/$', VerifyEmailView.as_view(), name='rest_verify_email'),
url(r'^change-password/$', PasswordChangeView.as_view(), name='change_password'),
url(r'^$', views.RegisterView.as_view(), name='rest_register'),
url(r'^verify-email/$',
registration_views.VerifyEmailView.as_view(),
name='rest_verify_email'),
url(r'^change-password/$',
rest_auth_views.PasswordChangeView.as_view(),
name='change_password'),
# This url is used by django-allauth and empty TemplateView is
# defined just to allow reverse() call inside app, for example when email
......
from rest_framework import serializers
from django.conf import settings
from rest_framework import serializers
from rest_auth.serializers import PasswordResetSerializer as PRS
from funkwhale_api.activity import serializers as activity_serializers
from . import models
......@@ -63,3 +65,12 @@ class UserReadSerializer(serializers.ModelSerializer):
'status': o.has_perm(internal_codename)
}
return perms
class PasswordResetSerializer(PRS):
def get_email_options(self):
return {
'extra_email_context': {
'funkwhale_url': settings.FUNKWHALE_URL
}
}
......@@ -11,7 +11,7 @@ python_files = tests.py test_*.py *_tests.py
testpaths = tests
env =
SECRET_KEY=test
DJANGO_EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend
EMAIL_CONFIG=consolemail://
CELERY_BROKER_URL=memory://
CELERY_TASK_ALWAYS_EAGER=True
CACHEOPS_ENABLED=False
......
......@@ -14,21 +14,6 @@ def test_can_create_listening(factories):
l = models.Listening.objects.create(user=user, track=track)
def test_anonymous_user_can_create_listening_via_api(
client, factories, preferences):
preferences['common__api_authentication_required'] = False
track = factories['music.Track']()
url = reverse('api:v1:history:listenings-list')
response = client.post(url, {
'track': track.pk,
})
listening = models.Listening.objects.latest('id')
assert listening.track == track
assert listening.session_key == client.session.session_key
def test_logged_in_user_can_create_listening_via_api(
logged_in_client, factories, activity_muted):
track = factories['music.Track']()
......
......@@ -151,20 +151,6 @@ def test_can_start_radio_for_logged_in_user(logged_in_client):
assert session.user == logged_in_client.user
def test_can_start_radio_for_anonymous_user(api_client, db, preferences):
preferences['common__api_authentication_required'] = False
url = reverse('api:v1:radios:sessions-list')
response = api_client.post(url, {'radio_type': 'random'})
assert response.status_code == 201
session = models.RadioSession.objects.latest('id')
assert session.radio_type == 'random'
assert session.user is None
assert session.session_key == api_client.session.session_key
def test_can_get_track_for_session_from_api(factories, logged_in_client):
files = factories['music.TrackFile'].create_batch(1)
tracks = [f.track for f in files]
......@@ -227,25 +213,25 @@ def test_can_start_tag_radio(factories):
radio = radios.TagRadio()
session = radio.start_session(user, related_object=tag)
assert session.radio_type =='tag'
assert session.radio_type == 'tag'
for i in range(5):
assert radio.pick() in good_tracks
def test_can_start_artist_radio_from_api(api_client, preferences, factories):
preferences['common__api_authentication_required'] = False
def test_can_start_artist_radio_from_api(
logged_in_api_client, preferences, factories):
artist = factories['music.Artist']()
url = reverse('api:v1:radios:sessions-list')
response = api_client.post(
response = logged_in_api_client.post(
url, {'radio_type': 'artist', 'related_object_id': artist.id})
assert response.status_code == 201
session = models.RadioSession.objects.latest('id')
assert session.radio_type, 'artist'
assert session.related_object, artist
assert session.radio_type == 'artist'
assert session.related_object == artist
def test_can_start_less_listened_radio(factories):
......@@ -257,6 +243,6 @@ def test_can_start_less_listened_radio(factories):
good_tracks = [f.track for f in good_files]
radio = radios.LessListenedRadio()
session = radio.start_session(user)
assert session.related_object == user
for i in range(5):
assert radio.pick() in good_tracks
......@@ -136,6 +136,20 @@ def test_changing_password_updates_secret_key(logged_in_client):
assert user.password != password
def test_can_request_password_reset(
factories, api_client, mailoutbox):
user = factories['users.User']()
payload = {
'email': user.email,
}
emails = len(mailoutbox)
url = reverse('rest_password_reset')
response = api_client.post(url, payload)
assert response.status_code == 200
assert len(mailoutbox) > emails
def test_user_can_patch_his_own_settings(logged_in_api_client):
user = logged_in_api_client.user
payload = {
......
Users can now request password reset by email, assuming
a SMTP server was correctly configured (#187)
Update
^^^^^^
Starting from this release, Funkwhale will send two types
of emails:
- Email confirmation emails, to ensure a user's email is valid
- Password reset emails, enabling user to reset their password without an admin's intervention
Email sending is disabled by default, as it requires additional configuration.
In this mode, emails are simply outputed on stdout.
If you want to actually send those emails to your users, you should edit your
.env file and tweak the EMAIL_CONFIG variable. See :ref:`setting-EMAIL_CONFIG`
for more details.
.. note::
As a result of these changes, the DJANGO_EMAIL_BACKEND variable,
which was not documented, has no effect anymore. You can safely remove it from
your .env file if it is set.
......@@ -6,6 +6,7 @@
# - DJANGO_SECRET_KEY
# - DJANGO_ALLOWED_HOSTS
# - FUNKWHALE_URL
# - EMAIL_CONFIG (if you plan to send emails)
# On non-docker setup **only**, you'll also have to tweak/uncomment those variables:
# - DATABASE_URL
# - CACHE_URL
......@@ -41,6 +42,16 @@ FUNKWHALE_API_PORT=5000
# your instance
FUNKWHALE_URL=https://yourdomain.funwhale
# Configure email sending using this variale
# By default, funkwhale will output emails sent to stdout
# here are a few examples for this setting
# EMAIL_CONFIG=consolemail:// # output emails to console (the default)
# EMAIL_CONFIG=dummymail:// # disable email sending completely
# On a production instance, you'll usually want to use an external SMTP server:
# EMAIL_CONFIG=smtp://user@:password@youremail.host:25'
# EMAIL_CONFIG=smtp+ssl://user@:password@youremail.host:465'
# EMAIL_CONFIG=smtp+tls://user@:password@youremail.host:587'
# Depending on the reverse proxy used in front of your funkwhale instance,
# the API will use different kind of headers to serve audio files
# Allowed values: nginx, apache2
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment