import smtplib
from typing import List
from asgiref.sync import async_to_sync
from django.conf import settings
from django.contrib.auth import (
login as auth_login,
logout as auth_logout,
update_session_auth_hash,
)
from django.contrib.auth.forms import AuthenticationForm
from django.contrib.auth.password_validation import validate_password
from django.core import mail
from django.core.exceptions import ValidationError as DjangoValidationError
from django.db import transaction
from django.utils.encoding import force_text
from django.utils.translation import ugettext as _
from ..core.config import config
from ..core.signals import permission_change
from ..utils.auth import (
anonymous_is_enabled,
has_perm,
user_to_collection_user,
)
from ..utils.autoupdate import (
inform_changed_data,
inform_data_collection_element_list,
)
from ..utils.cache import element_cache
from ..utils.collection import Collection, CollectionElement
from ..utils.rest_api import (
ModelViewSet,
Response,
SimpleMetadata,
ValidationError,
detail_route,
list_route,
status,
)
from ..utils.views import APIView
from .access_permissions import (
GroupAccessPermissions,
PersonalNoteAccessPermissions,
UserAccessPermissions,
)
from .models import Group, PersonalNote, User
from .serializers import GroupSerializer, PermissionRelatedField
# Viewsets for the REST API
class UserViewSet(ModelViewSet):
"""
API endpoint for users.
There are the following views: metadata, list, retrieve, create,
partial_update, update, destroy and reset_password.
"""
access_permissions = UserAccessPermissions()
queryset = User.objects.all()
def check_view_permissions(self):
"""
Returns True if the user has required permissions.
"""
if self.action in ('list', 'retrieve'):
result = self.get_access_permissions().check_permissions(self.request.user)
elif self.action == 'metadata':
result = has_perm(self.request.user, 'users.can_see_name')
elif self.action in ('update', 'partial_update'):
result = self.request.user.is_authenticated
elif self.action in ('create', 'destroy', 'reset_password', 'mass_import', 'mass_invite_email'):
result = (has_perm(self.request.user, 'users.can_see_name') and
has_perm(self.request.user, 'users.can_see_extra_data') and
has_perm(self.request.user, 'users.can_manage'))
else:
result = False
return result
def update(self, request, *args, **kwargs):
"""
Customized view endpoint to update an user.
Checks also whether the requesting user can update the user. He
needs at least the permissions 'users.can_see_name' (see
self.check_view_permissions()). Also it is evaluated whether he
wants to update himself or is manager.
"""
user = self.get_object()
# Check permissions.
if (has_perm(self.request.user, 'users.can_see_name') and
has_perm(request.user, 'users.can_see_extra_data') and
has_perm(request.user, 'users.can_manage')):
# The user has all permissions so he may update every user.
if request.data.get('is_active') is False and user == request.user:
# But a user can not deactivate himself.
raise ValidationError({'detail': _('You can not deactivate yourself.')})
else:
# The user does not have all permissions so he may only update himself.
if str(request.user.pk) != self.kwargs['pk']:
self.permission_denied(request)
# This is a hack to make request.data mutable. Otherwise fields can not be deleted.
request.data._mutable = True
# Remove fields that the user is not allowed to change.
# The list() is required because we want to use del inside the loop.
for key in list(request.data.keys()):
if key not in ('username', 'about_me'):
del request.data[key]
response = super().update(request, *args, **kwargs)
# Maybe some group assignments have changed. Better delete the restricted user cache
async_to_sync(element_cache.del_user)(user_to_collection_user(user))
return response
def destroy(self, request, *args, **kwargs):
"""
Customized view endpoint to delete an user.
Ensures that no one can delete himself.
"""
instance = self.get_object()
if instance == self.request.user:
raise ValidationError({'detail': _('You can not delete yourself.')})
self.perform_destroy(instance)
return Response(status=status.HTTP_204_NO_CONTENT)
@detail_route(methods=['post'])
def reset_password(self, request, pk=None):
"""
View to reset the password using the requested password.
"""
user = self.get_object()
if isinstance(request.data.get('password'), str):
try:
validate_password(request.data.get('password'), user=request.user)
except DjangoValidationError as errors:
raise ValidationError({'detail': ' '.join(errors)})
user.set_password(request.data.get('password'))
user.save()
return Response({'detail': _('Password successfully reset.')})
else:
raise ValidationError({'detail': 'Password has to be a string.'})
@list_route(methods=['post'])
@transaction.atomic
def mass_import(self, request):
"""
API endpoint to create multiple users at once.
Example: {"users": [{"first_name": "Max"}, {"first_name": "Maxi"}]}
"""
users = request.data.get('users')
if not isinstance(users, list):
raise ValidationError({'detail': 'Users has to be a list.'})
created_users = []
# List of all track ids of all imported users. The track ids are just used in the client.
imported_track_ids = []
for user in users:
serializer = self.get_serializer(data=user)
try:
serializer.is_valid(raise_exception=True)
except ValidationError:
# Skip invalid users.
continue
data = serializer.prepare_password(serializer.data)
groups = data['groups_id']
del data['groups_id']
db_user = User(**data)
db_user.save(skip_autoupdate=True)
db_user.groups.add(*groups)
created_users.append(db_user)
if 'importTrackId' in user:
imported_track_ids.append(user['importTrackId'])
# Now infom all clients and send a response
inform_changed_data(created_users)
return Response({
'detail': _('{number} users successfully imported.').format(number=len(created_users)),
'importedTrackIds': imported_track_ids})
@list_route(methods=['post'])
def mass_invite_email(self, request):
"""
Endpoint to send invitation emails to all given users (by id). Returns the
number of emails send.
"""
user_ids = request.data.get('user_ids')
if not isinstance(user_ids, list):
raise ValidationError({'detail': 'User_ids has to be a list.'})
for user_id in user_ids:
if not isinstance(user_id, int):
raise ValidationError({'detail': 'User_id has to be an int.'})
# Get subject and body from the response. Do not use the config values
# because they might not be translated.
subject = request.data.get('subject')
message = request.data.get('message')
if not isinstance(subject, str):
raise ValidationError({'detail': 'Subject has to be a string.'})
if not isinstance(message, str):
raise ValidationError({'detail': 'Message has to be a string.'})
users = User.objects.filter(pk__in=user_ids)
# Sending Emails. Keep track, which users gets an email.
# First, try to open the connection to the smtp server.
connection = mail.get_connection(fail_silently=False)
try:
connection.open()
except ConnectionRefusedError:
raise ValidationError({'detail': 'Cannot connect to SMTP server on {}:{}'.format(
settings.EMAIL_HOST,
settings.EMAIL_PORT)})
except smtplib.SMTPException as e:
raise ValidationError({'detail': '{}: {}'.format(e.errno, e.strerror)})
success_users = []
user_pks_without_email = []
try:
for user in users:
if user.email:
if user.send_invitation_email(connection, subject, message, skip_autoupdate=True):
success_users.append(user)
else:
user_pks_without_email.append(user.pk)
except DjangoValidationError as e:
raise ValidationError(e.message_dict)
connection.close()
inform_changed_data(success_users)
return Response({
'count': len(success_users),
'no_email_ids': user_pks_without_email})
class GroupViewSetMetadata(SimpleMetadata):
"""
Customized metadata class for OPTIONS requests.
"""
def get_field_info(self, field):
"""
Customized method to change the display name of permission choices.
"""
field_info = super().get_field_info(field)
if field.field_name == 'permissions':
field_info['choices'] = [
{
'value': choice_value,
'display_name': force_text(choice_name, strings_only=True).split(' | ')[2]
}
for choice_value, choice_name in field.choices.items()
]
return field_info
class GroupViewSet(ModelViewSet):
"""
API endpoint for groups.
There are the following views: metadata, list, retrieve, create,
partial_update, update and destroy.
"""
metadata_class = GroupViewSetMetadata
queryset = Group.objects.all()
serializer_class = GroupSerializer
access_permissions = GroupAccessPermissions()
def check_view_permissions(self):
"""
Returns True if the user has required permissions.
"""
if self.action in ('list', 'retrieve'):
result = self.get_access_permissions().check_permissions(self.request.user)
elif self.action == 'metadata':
# Every authenticated user can see the metadata.
# Anonymous users can do so if they are enabled.
result = self.request.user.is_authenticated or anonymous_is_enabled()
elif self.action in ('create', 'partial_update', 'update', 'destroy'):
# Users with all app permissions can edit groups.
result = (has_perm(self.request.user, 'users.can_see_name') and
has_perm(self.request.user, 'users.can_see_extra_data') and
has_perm(self.request.user, 'users.can_manage'))
else:
# Deny request in any other case.
result = False
return result
def update(self, request, *args, **kwargs):
"""
Customized endpoint to update a group. Send the signal
'permission_change' if group permissions change.
"""
group = self.get_object()
# Collect old and new (given) permissions to get the difference.
old_permissions = list(group.permissions.all()) # Force evaluation so the perms don't change anymore.
permission_names = request.data['permissions']
if isinstance(permission_names, str):
permission_names = [permission_names]
given_permissions = [
PermissionRelatedField(read_only=True).to_internal_value(data=perm) for perm in permission_names]
# Run super to update the group.
response = super().update(request, *args, **kwargs)
# Check status code and send 'permission_change' signal.
if response.status_code == 200:
# Delete the user chaches of all affected users
for user in group.user_set.all():
async_to_sync(element_cache.del_user)(user_to_collection_user(user))
def diff(full, part):
"""
This helper function calculates the difference of two lists:
The result is a list of all elements of 'full' that are
not in 'part'.
"""
part = set(part)
return [item for item in full if item not in part]
new_permissions = diff(given_permissions, old_permissions)
# Some permissions are added.
if len(new_permissions) > 0:
collection_elements: List[CollectionElement] = []
signal_results = permission_change.send(None, permissions=new_permissions, action='added')
for receiver, signal_collections in signal_results:
for cachable in signal_collections:
collection_elements.extend(Collection(cachable.get_collection_string()).element_generator())
inform_data_collection_element_list(collection_elements)
# TODO: Some permissions are deleted.
return response
def destroy(self, request, *args, **kwargs):
"""
Protects builtin groups 'Default' (pk=1) from being deleted.
"""
instance = self.get_object()
if instance.pk == 1:
self.permission_denied(request)
# The list() is required to evaluate the query
affected_users_ids = list(instance.user_set.values_list('pk', flat=True))
# Delete the group
self.perform_destroy(instance)
# Get the updated user data from the DB.
affected_users = User.objects.filter(pk__in=affected_users_ids)
inform_changed_data(affected_users)
return Response(status=status.HTTP_204_NO_CONTENT)
class PersonalNoteViewSet(ModelViewSet):
"""
API endpoint for personal notes.
There are the following views: metadata, list, retrieve, create,
partial_update, update, and destroy.
"""
access_permissions = PersonalNoteAccessPermissions()
queryset = PersonalNote.objects.all()
def check_view_permissions(self):
"""
Returns True if the user has required permissions.
"""
if self.action in ('list', 'retrieve'):
result = self.get_access_permissions().check_permissions(self.request.user)
elif self.action in ('metadata', 'create', 'partial_update', 'update', 'destroy'):
# Every authenticated user can see metadata and create personal
# notes for himself and can manipulate only his own personal notes.
# See self.perform_create(), self.update() and self.destroy().
result = self.request.user.is_authenticated
else:
result = False
return result
def perform_create(self, serializer):
"""
Customized method to inject the request.user into serializer's save
method so that the request.user can be saved into the model field.
"""
serializer.save(user=self.request.user)
def update(self, request, *args, **kwargs):
"""
Customized method to ensure that every user can change only his own
personal notes.
"""
if self.get_object().user != self.request.user:
self.permission_denied(request)
return super().update(request, *args, **kwargs)
def destroy(self, request, *args, **kwargs):
"""
Customized method to ensure that every user can delete only his own
personal notes.
"""
if self.get_object().user != self.request.user:
self.permission_denied(request)
return super().destroy(request, *args, **kwargs)
# Special API views
class UserLoginView(APIView):
"""
Login the user.
"""
http_method_names = ['get', 'post']
def post(self, *args, **kwargs):
# If the client tells that cookies are disabled, do not continue as guest (if enabled)
if not self.request.data.get('cookies', True):
raise ValidationError({'detail': _('Cookies have to be enabled to use OpenSlides.')})
form = AuthenticationForm(self.request, data=self.request.data)
if not form.is_valid():
raise ValidationError({'detail': _('Username or password is not correct.')})
self.user = form.get_user()
auth_login(self.request, self.user)
return super().post(*args, **kwargs)
def get_context_data(self, **context):
"""
Adds some context.
For GET requests adds login info text to context. This info text is
taken from the config. If this value is empty, a special text is used
if the admin user has the password 'admin'.
For POST requests adds the id of the current user to the context.
"""
if self.request.method == 'GET':
if config['general_login_info_text']:
context['info_text'] = config['general_login_info_text']
else:
try:
user = User.objects.get(username='admin')
except User.DoesNotExist:
context['info_text'] = ''
else:
if user.check_password('admin'):
context['info_text'] = _(
'Installation was successfully. Use {username} and '
'{password} for first login. Important: Please change '
'your password!').format(
username='admin',
password='admin')
else:
context['info_text'] = ''
# Add the privacy policy and legal notice, so the client can display it
# even, it is not logged in.
context['privacy_policy'] = config['general_event_privacy_policy']
context['legal_notice'] = config['general_event_legal_notice']
else:
# self.request.method == 'POST'
context['user_id'] = self.user.pk
user_collection = CollectionElement.from_instance(self.user)
context['user'] = user_collection.as_dict_for_user(self.user)
return super().get_context_data(**context)
class UserLogoutView(APIView):
"""
Logout the user.
"""
http_method_names = ['post']
def post(self, *args, **kwargs):
if not self.request.user.is_authenticated:
raise ValidationError({'detail': _('You are not authenticated.')})
auth_logout(self.request)
return super().post(*args, **kwargs)
class WhoAmIView(APIView):
"""
Returns the id of the requesting user.
"""
http_method_names = ['get']
def get_context_data(self, **context):
"""
Appends the user id to the context. Uses None for the anonymous
user. Appends also a flag if guest users are enabled in the config.
Appends also the serialized user if available.
"""
user_id = self.request.user.pk
if user_id is not None:
user_collection = CollectionElement.from_instance(self.request.user)
user_data = user_collection.as_dict_for_user(self.request.user)
else:
user_data = None
return super().get_context_data(
user_id=user_id,
guest_enabled=anonymous_is_enabled(),
user=user_data,
**context)
class SetPasswordView(APIView):
"""
Users can set a new password for themselves.
"""
http_method_names = ['post']
def post(self, request, *args, **kwargs):
user = request.user
if user.check_password(request.data['old_password']):
try:
validate_password(request.data.get('new_password'), user=user)
except DjangoValidationError as errors:
raise ValidationError({'detail': ' '.join(errors)})
user.set_password(request.data['new_password'])
user.save()
update_session_auth_hash(request, user)
else:
raise ValidationError({'detail': _('Old password does not match.')})
return super().post(request, *args, **kwargs)